WizardConnect/docs/wallet.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

340 lines
13 KiB
Markdown

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