# 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 dapp–wallet 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, options?: { /** Session persistence config. Enabled by default (key: "wizardconnect-session", * storage: localStorage). Pass `false` to disable. */ session?: DappSessionOptions | false; }) /** Call from the RelayStatusCallback each time the relay status changes. */ updateConnection(client: RelayClient | null, status: RelayStatus): void isWalletDiscovered(): boolean /** Convenience: build a full SignTransactionRequest, send it, and optionally * cancel via AbortSignal. See "Sending a sign request" below. */ signTransaction( request: Pick, options?: { signal?: AbortSignal }, ): Promise /** Low-level: send a fully constructed sign request. */ sendSignRequest(request: SignTransactionRequest): Promise /** Cancel an in-flight sign request by sequence number. Works for both * sign_transaction_request and sign_message_request. */ sendSignCancel(sequence: number, reason?: string): Promise /** Ask the wallet to sign a plain message, proving key control. Resolves only * after the result is verified. See "signMessage" below. */ signMessage( request: { message: string; path?: PathName; addressIndex?: number; userPrompt?: string; scheme?: MessageSignatureScheme }, options?: { signal?: AbortSignal }, ): Promise /** Whether the wallet advertised sign_message for this mode and scheme. */ walletSupportsSignMessage(mode?: MessageSigningMode, scheme?: MessageSignatureScheme): boolean /** Low-level: send a sign_message request and get the raw, UNVERIFIED response. */ sendSignMessageRequest(request: SignMessageRequest): Promise /** Get the next sequence number (for manual request construction). */ nextSequence(): number /** Send a UserDisconnect courtesy message to the wallet. */ sendDisconnect(message?: string): Promise /** Get raw PathXpub[] received in wallet_ready (for caching). */ getSessionPaths(): PathXpub[] /** Restore cached xpub paths — enables getPubkey() without wallet_ready. */ restoreSessionPaths(paths: PathXpub[]): void // Session persistence (see "Session persistence" section below) attachRelay(relay: DappRelayResult): void loadStoredSession(): StoredSession | null clearStoredSession(): 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 // 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); }, ); // Persist relay credentials and auto-save walletPublicKey on key exchange dappMgr.attachRelay(relay); // 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 The `signTransaction` convenience method auto-fills `action`, `sequence`, and `time`: ```typescript const response = await dappMgr.signTransaction({ transaction: { transaction: txHex, sourceOutputs, userPrompt: "Confirm swap", broadcast: true, }, inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex] }); console.log("Signed tx:", response.signedTransaction); ``` #### Cancellation via AbortSignal Pass an `AbortSignal` to automatically cancel the request when aborted. This sends `sign_cancel` to the wallet and rejects the promise with an `AbortError`: ```typescript const controller = new AbortController(); cancelButton.onclick = () => controller.abort("User cancelled"); try { const response = await dappMgr.signTransaction( { transaction: { ... }, inputPaths: [...] }, { signal: controller.signal }, ); } catch (err) { if (err.name === "AbortError") { console.log("User cancelled the signature request"); } } ``` #### Low-level: sendSignRequest For full control over the request, use `sendSignRequest` directly: ```typescript const seq = dappMgr.nextSequence(); const request: SignTransactionRequest = { action: RelayMsgAction.SignTransactionRequest, sequence: seq, time: Math.floor(Date.now() / 1000), transaction: { ... }, inputPaths: [[0, "receive", 0]], }; const response = await dappMgr.sendSignRequest(request); // Cancel with: await dappMgr.sendSignCancel(seq, "reason"); ``` ### Disconnecting ```typescript // Dapp-initiated: send courtesy message, clear session, then tear down the relay dappMgr.clearStoredSession(); 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 persistence Session persistence is **enabled by default**. The manager automatically: - **On construction**: restores xpub paths from storage so `getPubkey()` works immediately. - **On `walletready`**: saves `walletName`, `walletIcon`, and xpub `paths` to storage. The default storage key is `"wizardconnect-session"` and the default backend is `localStorage`. #### Saving session data Call `attachRelay()` after `initiateDappRelay()` to persist relay credentials and automatically save the wallet public key when key exchange completes: ```typescript const relay = initiateDappRelay(callback); dappMgr.attachRelay(relay); // saves credentials + auto-saves walletPublicKey // On disconnect: dappMgr.clearStoredSession(); ``` #### Loading for reconnection Use `loadStoredSession()` on an existing manager, or the standalone `loadSession()` when you need to read the session before creating the manager (e.g. to get relay credentials for `initiateDappRelay`): ```typescript import { loadSession } from "@wizardconnect/dapp"; const session = loadSession(); // uses default key and localStorage if (session?.walletPublicKey) { const relay = initiateDappRelay(callback, { existingCredentials: session }); } ``` #### Configuration ```typescript // Default: session enabled, key "wizardconnect-session", localStorage const mgr = new DappConnectionManager("My Dapp"); // Custom key: const mgr = new DappConnectionManager("My Dapp", undefined, { session: { key: "my-app-session" }, }); // Custom storage backend (e.g. for React Native or SSR): const mgr = new DappConnectionManager("My Dapp", undefined, { session: { storage: myCustomStorage }, }); // Disable session persistence: const mgr = new DappConnectionManager("My Dapp", undefined, { session: false, }); ``` The `SessionStorage` interface matches the Web Storage API: ```typescript interface SessionStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; } ``` #### Manual path management For advanced use cases, `getSessionPaths()` and `restoreSessionPaths()` are still available: ```typescript const paths = dappMgr.getSessionPaths(); // raw PathXpub[] from wallet_ready dappMgr.restoreSessionPaths(paths); // re-populate pubkeyState from cached paths ``` `restoreSessionPaths` throws if any xpub string is invalid. ### 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. ## signMessage Proves the user controls a key, without a transaction. The signature is a standard "Bitcoin Signed Message" signature, so anyone can verify it from the message, the signature and the address alone — including from an OP_RETURN via a block explorer, or by pasting it into Electron Cash's Verify Message. ```typescript import { MODE_WALLET_CHOICE } from "@wizardconnect/core"; // 1. Check support before offering the feature. if (!manager.walletSupportsSignMessage(MODE_WALLET_CHOICE)) { return; // hide the login button rather than offer one that fails } // 2. Get a single-use nonce from your server. See "Replay" below — this is not optional. const nonce = await fetch("/auth/nonce").then((r) => r.text()); // 3. Ask the wallet. Omitting path/addressIndex lets the wallet pick the key, // so no xpub is needed. const result = await manager.signMessage({ message: `${location.host} wants you to sign in.\nnonce=${nonce}`, userPrompt: "Sign in", }); // 4. result is already verified. Send it to your server, which retires the nonce. await fetch("/auth/verify", { method: "POST", body: JSON.stringify(result) }); ``` ### What is verified before it resolves `signMessage()` never hands back what the wallet said unchecked. It rejects unless: 1. the signature recovers over the message that was actually sent; 2. `publicKey` is the key that signed it; 3. `address` is that key's address; 4. and, when the dapp named a path it can derive, the signer is **exactly** the key it asked for. Without (4) a wallet could answer with a signature from any key it liked and a naive dapp would accept it as the identity it asked about. It is the library's job, not each integrator's. ### keyBinding ```typescript type MessageKeyBinding = | { checked: true; path: PathName; addressIndex: number } | { checked: false; reason: "wallet_chose_key" | "path_not_derivable" }; ``` `checked: false` does **not** mean unverified — the signature always is. It means there was no dapp-chosen key to compare against, so the proven address is the wallet's choice rather than the dapp's selection: - `wallet_chose_key` — the dapp omitted `path`/`addressIndex`. Normal for identity flows; the address in the result *is* the identity. - `path_not_derivable` — the dapp named an extension path (`stealth_scan`, say) that it has no derivation rule for. A dapp storing an identity should care about the difference. Naming a derivable path with no xpub available is an error, not an unchecked result — call `signMessage` after `wallet_ready`. ### Replay — your responsibility A signature proves key control over that exact text. It has **no freshness and no audience**: it is valid forever, to everyone, and a captured one is replayable indefinitely. Nothing in this library can change that today. **Use a single-use, server-issued nonce inside `message`, and retire it after one use.** A login built without one is permanently replayable by anyone who ever sees a signature — including the relay operator, or anyone reading it out of an OP_RETURN. Including the origin and an issued-at timestamp is also good practice, but the nonce is the part that actually stops replay. ### Cancellation Pass an `AbortSignal`, exactly as with `signTransaction`. There is no default timeout: a user approving on a phone may take a while, and picking an arbitrary deadline for them is worse than letting the dapp decide. ```typescript const controller = new AbortController(); const promise = manager.signMessage({ message }, { signal: controller.signal }); // user closes the dialog: controller.abort("User cancelled"); ``` ### Verifying elsewhere `@wizardconnect/core` exports the verification helpers, so a server (or any third party) can check a signature without a session: ```typescript import { verifyMessageSignatureForAddress, messageSignatureAddress, } from "@wizardconnect/core"; verifyMessageSignatureForAddress(message, signature, address); // the Electron Cash-equivalent check messageSignatureAddress(message, signature); // recover the address instead ``` Accepts CashAddr with or without a prefix, and legacy base58. Rejects P2SH — no message signature can prove control of a script hash. --- ## 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, ... }); }); ``` ## Using with React For React dapps, prefer the `useWizardConnect` hook from `@wizardconnect/react` over managing the relay lifecycle manually. The hook handles session persistence, auto-reconnect, and relay cleanup automatically. See [react.md](react.md). Dapps that need a custom wallet adapter (e.g. Cauldron, Moria) can use the hook and wrap the returned `manager` in their adapter: ```typescript const wc = useWizardConnect({ dappName: "My Dapp" }); useEffect(() => { if (!wc.manager) return; const wallet = new MyWalletAdapter(wc.manager); // dispatch wallet to your store }, [wc.manager]); ``` The `DappConnectionManager` is created by the hook; the adapter receives it rather than creating its own.