WizardConnect/docs/protocol.md

348 lines
14 KiB
Markdown
Raw Normal View History

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
disconnect — either → either, courtesy notification before tearing down
```
---
## 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
via `walletReadySentThisCycle`. This resets to `false` on each new connect/reconnect.
Receiving `dapp_ready(wallet_discovered=false)` also resets this flag.
#### 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 receive/change/defi
}
```
Carried as `wallet_ready.session["hdwalletv1"]`. The dapp validates it with `isHdwalletv1Session()`.
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 = "receive" | "change" | "defi";
```
| 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 |
### sign_transaction_request
```typescript
interface SignTransactionRequest {
action: "sign_transaction_request";
transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces
sequence: number;
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.
### 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.
### 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
// "receive" → 0, "change" → 1, "defi" → 7
```
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. It is not a protocol concern — the numeric indices never appear on the wire.