Splits ProtocolMessages exceeding NIP-44's 65,535-byte plaintext ceiling across multiple gift-wrapped events. Symmetric (both directions), fire-and-forget, backward-compatible via a new transport-level \`extensions\` field on \`dapp_ready\` and \`wallet_ready\`. Resolves the \"Failed to swap: invalid plaintext size\" error on aggregated swap requests and enables signed-tx responses up to the 1 MB BCH consensus limit (~2 MB hex).
417 lines
18 KiB
Markdown
417 lines
18 KiB
Markdown
# 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<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`. 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<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](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.
|