Skip to content

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 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

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

interface SignTransactionResult {
  signedTransactionHex: string;
}

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.
  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

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

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. - 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.

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

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()));
});