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