WizardConnect/docs/dapp.md
Håvard Kittelsen b9a0e16893 feat(core): login challenge helpers, so replay protection is not optional
The sign_message extension leaves replay to the dapp, because a signature proves
key control over an exact string and carries no freshness or audience. The
previous commit said so in three places in the docs. That is exactly the failure
this repository should not ship: a login built on a bare signMessage call works in
every manual test and is a password that never expires, so documenting the
requirement mostly relocates the blame.

So the two failures that matter are structural here rather than advisory:

  verifyLoginChallenge cannot be called without `domain` and `consumeNonce`. There
  is no overload that omits them. Verifying a login without single-use enforcement
  and audience binding is not something this API can express — if you want plain
  signature verification, verifyMessageSignatureForAddress is right there and is
  honestly named.

  createLoginChallenge refuses a nonce under MIN_NONCE_LENGTH and refuses a line
  break in any field, so neither a guessable nonce nor an injected `Nonce:` line
  can reach a signed message.

Check order is deliberate: parse, domain, expiry, signature, THEN consume the
nonce. Consuming earlier would let anyone who sniffs a nonce burn it with a
garbage signature before the real user finishes signing; there is a test asserting
the nonce survives a bad signature and the genuine login still completes.

consumeNonce is a caller-supplied callback rather than a store this module owns,
because single-use enforcement is a property of the caller's database — two
replays arriving together both reach that point and only one may be told true. The
docstring says it must be atomic. createInMemoryNonceStore exists for development
and says plainly that it is per-process, so two servers behind a load balancer
would each honour the same signature once.

parseLoginChallenge is strict: unknown fields, duplicate fields, out-of-order
fields and stray lines are rejected rather than skipped, so exactly one byte
sequence parses to a given challenge. A lenient parser is where field injection
lives.

Address is optional in the message because under wallet_choice the dapp does not
yet know which key will answer. When present the proof is self-describing — a
third party reading the message alone sees which address was claimed — and
verification then requires the recovered address to match it.

NOT SIWE. The layout is deliberately similar to Sign-In With Ethereum so it reads
familiarly, but it does not claim EIP-4361 or CAIP-122 compatibility: there is no
agreed SIWX profile for Bitcoin Cash to conform to. If one lands it belongs beside
this as a second format, not as a silent change to this one.

Also adds addressesEqual() to message-signing, which compares decoded public key
hashes so prefixed CashAddr, bare CashAddr, the token-aware form and legacy base58
all compare equal for the same key.

27 tests, mostly about what must be refused: the replay, the wrong site, the stale
and future-dated challenge, four field-injection attempts, the nonce-burning
attack, the wrong key, and a cross-encoding address match.

test-cli now builds its challenge with these helpers rather than hand-rolled text,
since that is what integrators copy, and verifies the response twice — once as a
third party would and once as the server would, printing proof that replaying the
identical signature is rejected.

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

20 KiB
Raw Permalink 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 dapp–wallet 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 — use the login challenge helpers

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. A login built on a bare signMessage call works perfectly in every manual test and is a password that never expires.

So don't hand-roll the message. @wizardconnect/core provides a format that closes the three holes that matter — single-use nonce, domain binding, expiry — and an API shaped so you cannot skip them.

Server, issuing a challenge:

import { createLoginNonce } from "@wizardconnect/core";

const nonce = createLoginNonce();     // 16 random bytes, hex
await db.nonces.insert({ nonce, expiresAt: Date.now() + 300_000 });
return { nonce };

Dapp, asking for the signature:

import { createLoginChallenge } from "@wizardconnect/core";

const message = createLoginChallenge({
  domain: location.host,
  nonce,                        // from the server — never generated here
  expiresInSeconds: 300,
  statement: "Sign in to view your positions.",
  // address: known only in dapp_path mode; include it when you have it
});

const result = await manager.signMessage({ message, userPrompt: "Sign in" });

Server, verifying:

import { verifyLoginChallenge } from "@wizardconnect/core";

const verification = await verifyLoginChallenge(result.message, result.signature, {
  domain: "app.example.com",
  // MUST be atomic — two replays arrive together and only one may be told true.
  consumeNonce: (nonce) => db.nonces.deleteAndReport(nonce),
});

if (!verification.ok) return unauthorized(verification.reason);
session.user = verification.address;    // only reachable through the ok branch

domain and consumeNonce are required parameters. There is no overload without them, because verifying a login without single-use enforcement and audience binding is not a thing this API can express. createLoginChallenge likewise refuses a nonce under 16 characters, and refuses a line break in any field so no value can inject its own Nonce: line.

Checks run in a deliberate order — parse, domain, expiry, signature, then consume the nonce. A bad signature therefore cannot burn a nonce the real user is still using.

The message format is deliberately similar to Sign-In With Ethereum, but it is not SIWE or CAIP-122 and does not claim compatibility; there is no agreed SIWX profile for Bitcoin Cash. It is plain text, so any wallet that can sign a message can sign it:

app.example.com wants you to sign in.

Address: bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h
Nonce: 8f3a21c0d4b57e69
Issued At: 2026-08-06T12:00:00Z
Expires At: 2026-08-06T12:05:00Z
Statement: Sign in to view your positions.

Address is omitted under wallet_choice, where the dapp does not yet know which key will answer — pass the address from the result to verifyLoginChallenge instead. When the line is present, the proof is self-describing (a third party reading the message alone sees which address was claimed) and verification requires the recovered address to match it.

createInMemoryNonceStore() exists for development. It is per-process, so two servers behind a load balancer will each honour the same signature once — use your database in production.

If you need a different message format, build it yourself and verify with verifyMessageSignatureForAddress — but then the nonce, the domain and the expiry are all yours to get right.

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.