Skip to content

Guide — Wallet Development

Build a Wallet

The wallet-gateway is the single interface your wallet needs to interact with. You do not write to the blockchain directly. You do not manage gas. You do not implement cryptographic verification logic. The gateway abstracts all of that — your wallet's job is to hold the user's key, construct two signatures, and communicate with the gateway over a WebSocket.

The TypeScript SDK makes this straightforward. This guide walks through a complete implementation: connecting, authenticating, fetching balances and fees, submitting a payment, and receiving the final settlement confirmation.

Time to complete: 45–90 minutes for a working implementation.


What the wallet-gateway gives you

Before writing any code, it is worth understanding what you get for free by targeting the gateway instead of the blockchain directly:

What the gateway provides What you don't have to build
Current permit nonce for any wallet + token Direct on-chain RPC calls
Fee calculation (GET_FEES) Settlement contract ABI interaction
Payment submission and queuing Transaction construction and gas estimation
Real-time status updates over WebSocket Block polling and confirmation tracking
Balance and transfer history Blockchain indexing
Push notifications for incoming transfers Event subscription infrastructure

Your wallet signs two messages and sends one payload. Everything else is handled.


Prerequisites

  • Node.js 18+ or a modern browser environment
  • A local stack running (see Run Locally) or access to a processor's gateway endpoint
  • Basic familiarity with TypeScript and async/await

Step 1 — Install the SDK

npm install @stablecoin-stack/wallet-sdk ethers

The SDK wraps the wallet-gateway WebSocket protocol (SS-002) and re-exports the message types. ethers is used for EIP-712 signing.


Step 2 — Connect to the gateway

import { WalletGatewayClient } from '@stablecoin-stack/wallet-sdk';
import { ethers } from 'ethers';

// Your signing key — in production this comes from secure key storage,
// a hardware wallet, or the device's secure enclave.
const signer = new ethers.Wallet(PRIVATE_KEY);

const client = new WalletGatewayClient({
  url: 'wss://gateway.your-processor.example/ws',  // or ws://localhost:4000 locally
  signer,
});

await client.connect();
// The SDK sends your first authenticated message automatically on connect.
// The gateway verifies the signature and your connection is established.

Every message the SDK sends is signed with your key before transmission. The gateway verifies each one. There are no session tokens, no cookies, no API keys — the signature on each message is the credential.


Step 3 — Fetch the current balance

// domainSeparators identify tokens in the gateway interface.
// Fetch the list of supported tokens and their domain separators
// from the Basic Data Service.
const supportedTokens = await fetch('https://data.your-processor.example/tokens')
  .then(r => r.json());

const USDC_DOMAIN = supportedTokens.find(t => t.symbol === 'USDC').domainSeparator;

const balances = await client.getBalance([USDC_DOMAIN]);
// Returns: [{ domainSeparator: '0x...', balance: '10000000' }]
// Balance is in token units (USDC has 6 decimals, so 10000000 = $10.00)

console.log(`Balance: $${Number(balances[0].balance) / 1e6}`);

Step 4 — Subscribe to real-time updates

Subscribe before submitting any payments so you never miss a notification.

// Subscribe to balance changes
await client.subscribeBalance([USDC_DOMAIN]);

// Subscribe to transfer notifications (including incoming payments)
await client.subscribeTransfers([USDC_DOMAIN]);

// Handle incoming notifications
client.on('BALANCE_UPDATE', ({ domainSeparator, balance }) => {
  console.log(`Balance updated: $${Number(balance) / 1e6}`);
  updateUI(balance);
});

client.on('TRANSFER_NOTIFICATION', ({ transfer }) => {
  console.log(`${transfer.direction} transfer: ${transfer.value} at block ${transfer.blockNumber}`);
  if (transfer.direction === 'OUT') {
    markPaymentComplete(transfer);
  }
});

Subscriptions are connection-scoped — if the connection drops, re-subscribe after reconnecting.


Step 5 — Retrieve a payment session

When a user scans a QR code or follows a deep link from a merchant, your wallet redeems the ephemeral token to get the payment parameters:

const session = await fetch(`${widgetUrl}/api/session`, {
  headers: { Authorization: `Bearer ${ephemeralToken}` },
}).then(r => r.json());

