`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>
13 KiB
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 dapp–wallet 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:
- Remote disconnect: the wallet sent a
disconnectmessage (any reason). - Protocol mismatch:
handleWalletReadyfound no overlap between the dapp's and wallet'ssupported_protocols. The dapp automatically sends aProtocolMismatchdisconnect 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: saveswalletName,walletIcon, and xpubpathsto 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.