WizardConnect/docs/protocol.md
Håvard Kittelsen 460e113e75 feat: multislot — more than one wallet key per transaction input
`inputPaths` names one HD key per entry, which is the whole story for a P2PKH
input: one input, one signature. A contract input is not like that. Its unlocking
bytecode may carry several sig/pubkey placeholders — an N-of-N agreement, or a
function taking (sig a, pubkey A, sig b, pubkey B) — and nothing in a 3-tuple can
say which placeholder a key fills.

So entries gain an optional fourth element, `slot`, and an input's index is listed
once per placeholder. `slot` defaults to 0, so every existing 3-tuple keeps
meaning exactly what it meant and no current dapp needs a capability check.
Sending `slot > 0`, or repeating an inputIndex, requires the wallet to advertise
`multislot` — an older wallet keeps one key per input and would return a
transaction missing signatures with nothing to say why.

This supersedes the `multislot` branch, which was cut before the
randomTradeSummary revert and still carries the reverted `txSummary` field. The
protocol design there is good and is kept: the placeholder format (65-byte
Schnorr sig push, 33-byte compressed pubkey push, spliced value-for-value so
offsets survive), the capability gate, and the three wallet rules — don't
deduplicate by inputIndex, compute each input's sighash once, reject rather than
under-fill. Two things are changed.

SLOTS ARE POSITIONAL, NOT BY VACANCY

The earlier definition numbered slots by scanning the template for zero-filled
pushes. That renumbers them as they fill, and a template is not always all
zeroes: in the N-of-N case the docs give as motivation, the dapp may have already
written the counterparty's signature into the first position. Verified against
that implementation's own reference fill, on a two-slot input with slot 0
pre-filled:

  asked for slot 1 -> not filled at all (under-fill)
  asked for slot 0 -> writes into the SECOND slot, silently

Here a push already holding a value still occupies its slot, so `slot` means the
same thing to the dapp that built the template and the wallet filling it,
whatever order things happen in. Overwriting a filled slot is an error rather
than a no-op, because discarding a counterparty's signature is not recoverable.

THE SCAN PARSES PUSHES INSTEAD OF SEARCHING FOR BYTES

A hex-substring search for the placeholder pattern can match bytes that merely
sit inside a larger push, splicing a signature into the middle of unrelated data.
findPlaceholders walks the script's push structure (direct pushes and
OP_PUSHDATA1/2/4) and throws on a truncated template rather than guessing at one
it cannot parse.

WHY THE HELPERS ARE IN THE LIBRARY

The placeholder layout is part of the wire contract — the dapp builds the
template, the wallet splices into it, and they must agree byte for byte or the
transaction is silently unspendable. Leaving every wallet to implement the scan
is how that goes wrong, and both failure modes above come from a reasonable
implementation of a reasonable-sounding rule.

fillPlaceholder throws on a missing slot, a wrong-length value, or an already
filled slot; unfilledPlaceholders is what "reject, don't under-fill" checks. That
makes the rule enforceable rather than something to remember.

Deliberately NOT included: the sighash-validation module from the earlier branch.
It is a separate concern — the library does no transaction signing today, so
adding a validator for it is new surface that deserves its own review, and its
signature-detection heuristic needs work (a 65-byte push is treated as a
signature, which an uncompressed public key also is).

23 core tests, including both misplacement cases above, the inside-a-larger-push
false positive, OP_PUSHDATA1 headers, truncated templates, and fill-order
independence.

Docs: protocol.md, extensions.md, wallet.md, dapp.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:37:42 +02:00

