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¶
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¶
- Integrate Merchant Checkout → — the other side of the payment
- Use the Event Explorer → — query and stream settlement data
- SS-002 Formal Specification — normative gateway interface reference