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
disconnect — either → either, courtesy notification before tearing down
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:
-
Key exchange —
public_keyis the wallet's Nostr pubkey;secretis echoed from the connection URI for MITM prevention. The dapp verifies the secret and callssetPairedPublicKey(public_key)before processing the rest of the message. This is whywallet_readybypasses the relay-client peer filter. -
Application handshake — the wallet populates
sessionfor every protocol it supports. The dapp picks the first protocol from its ownsupported_protocolslist that also appears in the wallet's list, then readssession[selectedProtocol]for the protocol-specific data.
Protocol negotiation
- The dapp sends its
supported_protocolslist in the proactivedapp_ready. - The wallet replies with its own
supported_protocolsand thesessionmap. - The dapp selects
agreed = dapp.supported_protocols.find(p => wallet.supported_protocols.includes(p)). - If no overlap: the dapp sends
disconnect(reason: "protocol_mismatch")and emits adisconnectevent. No further communication happens. - If agreed: the dapp reads
session[agreed]and sends a reactivedapp_readywithselected_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
- On every connect/reconnect, each side sends its own "ready" message proactively. The wallet
sends
wallet_readyimmediately (it already knows the dapp's pubkey from the URI). The dapp sendsdapp_readyonce the relay is connected and key exchange resolves. - Each "ready" message carries a boolean indicating whether the sender has already seen the
other party (
wallet_discoveredindapp_ready,dapp_discoveredinwallet_ready). - 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. - The wallet guards against duplicate
wallet_readymessages within a single connection cycle viawalletReadySentThisCycle. This resets tofalseon each new connect/reconnect. Receivingdapp_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)
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 receive/change/defi
}
Carried as wallet_ready.session["hdwalletv1"]. The dapp validates it with isHdwalletv1Session().
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 = "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
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
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
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).
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
// "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.