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
|
2026-04-21 14:50:54 +02:00
|
|
|
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.
|
2026-02-26 11:19:47 +01:00
|
|
|
```
|
|
|
|
|
|
2026-03-26 10:50:40 +01:00
|
|
|
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.
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 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
|
2026-04-03 12:35:50 +00:00
|
|
|
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`.
|
2026-02-26 11:19:47 +01:00
|
|
|
|
|
|
|
|
#### 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 {
|
2026-03-26 10:50:40 +01:00
|
|
|
paths: PathXpub[]; // BIP32 xpubs for each named path
|
|
|
|
|
extensions?: Record<string, unknown>; // optional extension capabilities and data
|
2026-02-26 11:19:47 +01:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Carried as `wallet_ready.session["hdwalletv1"]`. The dapp validates it with `isHdwalletv1Session()`.
|
|
|
|
|
|
2026-03-26 10:50:40 +01:00
|
|
|
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.
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
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
|
2026-03-26 10:50:40 +01:00
|
|
|
type PathName = string;
|
|
|
|
|
|
|
|
|
|
// Well-known path names:
|
|
|
|
|
const PATH_RECEIVE = "receive";
|
|
|
|
|
const PATH_CHANGE = "change";
|
|
|
|
|
const PATH_DEFI = "defi";
|
2026-02-26 11:19:47 +01:00
|
|
|
```
|
|
|
|
|
|
2026-03-26 10:50:40 +01:00
|
|
|
`PathName` is an open string. The well-known values are:
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
| 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 |
|
|
|
|
|
|
2026-03-26 10:50:40 +01:00
|
|
|
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.
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
### sign_transaction_request
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface SignTransactionRequest {
|
|
|
|
|
action: "sign_transaction_request";
|
|
|
|
|
transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces
|
|
|
|
|
sequence: number;
|
2026-03-16 16:53:56 +01:00
|
|
|
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex]
|
2026-02-26 11:19:47 +01:00
|
|
|
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.
|
|
|
|
|
|
Add multislot extension: multiple sig/pubkey placeholders per input
Extend inputPaths tuples with an optional 4th `slot` element so a single
contract input can carry several sig/pubkey placeholders, each filled by
a different key. Gated behind a `multislot` capability the wallet
advertises in wallet_ready (session["hdwalletv1"].extensions.multislot);
dapps must not send slotted/repeated-index requests otherwise.
- core: widen inputPaths to [number, PathName, number, number?]; accept
3- or 4-tuples (non-negative integer slot) in isSignTransactionRequest;
add EXT_MULTISLOT constant.
- wallet: fix extractContractSighashBytes so a filled pubkey placeholder
is no longer mis-read as a signature (only signature-length pushes
carry a sighash flag); export validateSighashFlags / isP2PKH.
- docs: protocol.md (slot semantics, placeholder byte format, capability
negotiation, SIGHASH 0x41/0x61 reconciliation), extensions.md
(multislot), wallet.md, dapp.md.
- tests: multislot-signing.test.ts reference fill; sighash-validation
pubkey-placeholder regression + slot cases; validator slot accept/
reject; integration repeated-index-with-slots passthrough.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:21:24 +02:00
|
|
|
`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.
|
2026-03-16 15:17:37 +01:00
|
|
|
|
2026-03-18 10:30:11 +01:00
|
|
|
#### SIGHASH requirement (security-critical)
|
|
|
|
|
|
Add multislot extension: multiple sig/pubkey placeholders per input
Extend inputPaths tuples with an optional 4th `slot` element so a single
contract input can carry several sig/pubkey placeholders, each filled by
a different key. Gated behind a `multislot` capability the wallet
advertises in wallet_ready (session["hdwalletv1"].extensions.multislot);
dapps must not send slotted/repeated-index requests otherwise.
- core: widen inputPaths to [number, PathName, number, number?]; accept
3- or 4-tuples (non-negative integer slot) in isSignTransactionRequest;
add EXT_MULTISLOT constant.
- wallet: fix extractContractSighashBytes so a filled pubkey placeholder
is no longer mis-read as a signature (only signature-length pushes
carry a sighash flag); export validateSighashFlags / isP2PKH.
- docs: protocol.md (slot semantics, placeholder byte format, capability
negotiation, SIGHASH 0x41/0x61 reconciliation), extensions.md
(multislot), wallet.md, dapp.md.
- tests: multislot-signing.test.ts reference fill; sighash-validation
pubkey-placeholder regression + slot cases; validator slot accept/
reject; integration repeated-index-with-slots passthrough.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:21:24 +02:00
|
|
|
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.)
|
2026-03-18 10:30:11 +01:00
|
|
|
|
|
|
|
|
`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.
|
|
|
|
|
|
Add multislot extension: multiple sig/pubkey placeholders per input
Extend inputPaths tuples with an optional 4th `slot` element so a single
contract input can carry several sig/pubkey placeholders, each filled by
a different key. Gated behind a `multislot` capability the wallet
advertises in wallet_ready (session["hdwalletv1"].extensions.multislot);
dapps must not send slotted/repeated-index requests otherwise.
- core: widen inputPaths to [number, PathName, number, number?]; accept
3- or 4-tuples (non-negative integer slot) in isSignTransactionRequest;
add EXT_MULTISLOT constant.
- wallet: fix extractContractSighashBytes so a filled pubkey placeholder
is no longer mis-read as a signature (only signature-length pushes
carry a sighash flag); export validateSighashFlags / isP2PKH.
- docs: protocol.md (slot semantics, placeholder byte format, capability
negotiation, SIGHASH 0x41/0x61 reconciliation), extensions.md
(multislot), wallet.md, dapp.md.
- tests: multislot-signing.test.ts reference fill; sighash-validation
pubkey-placeholder regression + slot cases; validator slot accept/
reject; integration repeated-index-with-slots passthrough.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:21:24 +02:00
|
|
|
Because `inputPaths` lets the dapp specify which key(s) sign each input (one or more per input for contracts), the wallet no longer
|
2026-03-18 10:30:11 +01:00
|
|
|
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 |
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
### 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.
|
|
|
|
|
|
2026-04-03 13:03:03 +02:00
|
|
|
### 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`.
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
### 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
|
2026-03-26 10:50:40 +01:00
|
|
|
function childIndexOfPathName(name: PathName): number | undefined
|
|
|
|
|
// "receive" → 0, "change" → 1, "defi" → 7, unknown → undefined
|
2026-02-26 11:19:47 +01:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-26 10:50:40 +01:00
|
|
|
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.
|