WizardConnect/docs/protocol.md
Håvard Kittelsen b9a0e16893 feat(core): login challenge helpers, so replay protection is not optional
The sign_message extension leaves replay to the dapp, because a signature proves
key control over an exact string and carries no freshness or audience. The
previous commit said so in three places in the docs. That is exactly the failure
this repository should not ship: a login built on a bare signMessage call works in
every manual test and is a password that never expires, so documenting the
requirement mostly relocates the blame.

So the two failures that matter are structural here rather than advisory:

  verifyLoginChallenge cannot be called without `domain` and `consumeNonce`. There
  is no overload that omits them. Verifying a login without single-use enforcement
  and audience binding is not something this API can express — if you want plain
  signature verification, verifyMessageSignatureForAddress is right there and is
  honestly named.

  createLoginChallenge refuses a nonce under MIN_NONCE_LENGTH and refuses a line
  break in any field, so neither a guessable nonce nor an injected `Nonce:` line
  can reach a signed message.

Check order is deliberate: parse, domain, expiry, signature, THEN consume the
nonce. Consuming earlier would let anyone who sniffs a nonce burn it with a
garbage signature before the real user finishes signing; there is a test asserting
the nonce survives a bad signature and the genuine login still completes.

consumeNonce is a caller-supplied callback rather than a store this module owns,
because single-use enforcement is a property of the caller's database — two
replays arriving together both reach that point and only one may be told true. The
docstring says it must be atomic. createInMemoryNonceStore exists for development
and says plainly that it is per-process, so two servers behind a load balancer
would each honour the same signature once.

parseLoginChallenge is strict: unknown fields, duplicate fields, out-of-order
fields and stray lines are rejected rather than skipped, so exactly one byte
sequence parses to a given challenge. A lenient parser is where field injection
lives.

Address is optional in the message because under wallet_choice the dapp does not
yet know which key will answer. When present the proof is self-describing — a
third party reading the message alone sees which address was claimed — and
verification then requires the recovered address to match it.

NOT SIWE. The layout is deliberately similar to Sign-In With Ethereum so it reads
familiarly, but it does not claim EIP-4361 or CAIP-122 compatibility: there is no
agreed SIWX profile for Bitcoin Cash to conform to. If one lands it belongs beside
this as a second format, not as a silent change to this one.

Also adds addressesEqual() to message-signing, which compares decoded public key
hashes so prefixed CashAddr, bare CashAddr, the token-aware form and legacy base58
all compare equal for the same key.

27 tests, mostly about what must be refused: the replay, the wrong site, the stale
and future-dated challenge, four field-injection attempts, the nonce-burning
attack, the wrong key, and a cross-encoding address match.

test-cli now builds its challenge with these helpers rather than hand-rolled text,
since that is what integrators copy, and verifies the response twice — once as a
third party would and once as the server would, printing proof that replaying the
identical signature is rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:33:11 +02:00

24 KiB

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).

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:

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
                            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
disconnect                — either → either, courtesy notification before tearing down
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.

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 for conventions on defining extension messages.


Base protocol

dapp_ready

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

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 exchangepublic_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 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.

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

interface Hdwalletv1Session {
  paths: PathXpub[];                        // BIP32 xpubs for each named path
  extensions?: Record<string, unknown>;     // optional extension capabilities and data
}

Carried as wallet_ready.session["hdwalletv1"]. The dapp validates it with isHdwalletv1Session().

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 for the full extension system.

See pubkey-derivation.md for the full xpub story.

PathXpub

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

type PathName = string;

// Well-known path names:
const PATH_RECEIVE = "receive";
const PATH_CHANGE  = "change";
const PATH_DEFI    = "defi";

PathName is an open string. The well-known values are:

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

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 for conventions on defining new path names.

sign_transaction_request

interface SignTransactionRequest {
  action: "sign_transaction_request";
  transaction: WcSignTransactionRequest;  // from @bch-wc2/interfaces
  sequence: number;
  inputPaths: [number, PathName, number][];  // [inputIndex, pathName, addressIndex]
  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.

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.

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

sign_transaction_response

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.

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.

sign_cancel

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;
}

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.

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).
  • A login prompt the user never answered. sign_message has no timeout by design — cancellation is explicit, matching sign_transaction_request.

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.

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.

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

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

// 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.

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 at the protocol level can enforce that.

Use createLoginChallenge() / verifyLoginChallenge() from @wizardconnect/core rather than composing the text yourself — verifyLoginChallenge cannot be called without a nonce-consuming callback and an expected domain, which is the enforcement this layer cannot provide. See dapp.md § Replay.

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.


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).

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:

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

function childIndexOfPathName(name: PathName): number | undefined
// "receive" → 0, "change" → 1, "defi" → 7, unknown → undefined

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 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.