WizardConnect/docs/dapp.md
Håvard Kittelsen b40da7230a feat: add the sign_message hdwalletv1 extension
Wires the Bitcoin Signed Message primitives into the protocol: a dapp can ask a
wallet to prove control of a key, and gets back a signature any third party can
check from the message and an address alone.

WIRE FORMAT

SignMessageRequest extends WcSignMessageRequest from @bch-wc2/interfaces — the
interface wallets already implement for WalletConnect — so the request object can
be handed straight to an existing WC2 signMessage handler. hdwalletv1 adds only
the optional key selection, mirroring how SignTransactionRequest wraps
WcSignTransactionRequest and adds inputPaths. That also brings `userPrompt` along,
which is dapp-supplied and unsigned; docs/wallet.md says to render it as
subordinate to the message, because presented as equal it lets a dapp caption
hostile text reassuringly.

Two key-selection modes, advertised separately from `schemes` because they are
independent capabilities — a wallet may sign with a dapp-named path yet have no
notion of a stable identity key, and a dapp that checked only for the extension
would find that out after the user clicked a login button:

  dapp_path      dapp sends path + addressIndex; needs that path's xpub
  wallet_choice  dapp sends neither; wallet picks and returns the address

wallet_choice exists because requiring an xpub to prove control of one key means
sharing the user's whole address history. It is the privacy-preserving option for
identity, and the one the WC2 interface already implies. A wallet advertising it
must choose deterministically or a returning user is unrecognisable.

The response is a discriminated union on `error`, so a caller cannot read
`.address` off a rejection and treat an empty string as an identity. publicKey and
address are required on success: under wallet_choice they are the dapp's only way
to learn which key answered.

WALLET SIDE

signMessage is optional; implementing it is what advertises the extension, so the
handshake cannot claim support an adapter does not have. An adapter that declares
the key itself wins — the automatic advertisement never overwrites it.

SignMessageResult carries only the signature. The public key and address are
recoverable from it and the manager derives them that way, so the three values
cannot disagree and an adapter cannot claim a proof about an address it did not
prove. The manager then compares the recovered key against the adapter's own key
for the path. Recovery alone cannot catch a signature over the wrong text — it
succeeds and yields some other key — so that comparison is what turns a wallet-side
derivation or encoding bug into an error at the call site rather than an opaque
rejection across the relay.

Requests are answered rather than dropped: an unsupported scheme, an unsupported
mode, a malformed request or a wallet with no signMessage all produce an error
response, checked before the user is prompted so nobody approves a signature we
cannot produce.

Dedup shares the sequence set with transaction signing. That is correct rather
than convenient: every sequence comes from one per-connection counter
(RelayClient.nextSequence), so a sequence identifies a request regardless of kind
— which is also what lets one sign_cancel cancel either.

DAPP SIDE

signMessage() resolves only after this library has verified the result: the
signature recovers over the message that was sent, publicKey is the key that
signed, address is that key's address, and — when the dapp named a path it can
derive — the signer is exactly the key it asked for. Anything inconsistent
rejects. Without that last check a wallet could answer with a signature from any
key and a naive dapp would accept it as the identity it asked about.

keyBinding reports whether that comparison happened, because "the wallet chose a
key" and "this is the key I asked for" are different claims and only one is an
identity the dapp selected. A derivable path with no xpub available is an error,
not an unchecked result.

No default timeout: cancellation is explicit via AbortSignal, matching
signTransaction. Picking a deadline for a user approving on a phone is worse than
letting the dapp decide.

EXTENSION SHAPE

Actions live in RelayMsgAction and are handled by the managers, rather than riding
the generic message events described in docs/extensions.md § 3. That is a new
pattern, not an existing convention — the only prior enum-plus-advertisement
capability is `chunk`, which is transport-level and outside the hdwalletv1
extension system entirely. It is documented as new under § First-party
extensions: third-party extensions define their own actions and are handled by the
host app; capabilities this library ships get manager support, because otherwise
every consumer hand-rolls the plumbing for a feature we already implement.

TESTS

24 wallet, 26 dapp, and 8 over a live relay. The integration test matters most:
NIP-17 gift wrapping, JSON encoding, relay storage and replay all sit between the
two sides, and it asserts the message arrives byte-identical, that a multi-byte
message is not re-encoded in transit, that a replayed request prompts once, and
that the resulting signature verifies from the address alone. makeTestAdapter
gained a real signMessage — it already holds HD keys, so there was nothing to
fake.

test-cli gains `--sign-message [dapp_path|wallet_choice]` and a wallet-side
approval path, so the flow can be driven by hand against a real wallet. It signs
a plain test message, not a login: a login needs a single-use nonce, a domain and
an expiry, and signing something that merely looks like one would be a bad
pattern to copy.

Docs: protocol.md, extensions.md, wallet.md, dapp.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:32:02 +02:00

17 KiB
Raw Blame History

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.

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<SignTransactionRequest, "transaction" | "inputPaths">,
    options?: { signal?: AbortSignal },
  ): Promise<SignTransactionResponse>

  /** Low-level: send a fully constructed sign request. */
  sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>

  /** 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<void>

  /** 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<VerifiedMessageSignature>

  /** 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<SignMessageSuccess>

  /** Get the next sequence number (for manual request construction). */
  nextSequence(): number

  /** Send a UserDisconnect courtesy message to the wallet. */
  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

  // 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 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:

// 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

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

dappMgr.on("walletready", (msg) => {
  console.log("Wallet:", msg.wallet_name);
  // pubkeyState is now populated with xpub nodes.
  // You can start deriving addresses.
});

Deriving addresses

// Get the first receive address pubkey:
const RECEIVE = 0;
const pubkey = dappMgr.getPubkey(RECEIVE, 0n);  // derives from xpub if needed

See pubkey-derivation.md for full details.

Sending a sign request

The signTransaction convenience method auto-fills action, sequence, and time:

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:

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:

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

// 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:

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):

import { loadSession } from "@wizardconnect/dapp";

const session = loadSession(); // uses default key and localStorage
if (session?.walletPublicKey) {
  const relay = initiateDappRelay(callback, { existingCredentials: session });
}

Configuration

// 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:

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:

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.

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

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.

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:

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:

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.

Dapps that need a custom wallet adapter (e.g. Cauldron, Moria) can use the hook and wrap the returned manager in their adapter:

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.