# 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 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](extensions.md) for conventions on defining extension messages. --- ## 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; // 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 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 ```typescript interface Hdwalletv1Session { paths: PathXpub[]; // BIP32 xpubs for each named path extensions?: Record; // 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](extensions.md) for the full extension system. 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 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](extensions.md) for conventions on defining new path names. ### sign_transaction_request ```typescript 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 list of positional tuples. Each tuple has 3 or 4 elements: | index | field | type | meaning | |-------|-------|------|---------| | `[0]` | `inputIndex` | integer | which transaction input this entry is for | | `[1]` | `PathName` | string | named derivation path (`"receive"`, `"change"`, `"defi"`, …) | | `[2]` | `addressIndex` | integer | address index within that path | | `[3]` | `slot` | integer, optional | placeholder slot within the input (default `0`) — see below | Each entry names one HD key (by named derivation path and address index within that path) that the wallet must contribute to the input at position `inputIndex`. Only inputs the wallet must sign need an entry — a contract input whose unlocking bytecode the dapp fully provides (including any non-wallet keys, e.g. a counterparty's) is omitted. An input with **no** entry is simply not the wallet's to sign and is left untouched; this is distinct from an entry the wallet cannot satisfy, which is an error (see Wallet rules below). This lets the wallet derive the correct private keys without scanning or guessing which key was used. For an ordinary P2PKH input, one entry suffices and `slot` is omitted: the wallet derives that key and produces the input's single signature. ##### Multiple placeholders per input (the `slot` element) A contract input may carry **several `sig`/`pubkey` placeholders**, each to be filled by a different key (e.g. an N-of-N agreement, or a function taking `(sig a, pubkey A, sig b, pubkey B)`). The dapp builds the unlocking bytecode with one placeholder per slot and lists the input's index once per slot, setting the fourth tuple element, `slot`: ``` inputPaths: [ [3, "receive", 0, 0], // input 3, placeholder slot 0 -> key receive/0 [3, "defi", 7, 1], // input 3, placeholder slot 1 -> key defi/7 ] ``` Placeholder format (the de-facto BCH-WC2 convention this builds on). Signatures **MUST** be **Schnorr** — the fixed-length placeholder scheme has no room for variable-length DER ECDSA: - a **signature placeholder** is a data push of **65 zero bytes** (`0x41` followed by 65 `0x00`) — room for a 64-byte Schnorr signature plus its one trailing sighash-flag byte; - a **public-key placeholder** is a data push of **33 zero bytes** (`0x21` followed by 33 `0x00`). Because the real signature (65 bytes) and real compressed public key (33 bytes) are exactly the length of their placeholders, the wallet splices the value in **value-for-value, keeping the same push opcode** (`0x41` / `0x21`) and not changing the bytecode length. `slot` semantics: - `slot` counts **sig placeholders and pubkey placeholders independently**, left-to-right, starting at 0. An input may legally have a different number of each (e.g. 2 sig placeholders but only 1 pubkey placeholder, when one key's pubkey is hard-coded in the redeem script). - An entry `[i, path, addr, k]` tells the wallet: derive the key for `(path, addr)`, write its signature into the **k-th sig placeholder** of input `i`, and write its compressed public key into the **k-th pubkey placeholder** of input `i` if one is present. - `slot` is **optional and defaults to 0**, so an entry with no `slot` fills the first sig (and first pubkey) placeholder — identical to the single-signature behaviour. Existing 3-tuple requests are therefore unchanged. The dapp ships the unsigned unlocking-bytecode template per input at **`sourceOutputs[i].unlockingBytecode`** (equivalently, the decoded `transaction.inputs[i].unlockingBytecode`). The wallet locates the k-th placeholder by scanning that template for the k-th occurrence of the zero-filled push above, then splices in the real signature / public key. It does **not** rely on the order of the `inputPaths` tuples — the `slot` value is authoritative. Wallet rules (**MUST**): - **Do not deduplicate `inputPaths` by `inputIndex`.** Several entries may share one `inputIndex` (one per slot); collapsing them into a map keyed by index drops signatures and yields an unspendable transaction. Keep every entry. - **Compute each input's sighash once.** All signatures within one input commit to the **same sighash**: the signing serialization covers `sourceOutputs[i].contract.redeemScript` and the transaction, *not* the unlocking bytecode being filled — so filling one slot does not invalidate another's sighash. Compute it once per input and reuse it for every slot, varying only the key. - **Reject, don't under-fill.** If an entry cannot be satisfied — the named path/index does not map to a key, or the input has no placeholder at the requested `slot` — the wallet **MUST** fail the whole request with an error. It **MUST NOT** return a transaction with a leftover zero placeholder, which would be silently unspendable. ##### Capability negotiation (required) Filling more than one placeholder per input is gated behind the **`multislot` extension**. A wallet that supports it advertises the key in its `wallet_ready` handshake: `session["hdwalletv1"].extensions.multislot` (see [extensions.md](extensions.md#multislot)). A dapp **MUST NOT** send any entry with `slot > 0` (nor more than one entry for the same `inputIndex`) unless the connected wallet advertised `multislot`. A wallet that does not advertise it only ever receives single-signature (`slot` 0 / 3-tuple) requests. This prevents the silent failure mode where a wallet unaware of slots fills only the first placeholder and returns an unspendable transaction. #### SIGHASH requirement (security-critical) Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID` and **SHOULD** additionally set `SIGHASH_UTXOS`. Concretely, the sighash-flag byte — the last byte of each signature push — **MUST** be either `0x41` (`SIGHASH_ALL | SIGHASH_FORKID`) or `0x61` (`SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`); the wallet **MUST** reject any request that would require any other flags. The bit values are `SIGHASH_ALL = 0x01`, `SIGHASH_UTXOS = 0x20`, `SIGHASH_FORKID = 0x40`. (`validateSighashFlags` in `@wizardconnect/wallet` enforces exactly this `{0x41, 0x61}` set.) `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(s) sign each input (one or more per input for contracts), 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 ```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. ### 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 ```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; } ``` Sent by the **dapp only** to cancel an in-flight `sign_transaction_request`. The wallet should dismiss the corresponding sign dialog immediately upon receipt. 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). **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. --- ## 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 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.