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>
20 KiB
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:
- Remote disconnect: the wallet sent a
disconnectmessage (any reason). - Protocol mismatch:
handleWalletReadyfound no overlap between the dapp's and wallet'ssupported_protocols. The dapp automatically sends aProtocolMismatchdisconnect 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: saveswalletName,walletIcon, and xpubpathsto 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:
- the signature recovers over the message that was actually sent;
publicKeyis the key that signed it;addressis that key's address;- 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 omittedpath/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.