2026-02-26 11:19:47 +01:00
|
|
|
# Wallet integration
|
|
|
|
|
|
|
|
|
|
Wallet integration uses `@wizardconnect/wallet`. The wallet implements the `WalletAdapter`
|
|
|
|
|
interface and hands it to `WalletConnectionManager`, which handles everything else.
|
|
|
|
|
|
|
|
|
|
## WalletAdapter
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
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>;
|
2026-03-26 10:50:40 +01:00
|
|
|
|
|
|
|
|
/** 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>;
|
2026-02-26 11:19:47 +01:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### DerivationPath
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface SignTransactionResult {
|
|
|
|
|
signedTransactionHex: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## WalletConnectionManager
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
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)
|
2026-03-26 10:50:40 +01:00
|
|
|
on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages
|
2026-02-26 11:19:47 +01:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### RelayConnectionState
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface PendingSignRequest {
|
|
|
|
|
connectionId: string;
|
|
|
|
|
request: SignTransactionRequest;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Connection lifecycle
|
|
|
|
|
|
|
|
|
|
### connect()
|
|
|
|
|
|
|
|
|
|
1. A unique `connectionId` is generated.
|
|
|
|
|
2. `initiateWalletRelay(statusCallback, { uri, walletPrivateKey })` is called.
|
|
|
|
|
3. The relay decodes the URI, extracts the dapp's public key and secret, and connects.
|
|
|
|
|
4. On the first `"connected"` status, `onConnected()` is called.
|
|
|
|
|
|
|
|
|
|
### onConnected()
|
|
|
|
|
|
|
|
|
|
1. `walletReadySentThisCycle` is reset to `false`.
|
|
|
|
|
2. A notification processor interval is started (1 second, for retry on send errors).
|
|
|
|
|
3. The wallet polls until `client.isKeyExchangeComplete()` (key exchange with dapp done).
|
|
|
|
|
4. `pushWalletReady()` is called.
|
|
|
|
|
|
|
|
|
|
### pushWalletReady()
|
|
|
|
|
|
|
|
|
|
Sends `wallet_ready` with:
|
|
|
|
|
- `supported_protocols: ["hdwalletv1"]`
|
|
|
|
|
- `wallet_name`, `wallet_icon` from the adapter.
|
2026-03-26 10:50:40 +01:00
|
|
|
- `session["hdwalletv1"]`: one `{ name, xpub }` per `DerivationPath` (receive/change/defi),
|
|
|
|
|
plus any additional paths from `adapter.getAdditionalPaths()` and extension data from
|
|
|
|
|
`adapter.getExtensions()`. See [extensions.md](extensions.md).
|
2026-02-26 11:19:47 +01:00
|
|
|
- `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:
|
|
|
|
|
1. The `remoteDisconnect` event is emitted with `(connectionId, reason, message)`.
|
|
|
|
|
2. 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:
|
|
|
|
|
|
|
|
|
|
1. Queueing or displaying the request.
|
|
|
|
|
2. Getting user approval.
|
|
|
|
|
3. Calling `sendSignResponse(connectionId, sequence, signedTxHex)` or
|
|
|
|
|
`sendSignError(connectionId, sequence, errorMessage)`.
|
|
|
|
|
|
|
|
|
|
The wallet library does not auto-sign or auto-reject anything.
|
|
|
|
|
|
2026-04-03 13:03:03 +02:00
|
|
|
**Deduplication:** The dapp re-sends pending sign requests when the wallet reconnects (see
|
|
|
|
|
[protocol docs](protocol.md#re-delivery-on-reconnect)). 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.
|
|
|
|
|
|
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
|
|
|
**Multiple keys per input:** an `inputPaths` entry may carry a fourth element, `slot`, and several
|
|
|
|
|
entries may share one `inputIndex` — one per sig/pubkey placeholder in a contract input. Do **not**
|
|
|
|
|
deduplicate by `inputIndex`: collapsing them drops signatures and yields an unspendable transaction.
|
|
|
|
|
Compute each input's sighash once and reuse it for every slot, varying only the key. Use
|
|
|
|
|
`fillPlaceholder()` / `unfilledPlaceholders()` from `@wizardconnect/core` rather than scanning for
|
|
|
|
|
placeholders yourself, and advertise `multislot` via `getExtensions()` if you support it. See
|
|
|
|
|
[extensions.md § multislot](extensions.md#multislot).
|
|
|
|
|
|
2026-03-18 10:30:11 +01:00
|
|
|
**SIGHASH enforcement:** The wallet **MUST** sign every input with
|
|
|
|
|
`SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the
|
|
|
|
|
[SIGHASH requirement](protocol.md#sighash-requirement-security-critical) section in the protocol
|
|
|
|
|
docs for the security rationale. Because the dapp specifies `inputPaths`, 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).
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
## Minimal example
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
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) {
|
2026-03-18 10:30:11 +01:00
|
|
|
// Show approval UI, sign with SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS, return hex.
|
|
|
|
|
// See protocol.md "SIGHASH requirement" — other sighash flags MUST be rejected.
|
2026-02-26 11:19:47 +01:00
|
|
|
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()));
|
|
|
|
|
});
|
|
|
|
|
```
|
|
|
|
|
|