// session contains:
// {
//   token: '0x...',            — ERC-2612 token contract address
//   tokenDomainSeparator: '0x...', — domain separator for gateway calls
//   beneficiary: '0x...',      — merchant receiving address
//   principal: '10000000',     — $10.00 in USDC units
//   acquirerId: '0x...',       — 16-byte acquirer ID (or Zero-UUID)
//   orderReference: '0x...',   — 16-byte order reference
//   deadline: 1780000000,      — Unix timestamp
// }

Step 6 — Fetch the nonce and fee

// Always fetch a fresh nonce — never cache it.
const { nonce } = await client.getNonce(USDC_DOMAIN);

// Get the exact fee for this payment
const { brokenDownAmount } = await client.getFees({
  domainSeparator: session.tokenDomainSeparator,
  amount: session.principal,
  acquirerId: session.acquirerId,
});

// The total the permit must be signed for:
// remaining = principal that reaches the merchant
// fees = processor + acquirer fees
const permitValue = BigInt(brokenDownAmount.remaining) + BigInt(brokenDownAmount.fees);

console.log(`Paying: $${Number(session.principal) / 1e6}`);
console.log(`Fee:    $${Number(brokenDownAmount.fees) / 1e6}`);
console.log(`Total:  $${Number(permitValue) / 1e6}`);

Step 7 — Build and sign the Permit (Signature 1)

The permit signature authorises the settlement contract to pull tokens from the user's wallet. It is verified by the token contract — not the gateway.

const permitParams = {
  owner:    await signer.getAddress(),
  spender:  SETTLEMENT_CONTRACT_ADDRESS,  // from Basic Data Service
  value:    permitValue.toString(),
  nonce:    nonce,
  deadline: session.deadline,
};

// Sign against the TOKEN CONTRACT's EIP-712 domain
const tokenDomain = {
  name:              'USD Coin',   // token contract name
  version:           '2',
  chainId:           YOUR_CHAIN_ID,
  verifyingContract: session.token,
};

const permitTypes = {
  Permit: [
    { name: 'owner',    type: 'address' },
    { name: 'spender',  type: 'address' },
    { name: 'value',    type: 'uint256' },
    { name: 'nonce',    type: 'uint256' },
    { name: 'deadline', type: 'uint256' },
  ],
};

const permitSigRaw = await signer.signTypedData(tokenDomain, permitTypes, permitParams);
const { v: v1, r: r1, s: s1 } = ethers.Signature.from(permitSigRaw);
const permitHash = ethers.TypedDataEncoder.hash(tokenDomain, permitTypes, permitParams);

const permitSig = {
  hash: permitHash,
  v:    v1 < 27 ? v1 + 27 : v1,   // normalise 0/1 → 27/28
  r:    r1,
  s:    s1,
};

Step 8 — Build the ref field and sign the operation (Signature 2)

The binding signature covers every parameter of the payment — token, merchant, amount, reference. Any modification invalidates it.

// ref = 16-byte orderReference || 16-byte acquirerId (both already hex, strip 0x)
const ref = '0x'
  + session.orderReference.replace('0x', '')   // 32 hex chars = 16 bytes
  + session.acquirerId.replace('0x', '');      // 32 hex chars = 16 bytes

const payWithPermitParams = {
  token:        session.token,
  beneficiary:  session.beneficiary,
  ref,
  permitParams,
};

// Sign against the SETTLEMENT CONTRACT's EIP-712 domain
const settlementDomain = {
  name:              'SettlementContract',
  version:           '1',
  chainId:           YOUR_CHAIN_ID,
  verifyingContract: SETTLEMENT_CONTRACT_ADDRESS,
};

const payWithPermitTypes = {
  PayWithPermitParams: [
    { name: 'token',        type: 'address' },
    { name: 'beneficiary',  type: 'address' },
    { name: 'ref',          type: 'bytes32' },
    { name: 'permitParams', type: 'PermitParams' },
  ],
  PermitParams: [
    { name: 'owner',    type: 'address' },
    { name: 'spender',  type: 'address' },
    { name: 'value',    type: 'uint256' },
    { name: 'nonce',    type: 'uint256' },
    { name: 'deadline', type: 'uint256' },
  ],
};

