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

13 KiB
Raw Blame History

Dapp integration

Dapp integration uses @wizardconnect/dapp (for session management and pubkey state) together with @wizardconnect/core (for the relay connection and URI generation).

DappConnectionManager

Manages a single dappwallet session. Handles the handshake, xpub storage, and sign request round-trips. Most dapps only ever have one active session at a time.

class DappConnectionManager extends EventEmitter {
  readonly pubkeyState: DappPubkeyStateManager;

  walletName: string | null;
  walletIcon: string | null;
  /** The agreed protocol name after handshake, e.g. "hdwalletv1". Null until wallet_ready. */
  protocol: string | null;

  constructor(dappName?: string, dappIcon?: string, options?: {
    /** Session persistence config. Enabled by default (key: "wizardconnect-session",
     *  storage: localStorage). Pass `false` to disable. */
    session?: DappSessionOptions | false;
  })

  /** Call from the RelayStatusCallback each time the relay status changes. */
  updateConnection(client: RelayClient | null, status: RelayStatus): void

  isWalletDiscovered(): boolean

  /** Convenience: build a full SignTransactionRequest, send it, and optionally
   *  cancel via AbortSignal. See "Sending a sign request" below. */
  signTransaction(
    request: Pick<SignTransactionRequest, "transaction" | "inputPaths">,
    options?: { signal?: AbortSignal },
  ): Promise<SignTransactionResponse>

  /** Low-level: send a fully constructed sign request. */
  sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>

  /** Cancel an in-flight sign request by sequence number. */
  sendSignCancel(sequence: number, reason?: string): Promise<void>

  /** Get the next sequence number (for manual request construction). */
  nextSequence(): number

  /** Send a UserDisconnect courtesy message to the wallet. */
  sendDisconnect(message?: string): Promise<void>

  /** Get raw PathXpub[] received in wallet_ready (for caching). */
  getSessionPaths(): PathXpub[]

  /** Restore cached xpub paths — enables getPubkey() without wallet_ready. */
  restoreSessionPaths(paths: PathXpub[]): void

  // Session persistence (see "Session persistence" section below)
  attachRelay(relay: DappRelayResult): void
  loadStoredSession(): StoredSession | null
  clearStoredSession(): void

  // Events
  on("walletready",     (msg: WalletReadyMessage)                                    => void)
  on("messagesent",     (msg: ProtocolMessage)                                       => void)
  on("messagereceived", (msg: ProtocolMessage)                                       => void)
  on("disconnect",      (reason: DisconnectReason, message: string | undefined)      => void)
}

The "messagereceived" event fires for all protocol messages, including extension-defined actions. Use it to handle custom messages from wallet extensions. See extensions.md for the extension system and graceful degradation patterns.

Pubkey state — convenience delegation

DappConnectionManager delegates to pubkeyState for all pubkey operations. These methods are also available directly on the manager:

// Get a pubkey (derives on demand from xpub if not cached)
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined

// Get all cached pubkeys for a path
getPubkeys(childIndex: number): Map<bigint, Uint8Array>

// Get/set the current address index for a path
getAddressIndex(childIndex: number): bigint
setAddressIndex(childIndex: number, index: bigint): void

// Get a smart "next index to use" (see pubkey-derivation.md)
getIndexToUse(childIndex: number, options?: { index?: bigint; reuseLast?: boolean }): bigint

// Get the min/max indices seen for a path
getIndexRange(childIndex: number): { min?: bigint; max?: bigint }

// Remove a used change address from the gap-fill queue
removeFromChangeQueue(index: bigint): void

// Get the stored xpub node (after wallet_ready)
getXpubNode(childIndex: number): HdPublicNodeValid | undefined

// Get raw PathXpub[] from wallet_ready (for caching)
getSessionPaths(): PathXpub[]

// Restore cached PathXpub[] — populates pubkeyState so getPubkey works without wallet_ready
restoreSessionPaths(paths: PathXpub[]): void

