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>
12 KiB
Wallet integration
Wallet integration uses @wizardconnect/wallet. The wallet implements the WalletAdapter
interface and hands it to WalletConnectionManager, which handles everything else.
WalletAdapter
interface WalletAdapter {
walletName: string; // shown in the dapp's connection UI
walletIcon: string; // URL or data-URI, shown in the dapp's connection UI
/**
* Return the relay identity private key for this session.
* May be ephemeral (random per session) or stable (HD-derived) — both work.
* The dapp learns the wallet's public key from the wallet_ready message,
* so stability across restarts is not required.
*/
getRelayPrivateKey(): Uint8Array;
/** Returns the compressed 33-byte secp256k1 public key at path/index. */
getPublicKey(path: DerivationPath, index: bigint): Uint8Array;
/** Returns the BIP32 base58-encoded xpub for the given derivation path.
* The dapp derives all addresses from this — no further pubkey requests needed. */
getXpub(path: DerivationPath): string;
/** Sign the transaction. May show approval UI to the user.
* Called when the wallet has received and validated a sign_transaction_request. */
signTransaction(request: SignTransactionRequest): Promise<SignTransactionResult>;
/** Optional: additional paths to include in the session (e.g. stealth_scan). */
getAdditionalPaths?(): PathXpub[];
/** Optional: extension data for the session handshake. */
getExtensions?(): Record<string, unknown>;
}
DerivationPath
enum DerivationPath {
Receive = 0, // m/44'/145'/0'/0 — external (receive) addresses
Change = 1, // m/44'/145'/0'/1 — internal (change) addresses
Cauldron = 7, // m/44'/145'/0'/7 — DeFi/Cauldron addresses
}
The numeric values are wallet-internal; the protocol uses names (receive, change, defi).
childIndexOfPath(path) and pathOfChildIndex(index) convert between them.
SignTransactionResult
interface SignTransactionResult {
signedTransactionHex: string;
}
WalletConnectionManager
class WalletConnectionManager extends EventEmitter {
constructor(adapter: WalletAdapter)
// Connect to a dapp. Returns a stable connection ID.
// If a connection for this URI already exists, returns the existing ID.
connect(uri: string): string
// Tear down a specific connection, sending a UserDisconnect courtesy message.
disconnect(connectionId: string): void
// Tear down all connections.
disconnectAll(): void
// Snapshot of all connections for UI rendering.
getConnections(): Record<string, RelayConnectionState>
// Send the signed transaction back to the dapp.
sendSignResponse(connectionId: string, sequence: number, signedTx: string): Promise<void>
// Send an error back to the dapp (user rejected, signing failed, etc.)
sendSignError(connectionId: string, sequence: number, errorMessage: string): Promise<void>
// Events
on("connectionStatusChanged", (id: string, status: RelayStatus) => void)
on("pendingSignRequest", (req: PendingSignRequest) => void)
on("connectionsChanged", () => void)
on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void)
on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages
}
RelayConnectionState
interface RelayConnectionState {
id: string;
uri: string;
status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" }
label: string; // dapp name once known, otherwise "Connecting..."
dappName: string | null;
dappIcon: string | null;
connectedAt: number; // Unix ms
}
PendingSignRequest
interface PendingSignRequest {
connectionId: string;
request: SignTransactionRequest;
}
Connection lifecycle
connect()
- A unique
connectionIdis generated. initiateWalletRelay(statusCallback, { uri, walletPrivateKey })is called.- The relay decodes the URI, extracts the dapp's public key and secret, and connects.
- On the first
"connected"status,onConnected()is called.
onConnected()
walletReadySentThisCycleis reset tofalse.- A notification processor interval is started (1 second, for retry on send errors).
- The wallet polls until
client.isKeyExchangeComplete()(key exchange with dapp done). pushWalletReady()is called.
pushWalletReady()
Sends wallet_ready with:
supported_protocols: ["hdwalletv1"]wallet_name,wallet_iconfrom the adapter.session["hdwalletv1"]: one{ name, xpub }perDerivationPath(receive/change/defi), plus any additional paths fromadapter.getAdditionalPaths()and extension data fromadapter.getExtensions(). See extensions.md.dapp_discovered: whether the dapp was seen in this runtime session.
The message is pushed to a per-connection notificationQueue and flushed immediately. Retry
is handled by the interval processor — if relay() throws (e.g. network drop), the message
stays in the queue and is retried on the next tick.
Receiving dapp_ready
wallet_discovered=false → reset walletReadySentThisCycle, call pushWalletReady() again
wallet_discovered=true → set dappDiscovered=true, no further action
dapp_name and dapp_icon are captured from the first dapp_ready that includes them.
Receiving disconnect
When a disconnect message arrives from the dapp:
- The
remoteDisconnectevent is emitted with(connectionId, reason, message). - The connection is cleaned up without sending a reply disconnect.
Receiving sign_transaction_request
The wallet emits pendingSignRequest with the connectionId and the full request. The host
application is responsible for:
- Queueing or displaying the request.
- Getting user approval.
- Calling
sendSignResponse(connectionId, sequence, signedTxHex)orsendSignError(connectionId, sequence, errorMessage).
The wallet library does not auto-sign or auto-reject anything.
Deduplication: The dapp re-sends pending sign requests when the wallet reconnects (see
protocol docs). To prevent duplicate approval dialogs,
WalletConnectionManager tracks in-flight sequences and silently drops requests whose sequence
has already been emitted. The guard is cleared when a response is sent (sendSignResponse /
sendSignError) or a sign_cancel is received.
SIGHASH enforcement: The wallet MUST sign every input with
SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS. See the
SIGHASH requirement section in the protocol
docs for the security rationale. Because the dapp specifies inputPaths (which may list multiple keys per input for contract placeholders), the wallet trusts the
dapp's key selection — SIGHASH_ALL is what makes this safe (a wrong-key signature is simply
invalid and cannot be repurposed).
Minimal example
import { WalletConnectionManager } from "@wizardconnect/wallet";
import type { WalletAdapter, DerivationPath } from "@wizardconnect/wallet";
class MyAdapter implements WalletAdapter {
walletName = "My Wallet";
walletIcon = "";
getRelayPrivateKey() { return crypto.getRandomValues(new Uint8Array(32)); }
getPublicKey(path: DerivationPath, index: bigint) { /* ... */ }
getXpub(path: DerivationPath) { /* ... */ }
async signTransaction(request) {
// Show approval UI, sign with SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS, return hex.
// See protocol.md "SIGHASH requirement" — other sighash flags MUST be rejected.
// For each request.inputPaths entry [inputIndex, pathName, addressIndex, slot?]
// derive the key and, for a contract input, fill the slot-th sig placeholder
// (a 65-zero push) and the slot-th pubkey placeholder (a 33-zero push). slot
// defaults to 0. Only honour slot > 0 / repeated indices if you advertised the
// `multislot` extension (see below).
// Optionally: import { validateSighashFlags } from "@wizardconnect/wallet"; to verify
// after filling placeholders.
return { signedTransactionHex: "..." };
}
}
const manager = new WalletConnectionManager(new MyAdapter());
// When user scans a QR code:
const connId = manager.connect("wiz://?p=...&s=...");
// When a sign request arrives:
manager.on("pendingSignRequest", async ({ connectionId, request }) => {
try {
const result = await showApprovalUI(request);
await manager.sendSignResponse(connectionId, request.sequence, result.signedTransactionHex);
} catch {
await manager.sendSignError(connectionId, request.sequence, "User rejected");
}
});
// When the dapp disconnects:
manager.on("remoteDisconnect", (id, reason, message) => {
console.log(`Dapp disconnected: ${reason}`, message);
store.dispatch(setConnections(manager.getConnections()));
});
// For Redux / UI updates:
manager.on("connectionsChanged", () => {
store.dispatch(setConnections(manager.getConnections()));
});
Multiple signatures per input (the multislot extension)
By default a wallet contributes 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. This is the multislot extension. Implementing it has two halves.
1. Advertise support in the handshake
Return the extension key from your adapter's getExtensions(). The manager folds whatever you return
into the wallet's wallet_ready message at session["hdwalletv1"].extensions, which is exactly where
the dapp looks:
import { EXT_MULTISLOT } from "@wizardconnect/core";
class MyAdapter implements WalletAdapter {
// ...
getExtensions() {
return { [EXT_MULTISLOT]: {} }; // presence = support; no handshake data needed
}
}
A dapp will only send slotted / repeated-index requests to a wallet that advertised this. If you do not advertise it you keep receiving ordinary single-signature requests and need do nothing else.
Requires a
@wizardconnect/*version whoseWalletConnectionManagerfoldsgetExtensions()into the session (and whoseinputPathstype carries the optionalslot). Older0.1.xpackages ignoregetExtensions(), so the capability silently never negotiates — check your version.
2. Fill the slots when signing
For each inputPaths entry [inputIndex, pathName, addressIndex, slot?], derive the key and, for a
contract input, fill the slot-th sig placeholder (a 0x41 + 65-zero push — 64-byte Schnorr sig
plus its sighash-flag byte) and the slot-th pubkey placeholder (a 0x21 + 33-zero push). slot
defaults to 0. See protocol.md § Multiple placeholders per input
for the exact byte format and slot-counting rule, and packages/wallet/src/multislot-signing.test.ts
for a reference fill (fillContractInput).
Three rules are easy to get wrong:
- Keep every entry — do not build a
Map<inputIndex, key>; that collapses the per-slot entries (last-write-wins) and drops signatures. Group intoMap<inputIndex, entry[]>instead. - Compute the sighash once per input and reuse it for all slots — it covers
contract.redeemScript, not the unlocking bytecode you are mutating, so filling one slot does not change another slot's sighash. Only the signing key differs between slots. - Reject if you cannot fill a requested slot (unmappable path, or no placeholder at that slot) — return a sign error rather than a transaction with a leftover zero placeholder, which is silently unspendable.
If you want a post-fill safety check, validateSighashFlags from @wizardconnect/wallet verifies
that every filled signature uses SIGHASH_ALL (it correctly ignores filled pubkey placeholders).