2026-03-26 10:50:40 +01:00
# Extensions
2026-04-21 14:50:54 +02:00
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 ](transport.md#transport-level-extensions ).
2026-03-26 10:50:40 +01:00
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:
```typescript
interface Hdwalletv1Session {
paths: PathXpub[];
extensions?: Record< string , unknown > ;
}
```
Each key in `extensions` is an extension name. Its **presence** indicates the wallet supports that
2026-03-28 22:39:34 +03:00
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.
2026-03-26 10:50:40 +01:00
Example:
```json
{
"paths": [
2026-03-28 22:39:34 +03:00
{ "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..." }
2026-03-26 10:50:40 +01:00
],
"extensions": {
2026-03-28 22:39:34 +03:00
"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'"
},
2026-03-26 10:50:40 +01:00
"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:
```json
{
"paths": [
2026-03-28 22:39:34 +03:00
{ "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..." }
2026-03-26 10:50:40 +01:00
],
"extensions": {
2026-03-28 22:39:34 +03:00
"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'"
}
2026-03-26 10:50:40 +01:00
}
}
```
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:
```typescript
// 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` :
```typescript
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` :
```typescript
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` :
```typescript
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:
```typescript
const adapter: WalletAdapter = {
// ... core implementation ...
getAdditionalPaths() {
return [
2026-03-28 22:39:34 +03:00
{ 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'") },
2026-03-26 10:50:40 +01:00
];
},
getExtensions() {
2026-03-28 22:39:34 +03:00
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'",
},
};
2026-03-26 10:50:40 +01:00
},
};
```
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` :
```typescript
manager.on("walletready", (msg) => {
const session = msg.session["hdwalletv1"] as Hdwalletv1Session;
2026-03-28 22:39:34 +03:00
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");
2026-03-26 10:50:40 +01:00
// enable stealth address features
} else {
2026-03-28 22:39:34 +03:00
// 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
2026-03-26 10:50:40 +01:00
}
});
```
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
2026-03-28 22:39:34 +03:00
| 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 ](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5 ) |
| `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 ](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5 ) |
| `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 |
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
| `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 |
2026-03-26 10:50:40 +01:00
See the discussions and specifications for each extension as they are formalized.
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
---
## 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:
```json
{
"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.
```typescript
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
```typescript
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 ](protocol.md#sign_message ) for the wire format,
[wallet.md ](wallet.md#signmessage ) for the adapter contract and display requirements, and
[dapp.md ](dapp.md#signmessage ) for what is verified and why replay is the dapp's job.