511 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, number?][]; // [inputIndex, pathName, addressIndex, slot?]
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 positional tuples with 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 the wallet must contribute to the input at position `inputIndex`. Only
inputs that require wallet signing need an entry — contract inputs whose unlocking bytecode the dapp
fully provides can be omitted. This allows the wallet to sign each input without scanning or guessing
which key was used.
An input with **no** entry is not the wallet's to sign and is left untouched. That is distinct from an
entry the wallet cannot satisfy, which is an error — see the wallet rules below.
#### Multiple placeholders per input (`slot`)
A contract input may carry several `sig`/`pubkey` placeholders, each filled by a different key — an
N-of-N agreement, or a contract function taking `(sig a, pubkey A, sig b, pubkey B)`. The dapp builds
the unlocking bytecode with one placeholder per position, lists the input's index once per
placeholder, and sets the fourth element:
```
inputPaths: [
[3, "receive", 0, 0], // input 3, slot 0 -> key receive/0
[3, "defi", 7, 1], // input 3, slot 1 -> key defi/7
]
```
**Placeholder format.** Signatures **MUST** be Schnorr — the scheme depends on the real value being
exactly as long as the placeholder it replaces, and DER ECDSA signatures are variable-length.
- a **signature placeholder** is a data push of **65 zero bytes** (`0x41` then 65 × `0x00`) — a
64-byte Schnorr signature plus its trailing sighash-flag byte;
- a **public-key placeholder** is a data push of **33 zero bytes** (`0x21` then 33 × `0x00`) — a
compressed public key.
Because the real value is exactly the placeholder's length, the wallet splices it in value-for-value,
keeping the push opcode and the total bytecode length unchanged. Every other placeholder's offset
therefore survives, and fills may be applied in any order.
**`slot` is positional.** It counts signature-sized and public-key-sized pushes independently,
left-to-right from 0 — *whether or not a push currently holds a value*. A push already carrying a
counterparty's signature still occupies its slot.
This matters because a template is not always all zeroes. In an N-of-N where the dapp has already
written the counterparty's signature into the first position, `slot` must still mean "the second
position" to both sides. An implementation that numbered slots by scanning for *empty* pushes would
renumber them as they fill, and write the wallet's signature into the wrong position — producing a
transaction that serialises, broadcasts, and is rejected by consensus.
An entry `[i, path, addr, k]` tells the wallet: derive the key for `(path, addr)`, write its signature
into the k-th signature slot of input `i`, and write its compressed public key into the k-th
public-key slot of input `i` if one is present. An input may legally have a different number of each
(2 signature slots but 1 public-key slot, when one key is hard-coded in the redeem script).
`slot` is optional and defaults to 0, so an entry without one fills the first signature (and first
public-key) slot — identical to single-signature behaviour. **Existing 3-tuple requests are
unchanged.**
The dapp ships the unsigned template at `sourceOutputs[i].unlockingBytecode` (equivalently, the
decoded `transaction.inputs[i].unlockingBytecode`). Ordering of the `inputPaths` tuples is not
significant; the `slot` value is authoritative.
**Wallet rules (MUST):**
- **Do not deduplicate `inputPaths` by `inputIndex`.** Several entries may share one index, one per
slot. Collapsing them into a map keyed by index drops signatures and yields an unspendable
transaction.
- **Compute each input's sighash once.** All signatures within one input commit to the *same* sighash:
the signing serialization covers the redeem script and the transaction, not the unlocking bytecode
being filled. Filling one slot does not invalidate another's sighash — compute it once per input and
vary only the key.
- **Reject, don't under-fill.** If an entry cannot be satisfied — the path/index maps to no key, or the
input has no placeholder at that slot — fail the whole request with an error. Never return a
transaction with a leftover zero placeholder, which is silently unspendable.
`@wizardconnect/core` provides `findPlaceholders`, `fillPlaceholder` and `unfilledPlaceholders` so
wallets do not each reimplement the scan; `fillPlaceholder` throws on a missing slot, a wrong-length
value, or an attempt to overwrite a filled slot, which makes "reject, don't under-fill" the default
rather than a rule to remember. See [extensions.md § multislot](extensions.md#multislot).
#### Capability negotiation (required)
Sending any entry with `slot > 0`, or more than one entry for the same `inputIndex`, requires the
wallet to advertise the `multislot` extension in `wallet_ready`. A wallet that predates the extension
keeps one key per input and would return a transaction missing signatures, with nothing to say why.
```typescript
import { peerSupportsMultislot, requiresMultislot } from "@wizardconnect/core";
const session = walletReady.session["hdwalletv1"];
if (requiresMultislot(inputPaths) && !peerSupportsMultislot(session.extensions)) {
// This wallet cannot serve the request. Tell the user; do NOT downgrade to a
// single signature, which would produce an unspendable transaction.
}
```
#### 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.