WizardConnect/docs/extensions.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

9.8 KiB

Extensions

This document covers hdwalletv1 protocol-level extensions — optional capabilities that extend the application protocol (extra path names, custom message actions, per-wallet features). For transport-level capabilities that apply below the application protocol regardless of which protocol is in use (chunking, future: compression), see transport.md § Transport-level extensions.

The hdwalletv1 protocol supports optional extensions that let wallets and dapps negotiate additional capabilities beyond the core sign-transaction flow. Extensions are backward-compatible: existing wallets and dapps that don't know about extensions continue to work unchanged.

How extensions work

Extensions use three mechanisms, all of which are optional and additive:

1. Session extensions field

Wallets advertise supported extensions in the wallet_ready handshake via an extensions field on the Hdwalletv1Session object:

interface Hdwalletv1Session {
  paths: PathXpub[];
  extensions?: Record<string, unknown>;
}

Each key in extensions is an extension name. Its presence indicates the wallet supports that extension. The value carries extension-specific handshake data (derivation paths to the hardened gate where the wallet exports xpubs), or {} if no data is needed.

Example:

{
  "paths": [
    { "name": "receive",       "xpub": "xpub6..." },
    { "name": "change",        "xpub": "xpub6..." },
    { "name": "defi",          "xpub": "xpub6..." },
    { "name": "stealth_spend", "xpub": "xpub6..." },
    { "name": "stealth_scan",  "xpub": "xpub6..." },
    { "name": "rpa_spend",     "xpub": "xpub6..." },
    { "name": "rpa_scan",      "xpub": "xpub6..." }
  ],
  "extensions": {
    "bch_stealth_bip352": {
      "spend_path": "m/352'/145'/0'/0'",
      "scan_path":  "m/352'/145'/0'/1'"
    },
    "rpa_bip47": {
      "spend_path": "m/47'/145'/0'/0'",
      "scan_path":  "m/47'/145'/0'/1'"
    },
    "decrypt": { "public_key": "02abc...", "scheme": "ecies" }
  }
}

2. Additional path names

PathName is an open string type. Wallets may include paths beyond the well-known receive/change/defi set. Extension-defined paths are carried in the standard paths array:

{
  "paths": [
    { "name": "receive",       "xpub": "xpub6..." },
    { "name": "change",        "xpub": "xpub6..." },
    { "name": "stealth_spend", "xpub": "xpub6..." },
    { "name": "stealth_scan",  "xpub": "xpub6..." },
    { "name": "rpa_spend",     "xpub": "xpub6..." },
    { "name": "rpa_scan",      "xpub": "xpub6..." }
  ],
  "extensions": {
    "bch_stealth_bip352": {
      "spend_path": "m/352'/145'/0'/0'",
      "scan_path":  "m/352'/145'/0'/1'"
    },
    "rpa_bip47": {
      "spend_path": "m/47'/145'/0'/0'",
      "scan_path":  "m/47'/145'/0'/1'"
    }
  }
}

Dapps should ignore path names they do not recognize. The standard pubkey derivation logic (DappPubkeyStateManager) automatically skips unknown paths.

3. Custom message actions

Extensions may define new message action strings beyond the well-known set. Custom messages follow the standard ProtocolMessage shape (action + time) and use the existing relay transport.

Convention for request/response operations:

// Request (dapp → wallet):
{ action: "<operation>_request", sequence: number, time: number, ...params }

// Response (wallet → dapp):
{ action: "<operation>_response", sequence: number, time: number, ...result }

The sequence field ties responses to requests, matching the pattern used by sign_transaction_request/sign_transaction_response.

Wallet side: custom messages are emitted via the "message" event on WalletConnectionManager:

manager.on("message", (connectionId, msg) => {
  if (msg.action === "decrypt_request") {
    // handle decrypt request
  }
});

Dapp side: all messages (including custom ones) are emitted via the "messagereceived" event on DappConnectionManager:

manager.on("messagereceived", (msg) => {
  if (msg.action === "decrypt_response") {
    // handle decrypt response
  }
});

Implementing an extension (wallet side)

Wallets advertise extensions by implementing optional methods on WalletAdapter:

interface WalletAdapter {
  // ... core methods ...

  /** Additional paths to include in the session (e.g. stealth_scan). */
  getAdditionalPaths?(): PathXpub[];

  /** Extension data for the session handshake. */
  getExtensions?(): Record<string, unknown>;
}

Example:

