# Wallet integration Wallet integration uses `@wizardconnect/wallet`. The wallet implements the `WalletAdapter` interface and hands it to `WalletConnectionManager`, which handles everything else. ## WalletAdapter ```typescript interface WalletAdapter { walletName: string; // shown in the dapp's connection UI walletIcon: string; // URL or data-URI, shown in the dapp's connection UI /** * Return the relay identity private key for this session. * May be ephemeral (random per session) or stable (HD-derived) — both work. * The dapp learns the wallet's public key from the wallet_ready message, * so stability across restarts is not required. */ getRelayPrivateKey(): Uint8Array; /** Returns the compressed 33-byte secp256k1 public key at path/index. */ getPublicKey(path: DerivationPath, index: bigint): Uint8Array; /** Returns the BIP32 base58-encoded xpub for the given derivation path. * The dapp derives all addresses from this — no further pubkey requests needed. */ getXpub(path: DerivationPath): string; /** Sign the transaction. May show approval UI to the user. * Called when the wallet has received and validated a sign_transaction_request. */ signTransaction(request: SignTransactionRequest): Promise; /** Optional: additional paths to include in the session (e.g. stealth_scan). */ getAdditionalPaths?(): PathXpub[]; /** Optional: extension data for the session handshake. */ getExtensions?(): Record; /** Optional: sign a plain message to prove key control. Implementing this is what * advertises the `sign_message` extension to dapps. See § signMessage below. */ signMessage?(request: SignMessageRequest): Promise; /** Optional: key-selection modes signMessage supports. Defaults to [MODE_DAPP_PATH]. */ signMessageModes?(): MessageSigningMode[]; } ``` ### DerivationPath ```typescript enum DerivationPath { Receive = 0, // m/44'/145'/0'/0 — external (receive) addresses Change = 1, // m/44'/145'/0'/1 — internal (change) addresses Cauldron = 7, // m/44'/145'/0'/7 — DeFi/Cauldron addresses } ``` The numeric values are wallet-internal; the protocol uses names (`receive`, `change`, `defi`). `childIndexOfPath(path)` and `pathOfChildIndex(index)` convert between them. ### SignTransactionResult ```typescript interface SignTransactionResult { signedTransactionHex: string; } ``` ### SignMessageResult ```typescript interface SignMessageResult { signature: string; // base64 — build it with signBitcoinMessage() addressPrefix?: CashAddrPrefix; // default "bitcoincash"; set on testnet path?: PathName; // which key was used, when the wallet can say addressIndex?: number; } ``` Deliberately just the signature. The public key and address are **not** asked for, because both are recoverable from the signature and `WalletConnectionManager` derives them that way — so the three values in the response can never disagree, and an adapter cannot accidentally claim a proof about an address it did not prove. ## WalletConnectionManager ```typescript class WalletConnectionManager extends EventEmitter { constructor(adapter: WalletAdapter) // Connect to a dapp. Returns a stable connection ID. // If a connection for this URI already exists, returns the existing ID. connect(uri: string): string // Tear down a specific connection, sending a UserDisconnect courtesy message. disconnect(connectionId: string): void // Tear down all connections. disconnectAll(): void // Snapshot of all connections for UI rendering. getConnections(): Record // Send the signed transaction back to the dapp. sendSignResponse(connectionId: string, sequence: number, signedTx: string): Promise // Send an error back to the dapp (user rejected, signing failed, etc.) sendSignError(connectionId: string, sequence: number, errorMessage: string): Promise // Send a message signature back to the dapp. Derives the public key and address // from the signature; throws if it does not match the requested message and key. sendSignMessageResponse(connectionId: string, sequence: number, result: SignMessageResult): Promise // Refuse a message-signing request (user rejected, unsupported, etc.) sendSignMessageError(connectionId: string, sequence: number, errorMessage: string): Promise // Events on("connectionStatusChanged", (id: string, status: RelayStatus) => void) on("pendingSignRequest", (req: PendingSignRequest) => void) on("pendingSignMessageRequest", (req: PendingSignMessageRequest) => void) on("connectionsChanged", () => void) on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void) on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages } ``` ### RelayConnectionState ```typescript interface RelayConnectionState { id: string; uri: string; status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" } label: string; // dapp name once known, otherwise "Connecting..." dappName: string | null; dappIcon: string | null; connectedAt: number; // Unix ms } ``` ### PendingSignRequest ```typescript interface PendingSignRequest { connectionId: string; request: SignTransactionRequest; } ``` ## Connection lifecycle ### connect() 1. A unique `connectionId` is generated. 2. `initiateWalletRelay(statusCallback, { uri, walletPrivateKey })` is called. 3. The relay decodes the URI, extracts the dapp's public key and secret, and connects. 4. On the first `"connected"` status, `onConnected()` is called. ### onConnected() 1. `walletReadySentThisCycle` is reset to `false`. 2. A notification processor interval is started (1 second, for retry on send errors). 3. The wallet polls until `client.isKeyExchangeComplete()` (key exchange with dapp done). 4. `pushWalletReady()` is called. ### pushWalletReady() Sends `wallet_ready` with: - `supported_protocols: ["hdwalletv1"]` - `wallet_name`, `wallet_icon` from the adapter. - `session["hdwalletv1"]`: one `{ name, xpub }` per `DerivationPath` (receive/change/defi), plus any additional paths from `adapter.getAdditionalPaths()` and extension data from `adapter.getExtensions()`. See [extensions.md](extensions.md). - `dapp_discovered`: whether the dapp was seen in this runtime session. The message is pushed to a per-connection `notificationQueue` and flushed immediately. Retry is handled by the interval processor — if `relay()` throws (e.g. network drop), the message stays in the queue and is retried on the next tick. ### Receiving dapp_ready ``` wallet_discovered=false → reset walletReadySentThisCycle, call pushWalletReady() again wallet_discovered=true → set dappDiscovered=true, no further action ``` `dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them. ### Receiving disconnect When a `disconnect` message arrives from the dapp: 1. The `remoteDisconnect` event is emitted with `(connectionId, reason, message)`. 2. The connection is cleaned up without sending a reply disconnect. ### Receiving sign_transaction_request The wallet emits `pendingSignRequest` with the `connectionId` and the full request. The host application is responsible for: 1. Queueing or displaying the request. 2. Getting user approval. 3. Calling `sendSignResponse(connectionId, sequence, signedTxHex)` or `sendSignError(connectionId, sequence, errorMessage)`. The wallet library does not auto-sign or auto-reject anything. **Deduplication:** The dapp re-sends pending sign requests when the wallet reconnects (see [protocol docs](protocol.md#re-delivery-on-reconnect)). To prevent duplicate approval dialogs, `WalletConnectionManager` tracks in-flight sequences and silently drops requests whose `sequence` has already been emitted. The guard is cleared when a response is sent (`sendSignResponse` / `sendSignError`) or a `sign_cancel` is received. **SIGHASH enforcement:** The wallet **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the [SIGHASH requirement](protocol.md#sighash-requirement-security-critical) section in the protocol docs for the security rationale. Because the dapp specifies `inputPaths`, the wallet trusts the dapp's key selection — `SIGHASH_ALL` is what makes this safe (a wrong-key signature is simply invalid and cannot be repurposed). ## signMessage Optional. Implementing it advertises the `sign_message` extension; omitting it leaves the wallet working exactly as before, and a dapp that asks anyway gets an explicit error rather than silence. ```typescript import { signBitcoinMessage, MODE_DAPP_PATH, MODE_WALLET_CHOICE } from "@wizardconnect/core"; signMessageModes: () => [MODE_DAPP_PATH, MODE_WALLET_CHOICE], async signMessage(request) { const index = request.addressIndex ?? 0; const privateKey = derivePrivateKey(request.path ?? "receive", index); return { signature: signBitcoinMessage(request.message, privateKey), path: request.path ?? "receive", addressIndex: index, }; }, ``` Then approve it like a transaction: ```typescript manager.on("pendingSignMessageRequest", async ({ connectionId, request }) => { const approved = await showMessageSigningDialog(request); // your UI if (!approved) { await manager.sendSignMessageError(connectionId, request.sequence, "User rejected"); return; } const result = await adapter.signMessage!(request); await manager.sendSignMessageResponse(connectionId, request.sequence, result); }); ``` ### Requirements **Sign the message verbatim.** UTF-8, no trimming, no Unicode normalisation. The signature must verify against exactly the text the dapp displayed; re-encoding it produces a signature that verifies against nothing. **Use `signBitcoinMessage()`.** The magic string and both compactSize length prefixes are what third-party verifiers check. This repository tests them against a real Electron Cash install (`npm run test:compat --workspace @wizardconnect/core`) and against committed vectors Electron Cash generated; a hand-rolled reimplementation in a wallet gets neither. **Choose deterministically under `wallet_choice`.** A dapp treats the returned address as a durable identity. If the wallet picks a different key per connection, a returning user is unrecognisable and login breaks. Only advertise `MODE_WALLET_CHOICE` if the choice is stable across restarts. Consider a dedicated identity key rather than `receive/0`, so proving identity does not link it to the user's main address history. **Echo the path used.** It is how a dapp learns which key answered under `wallet_choice`, and it lets `sendSignMessageResponse` check the signature against the key that should have produced it. ### Display requirements `sendSignMessageResponse` verifies the cryptography. It cannot verify that the user understood what they signed, which is the wallet's job: - **Show `request.message` in full, verbatim.** Do not truncate it. The user is signing every byte, including trailing whitespace and newlines. - **`request.userPrompt` is dapp-supplied and unsigned.** Render it as clearly subordinate to the message and attribute it to the dapp. Presented as equal, it lets a dapp caption hostile text reassuringly. - **Flag adversarial text.** Bidirectional overrides, zero-width characters, control characters and very long messages can all make signed text display as something other than what it is. - Message signing is safer to approve than a dummy transaction — the magic prefix guarantees the digest can never coincide with a transaction sighash, so no message signature can ever authorise a spend. That is a reason to prefer it over "sign this unspendable transaction" patterns, not a reason to show the user less. --- ## Minimal example ```typescript import { WalletConnectionManager } from "@wizardconnect/wallet"; import type { WalletAdapter, DerivationPath } from "@wizardconnect/wallet"; class MyAdapter implements WalletAdapter { walletName = "My Wallet"; walletIcon = ""; getRelayPrivateKey() { return crypto.getRandomValues(new Uint8Array(32)); } getPublicKey(path: DerivationPath, index: bigint) { /* ... */ } getXpub(path: DerivationPath) { /* ... */ } async signTransaction(request) { // Show approval UI, sign with SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS, return hex. // See protocol.md "SIGHASH requirement" — other sighash flags MUST be rejected. return { signedTransactionHex: "..." }; } } const manager = new WalletConnectionManager(new MyAdapter()); // When user scans a QR code: const connId = manager.connect("wiz://?p=...&s=..."); // When a sign request arrives: manager.on("pendingSignRequest", async ({ connectionId, request }) => { try { const result = await showApprovalUI(request); await manager.sendSignResponse(connectionId, request.sequence, result.signedTransactionHex); } catch { await manager.sendSignError(connectionId, request.sequence, "User rejected"); } }); // When the dapp disconnects: manager.on("remoteDisconnect", (id, reason, message) => { console.log(`Dapp disconnected: ${reason}`, message); store.dispatch(setConnections(manager.getConnections())); }); // For Redux / UI updates: manager.on("connectionsChanged", () => { store.dispatch(setConnections(manager.getConnections())); }); ```