WizardConnect/docs/dapp.md
Dagur Valberg Johannsson 08ea3da6aa
Re-send signature request on ready
User may need some time to open their wallet to approve signature
request and miss it.

If we have a active request, re-send it if we see a
wallet_ready signal, suggesting the wallet was just opened.
2026-04-03 13:22:40 +02:00

260 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Dapp integration
Dapp integration uses `@wizardconnect/dapp` (for session management and pubkey state) together
with `@wizardconnect/core` (for the relay connection and URI generation).
## DappConnectionManager
Manages a single dappwallet session. Handles the handshake, xpub storage, and sign request
round-trips. Most dapps only ever have one active session at a time.
```typescript
class DappConnectionManager extends EventEmitter {
readonly pubkeyState: DappPubkeyStateManager;
walletName: string | null;
walletIcon: string | null;
/** The agreed protocol name after handshake, e.g. "hdwalletv1". Null until wallet_ready. */
protocol: string | null;
constructor(dappName?: string, dappIcon?: string)
/** Call from the RelayStatusCallback each time the relay status changes.
* Attaches the message listener exactly once (on first client seen).
* Triggers onConnected() on each "connected" event. */
updateConnection(client: RelayClient | null, status: RelayStatus): void
isWalletDiscovered(): boolean
/** Get the next sequence number for a SignTransactionRequest. */
nextSequence(): number
/** Send a sign request and wait for the wallet's response.
* Rejects if the wallet returns an error or if the connection drops. */
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
/** Send a UserDisconnect courtesy message to the wallet.
* Caller is responsible for calling dappRelay.cleanup() afterwards. */
sendDisconnect(message?: string): Promise<void>
/** Get raw PathXpub[] received in wallet_ready (for caching). */
getSessionPaths(): PathXpub[]
/** Restore cached xpub paths — enables getPubkey() without wallet_ready. */
restoreSessionPaths(paths: PathXpub[]): void
// Events
on("walletready", (msg: WalletReadyMessage) => void)
on("messagesent", (msg: ProtocolMessage) => void)
on("messagereceived", (msg: ProtocolMessage) => void)
on("disconnect", (reason: DisconnectReason, message: string | undefined) => void)
}
```
The `"messagereceived"` event fires for **all** protocol messages, including extension-defined
actions. Use it to handle custom messages from wallet extensions. See
[extensions.md](extensions.md) for the extension system and graceful degradation patterns.
## Pubkey state — convenience delegation
`DappConnectionManager` delegates to `pubkeyState` for all pubkey operations. These methods
are also available directly on the manager:
```typescript
// Get a pubkey (derives on demand from xpub if not cached)
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined
// Get all cached pubkeys for a path
getPubkeys(childIndex: number): Map<bigint, Uint8Array>
// Get/set the current address index for a path
getAddressIndex(childIndex: number): bigint
setAddressIndex(childIndex: number, index: bigint): void
// Get a smart "next index to use" (see pubkey-derivation.md)
getIndexToUse(childIndex: number, options?: { index?: bigint; reuseLast?: boolean }): bigint
// Get the min/max indices seen for a path
getIndexRange(childIndex: number): { min?: bigint; max?: bigint }
// Remove a used change address from the gap-fill queue
removeFromChangeQueue(index: bigint): void
// Get the stored xpub node (after wallet_ready)
getXpubNode(childIndex: number): HdPublicNodeValid | undefined
// Get raw PathXpub[] from wallet_ready (for caching)
getSessionPaths(): PathXpub[]
// Restore cached PathXpub[] — populates pubkeyState so getPubkey works without wallet_ready
restoreSessionPaths(paths: PathXpub[]): void
```
Child index values: `0` = receive, `1` = change, `7` = defi (Cauldron). These are
internal to the dapp layer; use `childIndexOfPathName()` to convert from `PathName` if needed.
It returns `undefined` for extension path names — callers should skip those.
## Session lifecycle
### Initial connect
```typescript
import { initiateDappRelay } from "@wizardconnect/core";
import { DappConnectionManager } from "@wizardconnect/dapp";
const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.png");
const relay = initiateDappRelay(
(payload) => {
dappMgr.updateConnection(payload.client, payload.status);
// also update your own UI state here (connected/disconnected indicator)
},
{ explicitRelayUrls: ["wss://relay.cauldron.quest:443"] },
);
// Show relay.uri as a QR code for the wallet to scan.
console.log("Scan this URI:", relay.uri);
```
### After wallet connects
```typescript
dappMgr.on("walletready", (msg) => {
console.log("Wallet:", msg.wallet_name);
// pubkeyState is now populated with xpub nodes.
// You can start deriving addresses.
});
```
### Deriving addresses
```typescript
// Get the first receive address pubkey:
const RECEIVE = 0;
const pubkey = dappMgr.getPubkey(RECEIVE, 0n); // derives from xpub if needed
```
See [pubkey-derivation.md](pubkey-derivation.md) for full details.
### Sending a sign request
```typescript
const seq = dappMgr.nextSequence();
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: seq,
time: Math.floor(Date.now() / 1000),
transaction: {
transaction: { inputs, outputs, version: 2, locktime: 0 },
sourceOutputs,
userPrompt: "Confirm swap",
broadcast: true,
},
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex]
};
try {
const response = await dappMgr.sendSignRequest(request);
console.log("Signed tx:", response.signedTransaction);
} catch (err) {
console.error("Signing failed:", err.message);
}
```
`sendSignRequest` returns a Promise that resolves when the wallet sends back a
`sign_transaction_response` with the matching `sequence`. It rejects if the wallet sends an
error response.
### Disconnecting
```typescript
// Dapp-initiated: send courtesy message, then tear down the relay
await dappMgr.sendDisconnect("user closed the tab");
relay.cleanup();
// Listen for wallet-initiated disconnect or protocol mismatch:
dappMgr.on("disconnect", (reason, message) => {
if (reason === DisconnectReason.ProtocolMismatch) {
console.error("Protocol mismatch:", message);
} else {
console.log("Wallet disconnected:", reason, message);
}
relay.cleanup();
});
```
The `disconnect` event fires in two cases:
1. **Remote disconnect**: the wallet sent a `disconnect` message (any reason).
2. **Protocol mismatch**: `handleWalletReady` found no overlap between the dapp's and wallet's
`supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the
wallet before emitting the event.
### Session path persistence
After `wallet_ready`, the manager stores the raw `PathXpub[]` from the wallet. Use
`getSessionPaths()` to retrieve them (e.g. for caching in localStorage). On a subsequent
page load, restore them with `restoreSessionPaths()` so `getPubkey()` works immediately
without waiting for the wallet to reconnect:
```typescript
// After wallet_ready — save for later
const paths = dappMgr.getSessionPaths();
localStorage.setItem("myapp-paths", JSON.stringify(paths));
// On page load — restore before wallet reconnects
const cached = JSON.parse(localStorage.getItem("myapp-paths") ?? "null");
if (cached) {
dappMgr.restoreSessionPaths(cached);
// getPubkey() now works without wallet_ready
}
```
Throws if any xpub string is invalid (corrupt cached data should be cleared).
### Reconnection
`updateConnection()` is called on every relay status change. When `status.status === "connected"`,
it calls `onConnected()` which waits for key exchange and then sends a fresh `dapp_ready`. The
`walletDiscovered` flag carries over reconnects (it is only reset by creating a new manager),
so the correct `wallet_discovered` value is sent on each reconnect.
When `wallet_ready` is received and there are pending (unresponded) sign requests, the manager
automatically re-sends them. This handles the case where the user triggers a signature in the
dapp before the wallet app is open — the relay's time filter would otherwise discard the
original request. Dapps do not need to handle this manually; `sendSignRequest` promises remain
valid across reconnects.
## Using initiateDappRelay without DappConnectionManager
If you need lower-level control (e.g., in the test-cli), you can work directly with the
`RelayClient` and handle `wallet_ready` manually:
```typescript
const relay = initiateDappRelay(statusCallback, options);
relay.events.on("keyexchangecomplete", async (walletPubkey) => {
// Key exchange done — wait for relay client to be fully ready
while (!relay.client.isKeyExchangeComplete()) {
await sleep(50);
}
relay.client.on("message", (msg) => {
if (isDappReadyMessage(msg)) { /* ... */ }
if (isWalletReadyMessage(msg)) { /* ... */ }
});
await relay.client.relay({ action: RelayMsgAction.DappReady, ... });
});
```
## Cauldron (cauldron-beta) implementation notes
Cauldron uses `DappConnectionManager` via a vendored adapter in `src/relay/RelayWalletDapp.ts`.
This adapter wraps `DappConnectionManager` to implement Cauldron's internal `Wallet` interface.
Key design points:
- `DappConnectionManager` is created per connection session (not a singleton).
- `updateConnection()` is called from the relay status callback.
- `getXpubNode(childIndex)` and `getPubkey(childIndex, index)` are the primary access patterns.
- Child indices are used internally (0/1/7); `childIndexOfPathName()` converts from PathName
when processing `wallet_ready` data.