Child index values: 0 = receive, 1 = change, 7 = defi (Cauldron). These are internal to the dapp layer; use childIndexOfPathName() to convert from PathName if needed. It returns undefined for extension path names — callers should skip those.

Session lifecycle

Initial connect

import { initiateDappRelay } from "@wizardconnect/core";
import { DappConnectionManager } from "@wizardconnect/dapp";

const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.png");

const relay = initiateDappRelay(
  (payload) => {
    dappMgr.updateConnection(payload.client, payload.status);
  },
);

// Persist relay credentials and auto-save walletPublicKey on key exchange
dappMgr.attachRelay(relay);

// Show relay.uri as a QR code for the wallet to scan.
console.log("Scan this URI:", relay.uri);

After wallet connects

dappMgr.on("walletready", (msg) => {
  console.log("Wallet:", msg.wallet_name);
  // pubkeyState is now populated with xpub nodes.
  // You can start deriving addresses.
});

Deriving addresses

// Get the first receive address pubkey:
const RECEIVE = 0;
const pubkey = dappMgr.getPubkey(RECEIVE, 0n);  // derives from xpub if needed

See pubkey-derivation.md for full details.

Sending a sign request

The signTransaction convenience method auto-fills action, sequence, and time:

const response = await dappMgr.signTransaction({
  transaction: {
    transaction: txHex,
    sourceOutputs,
    userPrompt: "Confirm swap",
    broadcast: true,
  },
  inputPaths: [[0, "receive", 0], [1, "defi", 5]],  // [inputIndex, pathName, addressIndex, slot?]
});

console.log("Signed tx:", response.signedTransaction);

Contract inputs needing more than one key

An inputPaths entry takes an optional fourth element, slot, so one contract input can be filled by several keys — list the input's index once per placeholder. Check the wallet supports it first; a wallet that predates the extension returns a transaction missing signatures:

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

const inputPaths = [
  [3, "receive", 0, 0],   // input 3, slot 0
  [3, "defi",    7, 1],   // input 3, slot 1
];

const session = /* wallet_ready session["hdwalletv1"] */;
if (requiresMultislot(inputPaths) && !peerSupportsMultislot(session.extensions)) {
  // Do NOT downgrade to a single signature — the result would be unspendable.
}

See protocol.md § Multiple placeholders per input.

Cancellation via AbortSignal

Pass an AbortSignal to automatically cancel the request when aborted. This sends sign_cancel to the wallet and rejects the promise with an AbortError:

const controller = new AbortController();
cancelButton.onclick = () => controller.abort("User cancelled");

try {
  const response = await dappMgr.signTransaction(
    { transaction: { ... }, inputPaths: [...] },
    { signal: controller.signal },
  );
} catch (err) {
  if (err.name === "AbortError") {
    console.log("User cancelled the signature request");
  }
}

Low-level: sendSignRequest

For full control over the request, use sendSignRequest directly:

const seq = dappMgr.nextSequence();
const request: SignTransactionRequest = {
  action: RelayMsgAction.SignTransactionRequest,
  sequence: seq,
  time: Math.floor(Date.now() / 1000),
  transaction: { ... },
  inputPaths: [[0, "receive", 0]],
};

const response = await dappMgr.sendSignRequest(request);
// Cancel with: await dappMgr.sendSignCancel(seq, "reason");

Disconnecting

// Dapp-initiated: send courtesy message, clear session, then tear down the relay
dappMgr.clearStoredSession();
await dappMgr.sendDisconnect("user closed the tab");
relay.cleanup();

// Listen for wallet-initiated disconnect or protocol mismatch:
dappMgr.on("disconnect", (reason, message) => {
  if (reason === DisconnectReason.ProtocolMismatch) {
    console.error("Protocol mismatch:", message);
  } else {
    console.log("Wallet disconnected:", reason, message);
  }
  relay.cleanup();
});

The disconnect event fires in two cases:

  1. Remote disconnect: the wallet sent a disconnect message (any reason).
  2. Protocol mismatch: handleWalletReady found no overlap between the dapp's and wallet's supported_protocols. The dapp automatically sends a ProtocolMismatch disconnect to the wallet before emitting the event.