const adapter: WalletAdapter = {
  // ... core implementation ...

  getAdditionalPaths() {
    return [
      { name: "stealth_spend", xpub: deriveXpub("m/352'/145'/0'/0'") },
      { name: "stealth_scan",  xpub: deriveXpub("m/352'/145'/0'/1'") },
      { name: "rpa_spend",     xpub: deriveXpub("m/47'/145'/0'/0'") },
      { name: "rpa_scan",      xpub: deriveXpub("m/47'/145'/0'/1'") },
    ];
  },

  getExtensions() {
    return {
      "bch_stealth_bip352": {
        "spend_path": "m/352'/145'/0'/0'",
        "scan_path":  "m/352'/145'/0'/1'",
      },
      "rpa_bip47": {
        "spend_path": "m/47'/145'/0'/0'",
        "scan_path":  "m/47'/145'/0'/1'",
      },
    };
  },
};

Wallets that don't implement these methods produce the same session as before (receive/change/defi paths only, no extensions field).

Discovering extensions (dapp side)

Dapps check for extension support after receiving wallet_ready:

manager.on("walletready", (msg) => {
  const session = msg.session["hdwalletv1"] as Hdwalletv1Session;

  if (session.extensions?.bch_stealth_bip352) {
    const scanPath  = session.paths.find(p => p.name === "stealth_scan");
    const spendPath = session.paths.find(p => p.name === "stealth_spend");
    // enable stealth address features
  } else {
    // show: "Stealth payments require a wallet that supports BCH Stealth (BIP352)"
  }

  if (session.extensions?.rpa_bip47) {
    const rpaSpend = session.paths.find(p => p.name === "rpa_spend");
    const rpaScan  = session.paths.find(p => p.name === "rpa_scan");
    // enable RPA features
  }
});

Dapps should degrade gracefully when an extension is absent. If a feature requires an extension the wallet doesn't support, inform the user rather than failing silently.

Known extensions

Extension name Path names Hardened gate paths Purpose Status
bch_stealth_bip352 stealth_spend, stealth_scan m/352'/145'/0'/0', m/352'/145'/0'/1' BCH stealth addresses (BIP352 structure). Wallet exports xpubs at hardened gates; dapp derives /0 child locally. Standard — BCR post
rpa_bip47 rpa_spend, rpa_scan m/47'/145'/0'/0', m/47'/145'/0'/1' BIP47 reusable payment addresses. Wallet exports xpubs at hardened gates; dapp derives /0 child locally. Standard — BCR post
decrypt Dapp-side encrypted storage. Wallet provides a public key; dapp encrypts data for storage and sends decrypt_request messages when the data is needed. Proposed
multislot Fill more than one sig/pubkey placeholder per transaction input, routed by the slot element of inputPaths. Needed for contract inputs requiring several keys. Implemented — see below

See the discussions and specifications for each extension as they are formalized.


multislot

Lets one transaction input carry several sig/pubkey placeholders, each filled by a different key. Without it, inputPaths names one key per input and a contract requiring two signatures cannot be expressed.

Advertised with no payload — support is the whole message:

{ "extensions": { "multislot": {} } }

This extension adds no new actions. It widens an existing field: inputPaths entries gain an optional fourth element, slot. Existing 3-tuples are unchanged and mean exactly what they meant, so a dapp that never sets slot needs no capability check.

Wallet side

import {
  multislotExtensionAdvertisement,
  fillPlaceholder,
  unfilledPlaceholders,
} from "@wizardconnect/core";

const adapter: WalletAdapter = {
  getExtensions: () => multislotExtensionAdvertisement(),

  async signTransaction(request) {
    // One sighash per input, reused for every slot — see protocol.md.
    for (const [inputIndex, pathName, addressIndex, slot = 0] of request.inputPaths) {
      const signature = signSchnorr(sighashFor(inputIndex), keyFor(pathName, addressIndex));
      bytecode = fillPlaceholder(bytecode, "signature", slot, signature);
      // ...and the matching public key, if the contract has a placeholder for it.
    }
    if (unfilledPlaceholders(bytecode).length > 0) {
      throw new Error("refusing to return an under-filled transaction");
    }
  },
};

fillPlaceholder throws rather than returning a partial result when the slot does not exist, the value is the wrong length, or the slot is already filled. Those are the three ways to produce a transaction that looks fine and is unspendable.

Dapp side

Check before sending — a request needing slots is not something an older wallet can partially serve:

import { peerSupportsMultislot, requiresMultislot } from "@wizardconnect/core";

if (requiresMultislot(inputPaths) && !peerSupportsMultislot(session.extensions)) {
  // Tell the user their wallet cannot do this. Do NOT fall back to one signature.
}

See protocol.md § Multiple placeholders per input for the placeholder byte format, the positional definition of slot, and the wallet rules.