const bindingSigRaw = await signer.signTypedData(
  settlementDomain, payWithPermitTypes, payWithPermitParams
);
const { v: v2, r: r2, s: s2 } = ethers.Signature.from(bindingSigRaw);
const bindingHash = ethers.TypedDataEncoder.hash(
  settlementDomain, payWithPermitTypes, payWithPermitParams
);

const payWithPermitSig = {
  hash: bindingHash,
  v:    v2 < 27 ? v2 + 27 : v2,
  r:    r2,
  s:    s2,
};

Step 9 — Submit the payment

const payloadId = crypto.randomUUID();

const result = await client.submitPayment({
  payWithPermitParams,
  payWithPermitSig,
  permitSig,
  payloadId,
});

// result.status === 'ENQUEUING' — accepted, now wait for status updates
console.log(`Submitted. Tracking: ${result.payloadId}`);

Step 10 — Handle status updates

client.on('SUBMISSION_STATUS', ({ payloadId, status, txHash, failureReason, failureCategory }) => {
  switch (status) {
    case 'ENQUEUING':
      showUI('Preparing…');
      break;

    case 'PENDING':
      showUI('Validating…');
      break;

    case 'BROADCASTING':
      showUI(`Submitting… tx: ${txHash}`);
      break;

    case 'SUCCESS':
      // Transaction accepted by the network — NOT final settlement.
      // Wait for TRANSFER_NOTIFICATION for the confirmed on-chain event.
      showUI('Confirming on-chain…');
      break;

    case 'FAILURE':
      showUI(`Failed [${failureCategory}]: ${failureReason}`);
      break;
  }
});

// Final confirmation — arrives via the transfer subscription (Step 4)
// TRANSFER_NOTIFICATION with direction 'OUT' and matching value = payment complete

SUCCESS ≠ settled

SUBMISSION_STATUS: SUCCESS means the network accepted the transaction without reverting. Final settlement is confirmed by the TRANSFER_NOTIFICATION event after the required number of block confirmations. Always wait for the transfer notification before showing the user a final confirmation screen.


Step 11 — Handle reconnection

Connections close on idle timeout. Your wallet must reconnect gracefully:

client.on('disconnect', async (reason) => {
  console.warn('Gateway disconnected:', reason);

  await delay(2000);  // brief backoff — use exponential in production
  await client.connect();

  // Re-subscribe — subscriptions do not survive reconnection
  await client.subscribeBalance([USDC_DOMAIN]);
  await client.subscribeTransfers([USDC_DOMAIN]);

  // Re-sync balances — notifications during the gap were not delivered
  const balances = await client.getBalance([USDC_DOMAIN]);
  updateUI(balances);
});

Complete example

A minimal but complete wallet implementation — connect, fetch balance, pay, confirm:

import { WalletGatewayClient } from '@stablecoin-stack/wallet-sdk';
import { ethers } from 'ethers';

async function pay(ephemeralToken: string, widgetUrl: string) {
  const signer = new ethers.Wallet(PRIVATE_KEY);
  const client = new WalletGatewayClient({ url: GATEWAY_WS_URL, signer });
  await client.connect();

  // Subscribe before submitting
  await client.subscribeTransfers([USDC_DOMAIN]);

  // Get session parameters
  const session = await fetchSession(widgetUrl, ephemeralToken);

  // Fetch nonce + fee
  const { nonce } = await client.getNonce(USDC_DOMAIN);
  const { brokenDownAmount } = await client.getFees({
    domainSeparator: USDC_DOMAIN,
    amount: session.principal,
    acquirerId: session.acquirerId,
  });
  const permitValue = BigInt(brokenDownAmount.remaining) + BigInt(brokenDownAmount.fees);

  // Build + sign permit
  const permitSig = await signPermit(signer, session, permitValue, nonce);

  // Build + sign operation
  const { payWithPermitParams, payWithPermitSig } = await signOperation(
    signer, session, permitValue, nonce, permitSig
  );

  // Submit
  await client.submitPayment({ payWithPermitParams, payWithPermitSig, permitSig,
    payloadId: crypto.randomUUID() });

  // Await final confirmation
  return new Promise((resolve) => {
    client.on('TRANSFER_NOTIFICATION', ({ transfer }) => {
      if (transfer.direction === 'OUT') resolve(transfer);
    });
  });
}

Next steps