Session persistence

Session persistence is enabled by default. The manager automatically:

  • On construction: restores xpub paths from storage so getPubkey() works immediately.
  • On walletready: saves walletName, walletIcon, and xpub paths to storage.

The default storage key is "wizardconnect-session" and the default backend is localStorage.

Saving session data

Call attachRelay() after initiateDappRelay() to persist relay credentials and automatically save the wallet public key when key exchange completes:

const relay = initiateDappRelay(callback);
dappMgr.attachRelay(relay); // saves credentials + auto-saves walletPublicKey

// On disconnect:
dappMgr.clearStoredSession();

Loading for reconnection

Use loadStoredSession() on an existing manager, or the standalone loadSession() when you need to read the session before creating the manager (e.g. to get relay credentials for initiateDappRelay):

import { loadSession } from "@wizardconnect/dapp";

const session = loadSession(); // uses default key and localStorage
if (session?.walletPublicKey) {
  const relay = initiateDappRelay(callback, { existingCredentials: session });
}

Configuration

// Default: session enabled, key "wizardconnect-session", localStorage
const mgr = new DappConnectionManager("My Dapp");

// Custom key:
const mgr = new DappConnectionManager("My Dapp", undefined, {
  session: { key: "my-app-session" },
});

// Custom storage backend (e.g. for React Native or SSR):
const mgr = new DappConnectionManager("My Dapp", undefined, {
  session: { storage: myCustomStorage },
});

// Disable session persistence:
const mgr = new DappConnectionManager("My Dapp", undefined, {
  session: false,
});

The SessionStorage interface matches the Web Storage API:

interface SessionStorage {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
}

Manual path management

For advanced use cases, getSessionPaths() and restoreSessionPaths() are still available:

const paths = dappMgr.getSessionPaths();     // raw PathXpub[] from wallet_ready
dappMgr.restoreSessionPaths(paths);          // re-populate pubkeyState from cached paths

restoreSessionPaths throws if any xpub string is invalid.

Reconnection

updateConnection() is called on every relay status change. When status.status === "connected", it calls onConnected() which waits for key exchange and then sends a fresh dapp_ready. The walletDiscovered flag carries over reconnects (it is only reset by creating a new manager), so the correct wallet_discovered value is sent on each reconnect.

When wallet_ready is received and there are pending (unresponded) sign requests, the manager automatically re-sends them. This handles the case where the user triggers a signature in the dapp before the wallet app is open — the relay's time filter would otherwise discard the original request. Dapps do not need to handle this manually; sendSignRequest promises remain valid across reconnects.

Using initiateDappRelay without DappConnectionManager

If you need lower-level control (e.g., in the test-cli), you can work directly with the RelayClient and handle wallet_ready manually:

const relay = initiateDappRelay(statusCallback, options);

relay.events.on("keyexchangecomplete", async (walletPubkey) => {
  // Key exchange done — wait for relay client to be fully ready
  while (!relay.client.isKeyExchangeComplete()) {
    await sleep(50);
  }

  relay.client.on("message", (msg) => {
    if (isDappReadyMessage(msg)) { /* ... */ }
    if (isWalletReadyMessage(msg)) { /* ... */ }
  });

  await relay.client.relay({ action: RelayMsgAction.DappReady, ... });
});

Using with React

For React dapps, prefer the useWizardConnect hook from @wizardconnect/react over managing the relay lifecycle manually. The hook handles session persistence, auto-reconnect, and relay cleanup automatically. See react.md.

Dapps that need a custom wallet adapter (e.g. Cauldron, Moria) can use the hook and wrap the returned manager in their adapter:

const wc = useWizardConnect({ dappName: "My Dapp" });

useEffect(() => {
  if (!wc.manager) return;
  const wallet = new MyWalletAdapter(wc.manager);
  // dispatch wallet to your store
}, [wc.manager]);

The DappConnectionManager is created by the hook; the adapter receives it rather than creating its own.