WizardConnect/docs/dapp.md
Dagur Valberg Johannsson c8c1000ae7 Add multislot extension: multiple sig/pubkey placeholders per input
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>
2026-06-22 09:21:24 +02:00

376 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
```typescript
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](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:
```typescript
// 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
```typescript
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
```typescript
dappMgr.on("walletready", (msg) => {
console.log("Wallet:", msg.wallet_name);
// pubkeyState is now populated with xpub nodes.
// You can start deriving addresses.
});
```
### Deriving addresses
```typescript
// Get the first receive address pubkey:
const RECEIVE = 0;
const pubkey = dappMgr.getPubkey(RECEIVE, 0n); // derives from xpub if needed
```
See [pubkey-derivation.md](pubkey-derivation.md) for full details.
### Sending a sign request
The `signTransaction` convenience method auto-fills `action`, `sequence`, and `time`:
```typescript
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?]
// A contract input with several sig/pubkey placeholders lists its index once
// per slot, e.g. [[3, "receive", 0, 0], [3, "defi", 7, 1]]. Only do this when
// the wallet advertised the `multislot` extension — see below.
});
console.log("Signed tx:", response.signedTransaction);
```
#### 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`:
```typescript
const controller = new AbortController();
cancelButton.onclick = () => controller.abort("User cancelled");
try {
const response = await dappMgr.signTransaction(
{ transaction: { ... }, inputPaths: [[0, "receive", 0]] },
{ 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:
```typescript
const seq = dappMgr.nextSequence();
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: seq,
time: Math.floor(Date.now() / 1000),
transaction: { ... },
inputPaths: [[0, "receive", 0]], // one entry per key; repeat the index for a multi-key contract input
};
const response = await dappMgr.sendSignRequest(request);
// Cancel with: await dappMgr.sendSignCancel(seq, "reason");
```
### Disconnecting
```typescript
// 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:
```typescript
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`):
```typescript
import { loadSession } from "@wizardconnect/dapp";
const session = loadSession(); // uses default key and localStorage
if (session?.walletPublicKey) {
const relay = initiateDappRelay(callback, { existingCredentials: session });
}
```
#### Configuration
```typescript
// 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:
```typescript
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:
```typescript
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:
```typescript
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](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:
```typescript
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.