2026-02-26 11:19:47 +01:00
|
|
|
# Protocol
|
|
|
|
|
|
|
|
|
|
The application-level protocol has two layers:
|
|
|
|
|
|
|
|
|
|
- **Base protocol** — the handshake messages (`dapp_ready`, `wallet_ready`, `disconnect`) that are
|
|
|
|
|
shared across all application protocols and live in `@wizardconnect/core/protocols/base.ts`.
|
|
|
|
|
- **hdwalletv1** — the BCH HD-wallet protocol, carrying session data (xpubs) and
|
|
|
|
|
sign request round-trips. Defined in `@wizardconnect/core/protocols/hdwalletv1.ts`.
|
|
|
|
|
|
|
|
|
|
Both layers use the same encrypted relay channel (see [transport.md](transport.md)).
|
|
|
|
|
|
|
|
|
|
Protocol selection happens during the handshake via `supported_protocols` lists, not via a
|
|
|
|
|
hard-coded field. This allows forward-compatible negotiation when future protocol versions are added.
|
|
|
|
|
|
|
|
|
|
## Message envelope
|
|
|
|
|
|
|
|
|
|
Every message shares a base shape:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface ProtocolMessage {
|
|
|
|
|
action: string; // one of the RelayMsgAction values below
|
|
|
|
|
time: number; // Unix timestamp (seconds). Used for replay filtering.
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The `time` field is checked by the relay client: messages older than the last-processed timestamp
|
|
|
|
|
are silently dropped. This prevents stale messages buffered at the relay from being re-delivered
|
|
|
|
|
after a reconnect.
|
|
|
|
|
|
|
|
|
|
## Actions
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
dapp_ready — dapp → wallet, signals dapp is alive + lists supported protocols
|
|
|
|
|
wallet_ready — wallet → dapp, signals wallet is alive + delivers session data + key exchange
|
|
|
|
|
sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
|
|
|
|
|
sign_transaction_response — wallet → dapp, returns signed tx or error
|
|
|
|
|
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
|
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
|
|
|
or sign_message_request
|
|
|
|
|
sign_message_request — dapp → wallet, asks wallet to sign a plain message, proving key
|
|
|
|
|
control without a transaction. Gated on the `sign_message`
|
|
|
|
|
extension. See extensions.md.
|
|
|
|
|
sign_message_response — wallet → dapp, returns the signature, the signing key and its address
|
2026-02-26 11:19:47 +01:00
|
|
|
disconnect — either → either, courtesy notification before tearing down
|
2026-04-21 14:50:54 +02:00
|
|
|
chunk — either → either, transport-level. Carries one slice of a larger message
|
|
|
|
|
that exceeds NIP-44's 65,535-byte plaintext ceiling. Not tied to
|
|
|
|
|
hdwalletv1 semantics. See transport.md.
|
2026-02-26 11:19:47 +01:00
|
|
|
```
|
|
|
|
|
|
2026-03-26 10:50:40 +01:00
|
|
|
The action names above are the well-known set. Extensions may define additional action strings
|
|
|
|
|
(e.g. `decrypt_request`, `decrypt_response`). Both sides should ignore unknown actions gracefully.
|
|
|
|
|
See [extensions.md](extensions.md) for conventions on defining extension messages.
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Base protocol
|
|
|
|
|
|
|
|
|
|
### dapp_ready
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface DappReadyMessage {
|
|
|
|
|
action: "dapp_ready";
|
|
|
|
|
supported_protocols: string[]; // protocols this dapp supports, in preference order
|
|
|
|
|
selected_protocol?: string; // set only on the reactive dapp_ready (after seeing wallet_ready)
|
|
|
|
|
wallet_discovered: boolean; // true if dapp already saw this wallet this session
|
|
|
|
|
dapp_name?: string; // optional, sent on first message for wallet UI
|
|
|
|
|
dapp_icon?: string; // optional icon URL or data-URI
|
|
|
|
|
time: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`dapp_name` and `dapp_icon` are captured by the wallet on the first `dapp_ready` that includes
|
|
|
|
|
them. The wallet shows these in its connections list.
|
|
|
|
|
|
|
|
|
|
`selected_protocol` is absent on the proactive `dapp_ready` (sent before the dapp has seen
|
|
|
|
|
the wallet). Once the dapp receives `wallet_ready` and picks a protocol, the reactive `dapp_ready`
|
|
|
|
|
carries `selected_protocol` so the wallet can confirm the agreed protocol.
|
|
|
|
|
|
|
|
|
|
### wallet_ready
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface WalletReadyMessage {
|
|
|
|
|
action: "wallet_ready";
|
|
|
|
|
supported_protocols: string[]; // protocols this wallet supports
|
|
|
|
|
wallet_name: string;
|
|
|
|
|
wallet_icon: string;
|
|
|
|
|
dapp_discovered: boolean; // true if wallet already saw this dapp this session
|
|
|
|
|
session: Record<string, unknown>; // keyed by protocol name; each value is protocol-specific
|
|
|
|
|
public_key: string; // wallet's Nostr x-only pubkey (hex, 32 bytes) — key exchange
|
|
|
|
|
secret: string; // echo of the shared secret from the URI — MITM prevention
|
|
|
|
|
time: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`wallet_ready` is the most important message in the protocol. It serves two purposes:
|
|
|
|
|
|
|
|
|
|
1. **Key exchange** — `public_key` is the wallet's Nostr pubkey; `secret` is echoed from the
|
|
|
|
|
connection URI for MITM prevention. The dapp verifies the secret and calls
|
|
|
|
|
`setPairedPublicKey(public_key)` before processing the rest of the message. This is why
|
|
|
|
|
`wallet_ready` bypasses the relay-client peer filter.
|
|
|
|
|
|
|
|
|
|
2. **Application handshake** — the wallet populates `session` for every protocol it supports.
|
|
|
|
|
The dapp picks the first protocol from its own `supported_protocols` list that also appears
|
|
|
|
|
in the wallet's list, then reads `session[selectedProtocol]` for the protocol-specific data.
|
|
|
|
|
|
|
|
|
|
### Protocol negotiation
|
|
|
|
|
|
|
|
|
|
1. The dapp sends its `supported_protocols` list in the proactive `dapp_ready`.
|
|
|
|
|
2. The wallet replies with its own `supported_protocols` and the `session` map.
|
|
|
|
|
3. The dapp selects `agreed = dapp.supported_protocols.find(p => wallet.supported_protocols.includes(p))`.
|
|
|
|
|
4. If no overlap: the dapp sends `disconnect(reason: "protocol_mismatch")` and emits a `disconnect`
|
|
|
|
|
event. No further communication happens.
|
|
|
|
|
5. If agreed: the dapp reads `session[agreed]` and sends a reactive `dapp_ready` with
|
|
|
|
|
`selected_protocol = agreed`.
|
|
|
|
|
|
|
|
|
|
### Handshake
|
|
|
|
|
|
|
|
|
|
The handshake uses a **mutual-discovery** pattern. The goal is for both sides to converge to a
|
|
|
|
|
live session regardless of who reconnects first. "Discovered" means "I have received and processed
|
|
|
|
|
a ready message from the other side in this runtime session."
|
|
|
|
|
|
|
|
|
|
#### Rules
|
|
|
|
|
|
|
|
|
|
1. On every connect/reconnect, each side sends its own "ready" message proactively. The wallet
|
|
|
|
|
sends `wallet_ready` immediately (it already knows the dapp's pubkey from the URI). The dapp
|
|
|
|
|
sends `dapp_ready` once the relay is connected and key exchange resolves.
|
|
|
|
|
2. Each "ready" message carries a boolean indicating whether the sender has already seen the
|
|
|
|
|
other party (`wallet_discovered` in `dapp_ready`, `dapp_discovered` in `wallet_ready`).
|
|
|
|
|
3. On receiving a "ready" with the discovery flag `false`, the receiver must send back its own
|
|
|
|
|
"ready" — *even if it already sent one* — because the other side has lost state and needs
|
|
|
|
|
a fresh delivery.
|
|
|
|
|
4. The wallet guards against duplicate `wallet_ready` messages within a single connection cycle
|
2026-04-03 12:35:50 +00:00
|
|
|
via `walletReadySentThisCycle`. Both `walletReadySentThisCycle` and `dappDiscovered` reset to
|
|
|
|
|
`false` on each new connect/reconnect. This ensures the wallet always sends
|
|
|
|
|
`wallet_ready(dapp_discovered=false)` at the start of a new connection cycle, matching the
|
|
|
|
|
"Wallet reconnects" scenario. Receiving `dapp_ready(wallet_discovered=false)` also resets
|
|
|
|
|
`walletReadySentThisCycle`.
|
2026-02-26 11:19:47 +01:00
|
|
|
|
|
|
|
|
#### Scenarios
|
|
|
|
|
|
|
|
|
|
**Initial connect (neither has seen the other):**
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
Dapp ──dapp_ready(supported=["hdwalletv1"], wallet_discovered=false)──▶ Wallet (proactive)
|
|
|
|
|
Dapp ◀──wallet_ready(supported=["hdwalletv1"], session={...}, dapp_discovered=false)── Wallet
|
|
|
|
|
Dapp ──dapp_ready(supported=["hdwalletv1"], selected="hdwalletv1", wallet_discovered=true)──▶ Wallet
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
After step 3 the wallet sets `dappDiscovered = true`. No more ready messages unless a reconnect.
|
|
|
|
|
|
|
|
|
|
**Wallet reconnects (dapp still running, walletDiscovered=true):**
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
Dapp ──dapp_ready(wallet_discovered=true)──────────────────────────────▶ Wallet (proactive)
|
|
|
|
|
Dapp ◀──wallet_ready(dapp_discovered=false, session={...})────────────── Wallet (proactive)
|
|
|
|
|
Dapp ──dapp_ready(selected="hdwalletv1", wallet_discovered=true)────────▶ Wallet (reactive)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Dapp reconnects (browser refresh, wallet still running):**
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
Dapp ──dapp_ready(wallet_discovered=false)──────────────────────────────▶ Wallet (proactive)
|
|
|
|
|
Dapp ◀──wallet_ready(dapp_discovered=true, session={...})─────────────── Wallet (reactive)
|
|
|
|
|
```
|
|
|
|
|
(No third message: `dapp_discovered=true` means the dapp does not need to send a reactive reply.)
|
|
|
|
|
|
|
|
|
|
#### Design decision: why mutual discovery?
|
|
|
|
|
|
|
|
|
|
An alternative is a fixed initiator/responder role (only the dapp initiates). That breaks when the
|
|
|
|
|
wallet reconnects while the dapp is still alive — the wallet would wait for a dapp message that
|
|
|
|
|
never comes because the dapp thinks the session is live. Mutual discovery means each side sends a
|
|
|
|
|
"hello" on reconnect without depending on the other side's state.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## hdwalletv1 protocol
|
|
|
|
|
|
|
|
|
|
The `hdwalletv1` session data is carried in `wallet_ready.session["hdwalletv1"]`. It delivers
|
|
|
|
|
everything the dapp needs to derive an unlimited number of addresses without further contact with
|
|
|
|
|
the wallet.
|
|
|
|
|
|
|
|
|
|
### Hdwalletv1Session
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface Hdwalletv1Session {
|
2026-03-26 10:50:40 +01:00
|
|
|
paths: PathXpub[]; // BIP32 xpubs for each named path
|
|
|
|
|
extensions?: Record<string, unknown>; // optional extension capabilities and data
|
2026-02-26 11:19:47 +01:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Carried as `wallet_ready.session["hdwalletv1"]`. The dapp validates it with `isHdwalletv1Session()`.
|
|
|
|
|
|
2026-03-26 10:50:40 +01:00
|
|
|
The `extensions` field is optional. When present, each key is an extension name and its presence
|
|
|
|
|
indicates the wallet supports that extension. The value carries extension-specific handshake data,
|
|
|
|
|
or `{}` if no data is needed. See [extensions.md](extensions.md) for the full extension system.
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
See [pubkey-derivation.md](pubkey-derivation.md) for the full xpub story.
|
|
|
|
|
|
|
|
|
|
### PathXpub
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface PathXpub {
|
|
|
|
|
name: PathName; // "receive" | "change" | "defi"
|
|
|
|
|
xpub: string; // BIP32 base58-encoded extended public key
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`name` is the protocol-level identifier. The dapp uses the name to know what kind of addresses to
|
|
|
|
|
derive from the xpub; it does not need to know (or care) where the wallet derived the xpub from.
|
|
|
|
|
|
|
|
|
|
**Highly recommended derivation paths.** To ensure addresses are recognised by other wallets and
|
|
|
|
|
blockchain explorers, wallets should derive xpubs from the standard BIP44 paths for BCH:
|
|
|
|
|
|
|
|
|
|
| Name | Recommended derivation path | Purpose |
|
|
|
|
|
|------|-----------------------------|---------|
|
|
|
|
|
| `receive` | `m/44'/145'/0'/0` | External receive addresses |
|
|
|
|
|
| `change` | `m/44'/145'/0'/1` | Internal change addresses |
|
|
|
|
|
| `defi` | `m/44'/145'/0'/7` | DeFi / Cauldron addresses |
|
|
|
|
|
|
|
|
|
|
Using these paths means the same addresses will appear in any BIP44-compatible wallet that holds
|
|
|
|
|
the same seed, making fund recovery straightforward.
|
|
|
|
|
|
|
|
|
|
**Privacy-first alternative: any path per session.** The protocol does not enforce the recommended
|
|
|
|
|
paths. A wallet that prioritises privacy may derive xpubs from non-standard or randomly-chosen
|
|
|
|
|
paths, and may even rotate them each session. The dapp derives addresses correctly regardless —
|
|
|
|
|
it never sees the path, only the xpub. The trade-off is that funds sent to session-specific paths
|
|
|
|
|
will not be found by standard wallet recovery tools without additional metadata.
|
|
|
|
|
|
|
|
|
|
**Design decision: names instead of child indices.** The protocol uses human-readable names rather
|
|
|
|
|
than numeric child indices because the derivation path is a wallet-internal detail. A name like
|
|
|
|
|
`"receive"` is stable and meaningful; the corresponding BIP44 index is an implementation concern
|
|
|
|
|
that only the wallet (and internal dapp state) need to know.
|
|
|
|
|
|
|
|
|
|
### PathName
|
|
|
|
|
|
|
|
|
|
```typescript
|
2026-03-26 10:50:40 +01:00
|
|
|
type PathName = string;
|
|
|
|
|
|
|
|
|
|
// Well-known path names:
|
|
|
|
|
const PATH_RECEIVE = "receive";
|
|
|
|
|
const PATH_CHANGE = "change";
|
|
|
|
|
const PATH_DEFI = "defi";
|
2026-02-26 11:19:47 +01:00
|
|
|
```
|
|
|
|
|
|
2026-03-26 10:50:40 +01:00
|
|
|
`PathName` is an open string. The well-known values are:
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
| Name | Recommended BIP44 path | Purpose |
|
|
|
|
|
|------|------------------------|---------|
|
|
|
|
|
| `receive` | `m/44'/145'/0'/0` | External receive addresses |
|
|
|
|
|
| `change` | `m/44'/145'/0'/1` | Internal change addresses |
|
|
|
|
|
| `defi` | `m/44'/145'/0'/7` | DeFi / Cauldron addresses |
|
|
|
|
|
|
2026-03-26 10:50:40 +01:00
|
|
|
Wallets may include additional paths via extensions (e.g. `stealth_scan`, `stealth_spend`, `rpa`).
|
|
|
|
|
Dapps should ignore path names they do not recognize. See [extensions.md](extensions.md) for
|
|
|
|
|
conventions on defining new path names.
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
### sign_transaction_request
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface SignTransactionRequest {
|
|
|
|
|
action: "sign_transaction_request";
|
|
|
|
|
transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces
|
|
|
|
|
sequence: number;
|
2026-03-16 16:53:56 +01:00
|
|
|
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex]
|
2026-02-26 11:19:47 +01:00
|
|
|
time: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`sequence` is a unique number generated by `RelayClient.nextSequence()`. It starts at a random
|
|
|
|
|
offset (to avoid collisions across sessions) and increments by 2 per call. The dapp uses `sequence`
|
|
|
|
|
to match responses to requests.
|
|
|
|
|
|
|
|
|
|
`WcSignTransactionRequest` describes a Bitcoin Cash transaction: inputs, outputs, source outputs
|
|
|
|
|
(for signing), version, locktime, and an optional `userPrompt` string shown to the user in the
|
|
|
|
|
wallet UI.
|
|
|
|
|
|
2026-03-16 16:53:56 +01:00
|
|
|
`inputPaths` is a sparse array of `[inputIndex, PathName, addressIndex]` tuples. Each entry identifies
|
|
|
|
|
the HD derivation path name and address index the dapp used to derive the locking script for the input
|
|
|
|
|
at position `inputIndex`. Only inputs that require wallet signing need an entry — contract inputs with
|
|
|
|
|
pre-set unlocking bytecode can be omitted. This allows the wallet to sign each input without scanning
|
|
|
|
|
or guessing which key was used.
|
2026-03-16 15:17:37 +01:00
|
|
|
|
2026-03-18 10:30:11 +01:00
|
|
|
#### SIGHASH requirement (security-critical)
|
|
|
|
|
|
|
|
|
|
Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS` and **MUST**
|
|
|
|
|
reject any request that would require different flags.
|
|
|
|
|
|
|
|
|
|
`SIGHASH_ALL` ensures the signature commits to the entire transaction (all inputs and all outputs).
|
|
|
|
|
Without it, an attacker could collect a valid signature and graft it onto a different transaction —
|
|
|
|
|
for example, using `SIGHASH_NONE` an attacker could replace every output to redirect funds.
|
|
|
|
|
|
|
|
|
|
Because `inputPaths` lets the dapp specify which key signs each input, the wallet no longer
|
|
|
|
|
independently verifies that the key matches the UTXO's locking bytecode. This is safe **only** when
|
|
|
|
|
`SIGHASH_ALL` is enforced: if the dapp provides a wrong path, the resulting signature is invalid
|
|
|
|
|
(public key hash mismatch) and the transaction cannot broadcast. Without `SIGHASH_ALL`, a
|
|
|
|
|
wrong-key signature could still be repurposed in a different transaction context.
|
|
|
|
|
|
|
|
|
|
Summary of the flags:
|
|
|
|
|
|
|
|
|
|
| Flag | Purpose |
|
|
|
|
|
|------|---------|
|
|
|
|
|
| `SIGHASH_ALL` | Commits to all inputs and outputs — prevents output substitution |
|
|
|
|
|
| `SIGHASH_FORKID` | Prevents cross-fork replay (BCH ↔ BTC) |
|
|
|
|
|
| `SIGHASH_UTXOS` | Commits to all input UTXOs — prevents input substitution after signing |
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
### sign_transaction_response
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface SignTransactionResponse {
|
|
|
|
|
action: "sign_transaction_response";
|
|
|
|
|
sequence: number;
|
|
|
|
|
signedTransaction: string; // hex-encoded fully signed transaction
|
|
|
|
|
error?: string; // if present, signing failed; signedTransaction is ""
|
|
|
|
|
time: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The wallet either returns the signed transaction hex or an error string. The dapp rejects the
|
|
|
|
|
pending Promise associated with the `sequence` in the error case.
|
|
|
|
|
|
2026-04-03 13:03:03 +02:00
|
|
|
### Re-delivery on reconnect
|
|
|
|
|
|
|
|
|
|
If the wallet is not connected (or reconnects) while a `sign_transaction_request` is in flight,
|
|
|
|
|
the dapp automatically re-sends all pending requests when it receives `wallet_ready`. This
|
|
|
|
|
handles the common case where the user triggers a transaction in the dapp and then opens the
|
|
|
|
|
wallet app several seconds later.
|
|
|
|
|
|
|
|
|
|
The wallet deduplicates incoming requests by `sequence` number — if it has already emitted
|
|
|
|
|
`pendingSignRequest` for a given sequence and has not yet responded, the duplicate is silently
|
|
|
|
|
dropped. The dedup guard is cleared when the wallet sends a response (`sign_transaction_response`)
|
|
|
|
|
or receives a `sign_cancel`.
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
### sign_cancel
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface SignCancelMessage {
|
|
|
|
|
action: "sign_cancel";
|
|
|
|
|
sequence: number; // must match the sequence of the sign_transaction_request being cancelled
|
|
|
|
|
reason?: string; // optional human-readable explanation
|
|
|
|
|
time: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
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
|
|
|
Sent by the **dapp only** to cancel an in-flight `sign_transaction_request` **or
|
|
|
|
|
`sign_message_request`**. The wallet should dismiss the corresponding dialog immediately upon
|
|
|
|
|
receipt.
|
|
|
|
|
|
|
|
|
|
One `sign_cancel` unambiguously names one request of either kind, because both draw their
|
|
|
|
|
`sequence` from a single per-connection counter (`RelayClient.nextSequence`). That shared sequence
|
|
|
|
|
space is also why the wallet's dedup guard is shared: a sequence identifies a request regardless of
|
|
|
|
|
its action.
|
2026-02-26 11:19:47 +01:00
|
|
|
|
|
|
|
|
Use cases:
|
|
|
|
|
- User presses cancel on the dapp side while waiting for the wallet to sign.
|
|
|
|
|
- Dapp replaces a stale request with a new one (e.g., trade price has changed).
|
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
|
|
|
- A login prompt the user never answered. `sign_message` has no timeout by design — cancellation is
|
|
|
|
|
explicit, matching `sign_transaction_request`.
|
2026-02-26 11:19:47 +01:00
|
|
|
|
|
|
|
|
**Dapp side** (`DappConnectionManager`):
|
|
|
|
|
- `sendSignCancel(sequence, reason?)` — immediately rejects the pending Promise for that sequence,
|
|
|
|
|
then sends `sign_cancel` to the wallet.
|
|
|
|
|
|
|
|
|
|
**Wallet side** (`WalletConnectionManager`):
|
|
|
|
|
- Incoming `sign_cancel` emits a `signCancelled` event (`connectionId`, `sequence`, `reason`).
|
|
|
|
|
The host app is responsible for dismissing the sign dialog.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
Proves control of a key without a transaction: identity verification, SIWX-style login, or a signed
|
|
|
|
|
statement that can be published on chain. Gated on the `sign_message` extension — see
|
|
|
|
|
[extensions.md](extensions.md#sign_message).
|
|
|
|
|
|
|
|
|
|
The signature is the standard **"Bitcoin Signed Message"** construction, so it verifies in Electron
|
|
|
|
|
Cash, Electrum and `bitcoin-cli verifymessage`. That portability is the point: a third party holding
|
|
|
|
|
only the message, the signature and an address can check it, with no knowledge of this protocol.
|
|
|
|
|
|
|
|
|
|
### sign_message_request
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface SignMessageRequest {
|
|
|
|
|
action: "sign_message_request";
|
|
|
|
|
sequence: number;
|
|
|
|
|
message: string; // exact UTF-8 to sign — never trimmed or normalised
|
|
|
|
|
userPrompt?: string; // dapp-supplied context for the wallet's prompt; NOT signed
|
|
|
|
|
path?: PathName; // omit both to let the wallet choose the key
|
|
|
|
|
addressIndex?: number;
|
|
|
|
|
scheme?: "bitcoin_signed_message"; // default when absent
|
|
|
|
|
time: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`SignMessageRequest` extends `WcSignMessageRequest` from `@bch-wc2/interfaces` — the interface
|
|
|
|
|
wallets already implement for WalletConnect — so the object can be passed straight to an existing
|
|
|
|
|
WC2 `signMessage` handler. hdwalletv1 adds only the optional key selection, mirroring how
|
|
|
|
|
`SignTransactionRequest` wraps `WcSignTransactionRequest` and adds `inputPaths`.
|
|
|
|
|
|
|
|
|
|
**Key selection is all-or-nothing.** `path` and `addressIndex` must both be present or both absent;
|
|
|
|
|
half of one is ambiguous between the two modes and is rejected.
|
|
|
|
|
|
|
|
|
|
| Mode | Request | Who picks the key | Needs an xpub? |
|
|
|
|
|
|------|---------|-------------------|----------------|
|
|
|
|
|
| `dapp_path` | `path` + `addressIndex` set | dapp | yes |
|
|
|
|
|
| `wallet_choice` | both omitted | wallet | no |
|
|
|
|
|
|
|
|
|
|
`wallet_choice` exists for pure identity checks, where requiring an xpub would mean sharing the
|
|
|
|
|
user's whole address history to prove control of one key. A wallet advertising it **must** choose
|
|
|
|
|
deterministically: a dapp treats the returned address as a durable identity, so a fresh key per
|
|
|
|
|
connection makes a returning user unrecognisable.
|
|
|
|
|
|
|
|
|
|
### sign_message_response
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// Success
|
|
|
|
|
interface SignMessageSuccess {
|
|
|
|
|
action: "sign_message_response";
|
|
|
|
|
sequence: number;
|
|
|
|
|
signature: string; // base64, 65 bytes decoded — a WcSignMessageResponse
|
|
|
|
|
publicKey: string; // hex, in the serialisation the signature's header declares
|
|
|
|
|
address: string; // CashAddr of publicKey
|
|
|
|
|
scheme: "bitcoin_signed_message";
|
|
|
|
|
path?: PathName; // echoed: what the wallet actually used
|
|
|
|
|
addressIndex?: number;
|
|
|
|
|
time: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Failure
|
|
|
|
|
interface SignMessageFailure {
|
|
|
|
|
action: "sign_message_response";
|
|
|
|
|
sequence: number;
|
|
|
|
|
error: string;
|
|
|
|
|
time: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`publicKey` and `address` are required on success. Under `wallet_choice` they are the dapp's only
|
|
|
|
|
way to learn which key answered, and requiring them means a dapp can always verify rather than
|
|
|
|
|
sometimes.
|
|
|
|
|
|
|
|
|
|
**The compression bit is load-bearing.** The signature's header byte declares whether the public key
|
|
|
|
|
is compressed, and a key's compressed and uncompressed forms hash to **two different addresses**. A
|
|
|
|
|
response must report the form its header declares, or it is claiming a proof about an address it did
|
|
|
|
|
not prove. `recoverMessageSigner()` returns `{ publicKey, compressed }` together for this reason.
|
|
|
|
|
|
|
|
|
|
### What the wallet checks
|
|
|
|
|
|
|
|
|
|
`WalletConnectionManager.sendSignMessageResponse()` derives `publicKey` and `address` from the
|
|
|
|
|
signature by recovery rather than accepting them from the adapter, so the three can never disagree.
|
|
|
|
|
It then compares the recovered key against the adapter's own key for the path, and throws on
|
|
|
|
|
mismatch. Recovery alone cannot detect a signature over the wrong text — it succeeds and yields some
|
|
|
|
|
other key — so this comparison is what turns a wallet-side bug into an error at the call site
|
|
|
|
|
instead of an opaque rejection across the relay.
|
|
|
|
|
|
|
|
|
|
### What the dapp checks
|
|
|
|
|
|
|
|
|
|
`DappConnectionManager.signMessage()` resolves only after verifying that the signature recovers over
|
|
|
|
|
the message it sent, that `publicKey` is the key that signed, that `address` is that key's address,
|
|
|
|
|
and — when the dapp named a path it can derive — that the signer is exactly the key it asked for.
|
|
|
|
|
Anything inconsistent rejects. See [dapp.md](dapp.md#signmessage).
|
|
|
|
|
|
|
|
|
|
### Replay is the verifier's responsibility
|
|
|
|
|
|
|
|
|
|
A signature proves key control over that exact text. It carries **no freshness and no audience**: it
|
|
|
|
|
is valid forever and to everyone. A login flow must put a single-use, server-issued nonce in
|
|
|
|
|
`message` and retire it after one use. Nothing in this protocol can enforce that, and a dapp that
|
|
|
|
|
skips it has built a login that any captured signature reopens indefinitely. See
|
|
|
|
|
[dapp.md § Replay](dapp.md#replay--your-responsibility).
|
|
|
|
|
|
|
|
|
|
### Re-delivery on reconnect
|
|
|
|
|
|
|
|
|
|
Handled exactly like `sign_transaction_request`: the dapp re-sends pending requests on
|
|
|
|
|
`wallet_ready`, and the wallet's shared dedup guard means a replay does not re-prompt the user.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
## disconnect
|
|
|
|
|
|
|
|
|
|
Either side may send a `disconnect` message before tearing down the relay connection. This is a
|
|
|
|
|
courtesy notification — the remote side treats the connection as closed immediately upon receipt
|
|
|
|
|
(no acknowledgement).
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
enum DisconnectReason {
|
|
|
|
|
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
|
|
|
|
|
UserDisconnect = "user_disconnect", // explicit user or application action
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DisconnectMessage {
|
|
|
|
|
action: "disconnect";
|
|
|
|
|
reason: DisconnectReason;
|
|
|
|
|
message?: string; // optional human-readable detail
|
|
|
|
|
time: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Wallet side** (`WalletConnectionManager`):
|
|
|
|
|
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
|
|
|
|
|
- Incoming `disconnect` emits a `remoteDisconnect` event
|
|
|
|
|
(`connectionId`, `reason`, `message`) and removes the connection.
|
|
|
|
|
|
|
|
|
|
**Dapp side** (`DappConnectionManager`):
|
|
|
|
|
- `sendDisconnect(message?)` sends `UserDisconnect`. Caller then calls `dappRelay.cleanup()`.
|
|
|
|
|
- Protocol mismatch during `handleWalletReady` sends `ProtocolMismatch` and emits a `disconnect`
|
|
|
|
|
event (`reason`, `message`).
|
|
|
|
|
- Incoming `disconnect` emits a `disconnect` event.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Type guards
|
|
|
|
|
|
|
|
|
|
`@wizardconnect/core` exports runtime type guards for all protocol messages:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
isProtocolMessage(obj) → ProtocolMessage
|
|
|
|
|
isDappReadyMessage(obj) → DappReadyMessage
|
|
|
|
|
isWalletReadyMessage(obj) → WalletReadyMessage
|
|
|
|
|
isDisconnectMessage(obj) → DisconnectMessage
|
|
|
|
|
isHdwalletv1Session(obj) → Hdwalletv1Session
|
|
|
|
|
isPathXpub(obj) → PathXpub
|
|
|
|
|
isErrorMessage(obj) → ErrorMessage
|
|
|
|
|
isSignTransactionRequest(obj) → SignTransactionRequest
|
|
|
|
|
isSignCancelMessage(obj) → SignCancelMessage
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
These are used internally to validate incoming messages before dispatch.
|
|
|
|
|
|
|
|
|
|
## Helper: childIndexOfPathName
|
|
|
|
|
|
|
|
|
|
```typescript
|
2026-03-26 10:50:40 +01:00
|
|
|
function childIndexOfPathName(name: PathName): number | undefined
|
|
|
|
|
// "receive" → 0, "change" → 1, "defi" → 7, unknown → undefined
|
2026-02-26 11:19:47 +01:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The protocol uses string names for paths, but code that manages key state internally (such as
|
|
|
|
|
`DappPubkeyStateManager`) keys its maps by numeric child index. This helper converts between the
|
2026-03-26 10:50:40 +01:00
|
|
|
two representations for the well-known path names. It returns `undefined` for extension path names
|
|
|
|
|
(e.g. `"stealth_scan"`). Callers must handle the `undefined` case — typically by skipping the path.
|
|
|
|
|
It is not a protocol concern — the numeric indices never appear on the wire.
|