WizardConnect/docs/wallet.md
Håvard Kittelsen dcda4fce6a fix(wallet): deliver the courtesy disconnect before tearing the relay down
doDisconnect fired the courtesy `disconnect` message without awaiting it, then
tore the relay connection down on the next line:

    conn.client.relay(disconnectMsg).catch(() => {});   // fire and forget
    ...
    conn.cleanup();                                      // closes the pool underneath it

relay() resolves only after `Promise.allSettled(pool.publish(...))` — a real
round trip to every configured relay. cleanup() closed the pool while that
publish was still in flight, so the message usually never left and the dapp went
on believing the wallet was connected until its own liveness timeout fired.
Downstream wallets were patching this out of the published package.

Teardown now splits into two halves with opposite timing requirements.

Registry removal stays synchronous. getConnections() is what a UI renders, and
connect() returns an existing connection for a URI, so leaving this one in the
map while its teardown is pending would hand a caller a dying connection. This
is the one place this differs from !30 and from the downstream patches, which
defer the registry removal along with the teardown.

Relay teardown is deferred until the publish settles, bounded by
DISCONNECT_PUBLISH_TIMEOUT_MS (5s). The bound matters: "the publish never
settles" is exactly the case where a relay is unreachable, and a socket that is
never closed is worse than a courtesy message that is never delivered.

disconnect() keeps its synchronous void signature — not a breaking change.

Why it shipped broken: disconnect.test.ts only covered dapp → wallet. Nothing
exercised wallet → dapp, and the failure is invisible from the wallet's side —
its own state is correct either way, and only the peer notices.
disconnect-delivery.test.ts covers that direction over a live relay, including
the two invariants the deferral must not break (registry cleared immediately,
URI reusable afterwards).

Also fixes a latent hang in the integration harness that the new file exposed.
setupConnection gated both the dapp_ready send and the message handler inside
the keyexchangecomplete callback, so the handshake hung on receiving the wallet's
single wallet_ready for the cycle. Miss it — the dapp's subscription can come up
after the wallet has already published — and key exchange never resolves, the
handler never registers, no dapp_ready is ever sent, and the wallet, guarded by
walletReadySentThisCycle, has nothing prompting it to retry. It now re-announces
dapp_ready(wallet_discovered=false) every 2s until key exchange completes, which
resets that guard and earns another wallet_ready: the recovery path mutual
discovery already specifies, which the harness was not using. Plus retry: 2 on
the integration config, since these tests talk to live relays and a dropped
connection is an environment failure rather than a regression.

docs/wallet.md gains a "Sending disconnect" section for the synchronous/deferred
split and what a caller may rely on. docs/protocol.md gains the sender-side half
of the courtesy-disconnect semantics, which previously read as though "no
acknowledgement" licensed fire-and-forget. That reading is what produced the bug.

The race was diagnosed and first fixed by hantyrram (Ronaldo Ramano) in !30,
which this supersedes — the deferral is their fix; this changes only how it is
scoped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:29:34 +02:00

259 lines
9.8 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.
// Returns as soon as the connection has left the registry; the relay socket
// closes once the courtesy message is published. See § Sending disconnect.
disconnect(connectionId: string): void
// Tear down all connections. Each is disconnected independently.
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.
### Sending disconnect
`disconnect(id)` and `disconnectAll()` split teardown into two phases, because the two halves
have opposite timing requirements.
**Synchronous** — the connection is removed from the registry, its pending sign sequences are
released, and `connectionsChanged` is emitted. This cannot wait: `getConnections()` is what the
UI renders, and `connect()` returns the existing connection for a URI that already has one, so a
connection left in the map during teardown would be handed back to a caller as if it were live.
**Deferred** — the relay socket closes only after the courtesy `disconnect` message has been
published. `RelayClient.relay()` resolves after the publish settles against every configured
relay, which is a real round trip; closing the socket before that kills the publish in flight and
the dapp keeps believing the wallet is connected until its own liveness timeout fires.
The deferral is bounded by `DISCONNECT_PUBLISH_TIMEOUT_MS` (5 s). A publish that never settles is
precisely the unreachable-relay case, and a socket that is never closed is a worse failure than a
courtesy message that is never delivered.
Callers do not need to await anything. The observable contract is that state is correct
immediately and delivery is best-effort within the timeout.
### 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`, 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.
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()));
});
```