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>
12 KiB
Extensions
This document covers hdwalletv1 protocol-level extensions — optional capabilities that extend
the application protocol (extra path names, custom message actions, per-wallet features). For
transport-level capabilities that apply below the application protocol regardless of which
protocol is in use (chunking, future: compression), see
transport.md § Transport-level extensions.
The hdwalletv1 protocol supports optional extensions that let wallets and dapps negotiate additional capabilities beyond the core sign-transaction flow. Extensions are backward-compatible: existing wallets and dapps that don't know about extensions continue to work unchanged.
How extensions work
Extensions use three mechanisms, all of which are optional and additive:
1. Session extensions field
Wallets advertise supported extensions in the wallet_ready handshake via an extensions field
on the Hdwalletv1Session object:
interface Hdwalletv1Session {
paths: PathXpub[];
extensions?: Record<string, unknown>;
}
Each key in extensions is an extension name. Its presence indicates the wallet supports that
extension. The value carries extension-specific handshake data (derivation paths to the hardened
gate where the wallet exports xpubs), or {} if no data is needed.
Example:
{
"paths": [
{ "name": "receive", "xpub": "xpub6..." },
{ "name": "change", "xpub": "xpub6..." },
{ "name": "defi", "xpub": "xpub6..." },
{ "name": "stealth_spend", "xpub": "xpub6..." },
{ "name": "stealth_scan", "xpub": "xpub6..." },
{ "name": "rpa_spend", "xpub": "xpub6..." },
{ "name": "rpa_scan", "xpub": "xpub6..." }
],
"extensions": {
"bch_stealth_bip352": {
"spend_path": "m/352'/145'/0'/0'",
"scan_path": "m/352'/145'/0'/1'"
},
"rpa_bip47": {
"spend_path": "m/47'/145'/0'/0'",
"scan_path": "m/47'/145'/0'/1'"
},
"decrypt": { "public_key": "02abc...", "scheme": "ecies" }
}
}
2. Additional path names
PathName is an open string type. Wallets may include paths beyond the well-known
receive/change/defi set. Extension-defined paths are carried in the standard paths array:
{
"paths": [
{ "name": "receive", "xpub": "xpub6..." },
{ "name": "change", "xpub": "xpub6..." },
{ "name": "stealth_spend", "xpub": "xpub6..." },
{ "name": "stealth_scan", "xpub": "xpub6..." },
{ "name": "rpa_spend", "xpub": "xpub6..." },
{ "name": "rpa_scan", "xpub": "xpub6..." }
],
"extensions": {
"bch_stealth_bip352": {
"spend_path": "m/352'/145'/0'/0'",
"scan_path": "m/352'/145'/0'/1'"
},
"rpa_bip47": {
"spend_path": "m/47'/145'/0'/0'",
"scan_path": "m/47'/145'/0'/1'"
}
}
}
Dapps should ignore path names they do not recognize. The standard pubkey derivation logic
(DappPubkeyStateManager) automatically skips unknown paths.
3. Custom message actions
Extensions may define new message action strings beyond the well-known set. Custom messages follow
the standard ProtocolMessage shape (action + time) and use the existing relay transport.
Convention for request/response operations:
// Request (dapp → wallet):
{ action: "<operation>_request", sequence: number, time: number, ...params }
// Response (wallet → dapp):
{ action: "<operation>_response", sequence: number, time: number, ...result }
The sequence field ties responses to requests, matching the pattern used by
sign_transaction_request/sign_transaction_response.
Wallet side: custom messages are emitted via the "message" event on
WalletConnectionManager:
manager.on("message", (connectionId, msg) => {
if (msg.action === "decrypt_request") {
// handle decrypt request
}
});
Dapp side: all messages (including custom ones) are emitted via the "messagereceived" event
on DappConnectionManager:
manager.on("messagereceived", (msg) => {
if (msg.action === "decrypt_response") {
// handle decrypt response
}
});
Implementing an extension (wallet side)
Wallets advertise extensions by implementing optional methods on WalletAdapter:
interface WalletAdapter {
// ... core methods ...
/** Additional paths to include in the session (e.g. stealth_scan). */
getAdditionalPaths?(): PathXpub[];
/** Extension data for the session handshake. */
getExtensions?(): Record<string, unknown>;
}
Example:
const adapter: WalletAdapter = {
// ... core implementation ...
getAdditionalPaths() {
return [
{ name: "stealth_spend", xpub: deriveXpub("m/352'/145'/0'/0'") },
{ name: "stealth_scan", xpub: deriveXpub("m/352'/145'/0'/1'") },
{ name: "rpa_spend", xpub: deriveXpub("m/47'/145'/0'/0'") },
{ name: "rpa_scan", xpub: deriveXpub("m/47'/145'/0'/1'") },
];
},
getExtensions() {
return {
"bch_stealth_bip352": {
"spend_path": "m/352'/145'/0'/0'",
"scan_path": "m/352'/145'/0'/1'",
},
"rpa_bip47": {
"spend_path": "m/47'/145'/0'/0'",
"scan_path": "m/47'/145'/0'/1'",
},
};
},
};
Wallets that don't implement these methods produce the same session as before (receive/change/defi paths only, no extensions field).
Discovering extensions (dapp side)
Dapps check for extension support after receiving wallet_ready:
manager.on("walletready", (msg) => {
const session = msg.session["hdwalletv1"] as Hdwalletv1Session;
if (session.extensions?.bch_stealth_bip352) {
const scanPath = session.paths.find(p => p.name === "stealth_scan");
const spendPath = session.paths.find(p => p.name === "stealth_spend");
// enable stealth address features
} else {
// show: "Stealth payments require a wallet that supports BCH Stealth (BIP352)"
}
if (session.extensions?.rpa_bip47) {
const rpaSpend = session.paths.find(p => p.name === "rpa_spend");
const rpaScan = session.paths.find(p => p.name === "rpa_scan");
// enable RPA features
}
});
Dapps should degrade gracefully when an extension is absent. If a feature requires an extension the wallet doesn't support, inform the user rather than failing silently.
Known extensions
| Extension name | Path names | Hardened gate paths | Purpose | Status |
|---|---|---|---|---|
bch_stealth_bip352 |
stealth_spend, stealth_scan |
m/352'/145'/0'/0', m/352'/145'/0'/1' |
BCH stealth addresses (BIP352 structure). Wallet exports xpubs at hardened gates; dapp derives /0 child locally. |
Standard — BCR post |
rpa_bip47 |
rpa_spend, rpa_scan |
m/47'/145'/0'/0', m/47'/145'/0'/1' |
BIP47 reusable payment addresses. Wallet exports xpubs at hardened gates; dapp derives /0 child locally. |
Standard — BCR post |
decrypt |
— | — | Dapp-side encrypted storage. Wallet provides a public key; dapp encrypts data for storage and sends decrypt_request messages when the data is needed. |
Proposed |
sign_message |
— | — | Sign a plain message to prove key control, without a transaction. Portable "Bitcoin Signed Message" signature — verifiable in Electron Cash, Electrum and bitcoin-cli verifymessage. |
Implemented — see below |
See the discussions and specifications for each extension as they are formalized.
First-party extensions
sign_message does not follow § 3 above. Its actions live in the RelayMsgAction enum and are
handled by WalletConnectionManager and DappConnectionManager directly, rather than riding the
generic message / messagereceived events.
That is a new pattern, not an existing convention — the only prior enum-plus-advertisement
capability is chunk, which is transport-level and so lives outside the hdwalletv1 extension system
entirely. The distinction is about who implements the extension:
- Third-party extensions define their own action strings and are handled by the host app through the generic message events (§ 3). This library never needs to know they exist.
- First-party extensions ship in this library with manager support, verification helpers and tests. Routing those through the generic events would force every consumer to hand-roll the request/response plumbing for a capability the library already implements.
Support is still advertised through the same session.extensions mechanism (§ 1), so discovery is
uniform and a wallet that does not implement the capability is unaffected.
sign_message
Advertised under the sign_message key:
{
"extensions": {
"sign_message": {
"schemes": ["bitcoin_signed_message"],
"modes": ["dapp_path", "wallet_choice"]
}
}
}
schemes and modes are advertised separately because they are independent capabilities. A wallet
may be able to sign with a dapp-named path but have no notion of a stable identity key, and a dapp
that checked only for the extension's presence would discover that at request time — after the user
had already clicked a login button.
| Mode | Meaning |
|---|---|
dapp_path |
The dapp sends path + addressIndex. Requires the dapp to hold that path's xpub. |
wallet_choice |
The dapp sends neither; the wallet picks the key and returns its address. No xpub needed. |
An older wallet that advertised "sign_message": {} before these fields existed is read as
{schemes: ["bitcoin_signed_message"], modes: ["dapp_path"]} — the original behaviour.
Wallet side
Implement WalletAdapter.signMessage — that alone is what advertises the extension, so the handshake
cannot claim support an adapter does not have. Declare signMessageModes() if the wallet supports
wallet_choice. An adapter that returns its own sign_message entry from getExtensions() wins;
the automatic advertisement never overwrites an explicit one.
import { signBitcoinMessage, MODE_DAPP_PATH, MODE_WALLET_CHOICE } from "@wizardconnect/core";
const adapter: WalletAdapter = {
// ... core implementation ...
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,
};
},
};
Use signBitcoinMessage() rather than assembling the construction. The magic string and both
compactSize length prefixes are what third-party verifiers check, and they are covered by this
repository's conformance tests against a real Electron Cash install; a reimplementation is not.
Dapp side
import { MODE_WALLET_CHOICE } from "@wizardconnect/core";
if (!manager.walletSupportsSignMessage(MODE_WALLET_CHOICE)) {
// Hide the login button rather than offering one that fails.
}
const result = await manager.signMessage({
// The nonce MUST be single-use and server-issued — see dapp.md § Replay.
message: `${location.host} wants you to sign in\nnonce=${singleUseNonce}`,
userPrompt: "Sign in",
});
// result is already verified; result.address is the proven identity.
See protocol.md § sign_message for the wire format, wallet.md for the adapter contract and display requirements, and dapp.md for what is verified and why replay is the dapp's job.