Keep session persistance by default, such that when reloading a dapp, the wizardconnect session is kept alive. Improve signature request interface. Simplify the react API.
373 lines
12 KiB
Markdown
373 lines
12 KiB
Markdown
# 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]
|
||
});
|
||
|
||
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: [...] },
|
||
{ 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]],
|
||
};
|
||
|
||
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.
|