WizardConnect/docs/extensions.md
Dagur Valberg Johannsson c8c1000ae7 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

10 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'",
      },
    };
  },
};

Whatever getExtensions() returns becomes session["hdwalletv1"].extensions verbatim in the wallet_ready message — the WalletConnectionManager merges it in (it does not transform the keys). That is the exact location the dapp reads (see "Discovering extensions" below). So returning { multislot: {} } is what the dapp sees as session["hdwalletv1"].extensions.multislot.

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

Version note: folding getExtensions() into the session was added in a later @wizardconnect release, together with the EXT_MULTISLOT constant and the optional slot (4th) element of the inputPaths tuple type. Packages from the 0.1.x line ignore getExtensions() and type inputPaths as a 3-tuple without EXT_MULTISLOT, so an extension advertised that way silently fails to negotiate and a TypeScript consumer won't compile the 4-tuple. Confirm your @wizardconnect/core / @wizardconnect/wallet version actually ships these (or read the slot positionally and use the "multislot" string literal) before relying on them.

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 Wallet can fill more than one sig/pubkey placeholder per input, routed by the slot element of inputPaths. Needed for contracts that take multiple signatures from distinct keys in a single input. Proposed

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

multislot

By default an inputPaths entry is a 3-tuple [inputIndex, pathName, addressIndex] and the wallet contributes exactly one signature (and at most one public key) per input. Some contracts need the wallet to fill several sig/pubkey placeholders in a single input, each with a different key.

A wallet that supports this advertises multislot in its session extensions:

{
  "paths": [ /* ... */ ],
  "extensions": { "multislot": {} }
}

The value is {} — no handshake data is needed; presence alone signals support.

When advertised, the dapp may add a fourth element, slot, to inputPaths entries and may list the same inputIndex more than once (one entry per placeholder slot). The wallet fills the slot-th sig placeholder and the slot-th pubkey placeholder of that input with the named key. See protocol.md § Multiple placeholders per input for the placeholder byte format and exact slot semantics.

A dapp MUST NOT emit slot > 0 (or repeat an inputIndex) toward a wallet that has not advertised multislot. A wallet that does not advertise it continues to receive only ordinary 3-tuple, single-signature requests, so it is unaffected. This negotiation is what prevents an unaware wallet from silently filling only the first placeholder and returning an unspendable transaction.