Wallet integration¶
Wallet integration uses @wizardconnect/wallet. The wallet implements the WalletAdapter
interface and hands it to WalletConnectionManager, which handles everything else.
WalletAdapter¶
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 stable relay identity private key for this connection URI.
* MUST derive it from the wallet seed and URI, or persist it with the URI.
* Reconnecting to the same URI MUST return the same key.
*/
getRelayPrivateKey(uri: string): 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>;
}
A wallet MUST use one stable relay key per connection URI. Derive it from the wallet seed
and URI, or persist a randomly generated key with that URI and reuse it across restarts.
Once paired, the dapp ignores messages from any other key, including wallet_ready. A wallet
that changes its relay key must obtain a fresh connection URI by starting a new dapp pairing
(reload the dapp rather than resuming the old pairing).
For wiz-test wallet, reuse the same --private-key <64-hex-chars> every time you connect to
the same URI. If omitted, the CLI generates and prints a random relay key; save that key and
pass it with --private-key on subsequent runs. The CLI does not persist it automatically.
DerivationPath¶
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¶
WalletConnectionManager¶
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¶
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¶
Connection lifecycle¶
connect()¶
- A unique
connectionIdis generated. initiateWalletRelay(statusCallback, { uri, walletPrivateKey })is called.- The relay decodes the URI, extracts the dapp's public key and secret, and connects.
- On the first
"connected"status,onConnected()is called.
onConnected()¶
walletReadySentThisCycleis reset tofalse.- A notification processor interval is started (1 second, for retry on send errors).
- The wallet polls until
client.isKeyExchangeComplete()(key exchange with dapp done). pushWalletReady()is called.
pushWalletReady()¶
Sends wallet_ready with:
supported_protocols: ["hdwalletv1"]wallet_name,wallet_iconfrom the adapter.session["hdwalletv1"]: one{ name, xpub }perDerivationPath(receive/change/defi), plus any additional paths fromadapter.getAdditionalPaths()and extension data fromadapter.getExtensions(). See 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:
- The
remoteDisconnectevent is emitted with(connectionId, reason, message). - 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:
- Queueing or displaying the request.
- Getting user approval.
- Calling
sendSignResponse(connectionId, sequence, signedTxHex)orsendSignError(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). 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 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¶
The example takes walletRelayPrivateKey from the wallet's seed-and-URI derivation or saved
record for this URI. Keep that key when reconnecting or recreating the adapter.
import { WalletConnectionManager } from "@wizardconnect/wallet";
import type { WalletAdapter, DerivationPath } from "@wizardconnect/wallet";
class MyAdapter implements WalletAdapter {
walletName = "My Wallet";
walletIcon = "";
constructor(private readonly relayPrivateKey: Uint8Array) {}
getRelayPrivateKey(_uri: string) {
return this.relayPrivateKey;
}
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(walletRelayPrivateKey),
);
// 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()));
});