Wallet SDK
Let users pay on-chain from self-custody wallets (EVM / TRON / Solana).
@stableops/wallet-sdk is a browser-only front-end library: it hands the
paymentInstructions returned when your backend creates an order to the user's
wallet and sends an on-chain transfer to that order's deposit address.
Idempotency, address allocation, chain scanning, confirmations, and webhooks all stay on the StableOps backend. The wallet SDK does exactly one thing: get the user's funds to the right address.
Never expose STABLEOPS_API_KEY in the browser. Orders must be created on your
backend; the frontend only receives the order id, amount, and
paymentInstructions.
Install
pnpm add @stableops/wallet-sdkRequires a browser environment with fetch and EIP-1193 / TronLink / Solana
wallet adapters. WalletConnect is optional; install its dependency only when you
need it (see below).
Want a runnable end-to-end example?
The Playground walks through create → wallet payment → confirm → finalized in the browser, with source you can read.
Quick start (injected wallets)
getInjectedWalletProviders() collects the wallets injected into the page
(MetaMask, TronLink, Phantom, etc). sendOrderWalletPayment() picks a candidate
chain from the order that has an available wallet and sends the transfer.
import { getInjectedWalletProviders, sendOrderWalletPayment } from '@stableops/wallet-sdk'
const sent = await sendOrderWalletPayment({
order, // { amount, paymentInstructions }, from your backend
providers: getInjectedWalletProviders(),
})
console.log(sent.txHash)Use preferredChains to control which chains are tried first:
await sendOrderWalletPayment({
order,
providers: getInjectedWalletProviders(),
preferredChains: ['base', 'arbitrum'],
})Selecting an instruction yourself
When you want to control which chain is used, select the instruction with
selectWalletPaymentInstruction(), then call the lower-level
sendWalletPayment():
import {
getInjectedWalletProviders,
selectWalletPaymentInstruction,
sendWalletPayment,
} from '@stableops/wallet-sdk'
const { instruction, provider } = selectWalletPaymentInstruction(
order.paymentInstructions,
getInjectedWalletProviders(),
['base'], // optional: preferred chains
)
const sent = await sendWalletPayment({
provider,
instruction,
amount: order.amount,
})
console.log(sent.txHash)WalletConnect (mobile / custom UI)
For mobile browsers or pages without an injected EVM provider, wire up the optional WalletConnect runtime. The SDK ships no UI and maintains no wallet list: you pass the wallet options in, and render the picker, QR code, and loading / error states yourself by subscribing to the controller state.
pnpm add @walletconnect/universal-providerimport { createWalletConnectController, sendOrderWalletPayment } from '@stableops/wallet-sdk'
const wc = await createWalletConnectController({
projectId: 'YOUR_REOWN_PROJECT_ID',
metadata: {
name: 'Your App',
description: 'StableOps checkout',
url: window.location.origin,
icons: [`${window.location.origin}/icon.png`],
},
// Enabled namespaces (pick what you need)
chains: ['base', 'arbitrum'], // EVM
solanaChains: ['solana'], // Solana
tronChains: ['tron'], // TRON
wallets: [
{
id: 'metamask',
name: 'MetaMask',
links: { native: 'metamask://', universal: 'https://metamask.app.link' },
},
],
})
const unsubscribe = wc.subscribe((state) => {
// Render based on state.status:
// 'idle' | 'connecting' | 'uri_ready' | 'connected' | 'failed' | 'disconnected'
// When state.status === 'uri_ready', render a QR code from state.uri.
})
await wc.connect({ walletId: 'metamask' })
const sent = await sendOrderWalletPayment({
order,
providers: wc.providers, // pass the controller's providers straight in
})
unsubscribe()
console.log(sent.txHash)All three namespaces work over WalletConnect:
- EVM: behaves the same as injected wallets.
- Solana: depends on wallet support for
solana_signTransaction/solana_signAndSendTransaction; custom RPC / devnet flows requiresolana_signTransaction. - TRON: the wallet only does
tron_signTransaction; the SDK builds and broadcasts the transaction with tronweb (default trongrid public node, overridable viatronRpcUrl).
Return value: SentWalletPayment
Both sendWalletPayment and sendOrderWalletPayment return:
{
txHash: string
chain: ChainId
asset: 'USDC' | 'USDT'
fromAddress: string
toAddress: string
tokenContract: string
amount: string // human-readable amount, e.g. '49.00'
amountUnits: string // smallest-unit integer string
confirmation: Promise<void>
}confirmation is a best-effort hint and does not block the function from
returning (it runs in the background after broadcast):
- resolves when the transaction is mined and the contract call succeeded (or after a best-effort timeout of ~90s).
- rejects (
code: 'wallet_tx_reverted') when the call reverted on-chain and no tokens moved (e.g. insufficient balance). The goal is to surface an immediate revert early instead of leaving the user waiting.
sent.confirmation.catch((err) => {
// err.code === 'wallet_tx_reverted'
})Server-side chain scanning is the source of truth. If the scanner has already
advanced the order to detected / confirmed, defer to the server and ignore a
confirmation rejection.
Supported chains
| Chain | How it works |
|---|---|
ethereum base arbitrum polygon optimism bsc (plus testnets *-sepolia / polygon-amoy / bsc-testnet) | Calls EIP-1193 wallets, switches / adds networks when needed, sends an ERC-20 transfer. Override RPC / explorer via chainConfigs. |
tron tron-nile | Calls TronLink / TronWeb (or WalletConnect TRON) to build, sign, and broadcast a TRC-20 transfer. Pass tronRpcUrl for testnet or a self-hosted node. |
solana solana-devnet | Calls Solana wallet adapters, idempotently creates the recipient associated token account, sends an SPL Token TransferChecked. For devnet pass solanaRpcUrl: 'https://api.devnet.solana.com' (or an equivalent node). |
Error handling
Every failure throws StableOpsWalletError with .code, .message, and an
optional .details.
import { StableOpsWalletError } from '@stableops/wallet-sdk'
try {
await sendOrderWalletPayment({ order, providers: getInjectedWalletProviders() })
} catch (err) {
if (err instanceof StableOpsWalletError && err.code === 'wallet_user_rejected') {
// User cancelled in the wallet
}
throw err
}Common code values:
| code | Meaning |
|---|---|
payment_instruction_not_found | The order has no payable on-chain instruction |
wallet_provider_not_found | No wallet provider for any candidate chain |
wallet_user_rejected | The user rejected the request in their wallet |
wallet_tx_reverted | On-chain revert (usually thrown via confirmation) |
unsupported_chain | Chain not supported by the wallet helper |
chain_config_not_found | Missing EVM chain config (supply chainConfigs) |
invalid_amount / invalid_evm_address / invalid_tron_address / invalid_solana_address | Invalid input |
tron_dependency_missing / solana_dependency_missing | Missing TRON / Solana runtime dependency |
Debugging
Turn on module-level debug logging (prefixed with [wallet-sdk]):
import { setWalletSdkDebug } from '@stableops/wallet-sdk'
setWalletSdkDebug(true)You can also enable it without code changes: set
globalThis.STABLEOPS_WALLET_DEBUG = true in the browser console, or inject the
WALLET_SDK_DEBUG=1 environment variable at build time.
How is this guide?
Last updated