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>
295 lines
12 KiB
Markdown
295 lines
12 KiB
Markdown
# 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>;
|
|
|
|
/** 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
|
|
|
|
```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)
|
|
on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages
|
|
}
|
|
```
|
|
|
|
### 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.
|
|
- `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).
|
|
- `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.
|
|
|
|
**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.
|
|
|
|
**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` (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
|
|
|
|
```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) {
|
|
// 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:
|
|
|
|
```typescript
|
|
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 whose `WalletConnectionManager` folds `getExtensions()` into
|
|
> the session (and whose `inputPaths` type carries the optional `slot`). Older `0.1.x` packages
|
|
> ignore `getExtensions()`, 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](protocol.md#multiple-placeholders-per-input-the-slot-element)
|
|
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 into `Map<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).
|
|
|