WizardConnect/docs/dapp.md

494 lines
17 KiB
Markdown
Raw Normal View History

2026-02-26 11:19:47 +01:00
# 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.
```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;
})
2026-02-26 11:19:47 +01:00
/** Call from the RelayStatusCallback each time the relay status changes. */
2026-02-26 11:19:47 +01:00
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>
2026-02-26 11:19:47 +01:00
/** Low-level: send a fully constructed sign request. */
2026-02-26 11:19:47 +01:00
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
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
/** 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>
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
/** 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. */
2026-02-26 11:19:47 +01:00
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
2026-02-26 11:19:47 +01:00
// 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.
2026-02-26 11:19:47 +01:00
## 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<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
2026-02-26 11:19:47 +01:00
```
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.
2026-02-26 11:19:47 +01:00
## 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);
2026-02-26 11:19:47 +01:00
// 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`:
2026-02-26 11:19:47 +01:00
```typescript
const response = await dappMgr.signTransaction({
2026-02-26 11:19:47 +01:00
transaction: {
transaction: txHex,
2026-02-26 11:19:47 +01:00
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");
2026-02-26 11:19:47 +01:00
try {
const response = await dappMgr.signTransaction(
{ transaction: { ... }, inputPaths: [...] },
{ signal: controller.signal },
);
2026-02-26 11:19:47 +01:00
} catch (err) {
if (err.name === "AbortError") {
console.log("User cancelled the signature request");
}
2026-02-26 11:19:47 +01:00
}
```
#### 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");
```
2026-02-26 11:19:47 +01:00
### Disconnecting
```typescript
// Dapp-initiated: send courtesy message, clear session, then tear down the relay
dappMgr.clearStoredSession();
2026-02-26 11:19:47 +01:00
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.
2026-02-26 11:19:47 +01:00
### 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.
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
## 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.
---
2026-02-26 11:19:47 +01:00
## 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).
2026-02-26 11:19:47 +01:00
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]);
```
2026-02-26 11:19:47 +01:00
The `DappConnectionManager` is created by the hook; the adapter receives it rather than
creating its own.