StableOps
SDKs

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

Requires 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-provider
import { 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 require solana_signTransaction.
  • TRON: the wallet only does tron_signTransaction; the SDK builds and broadcasts the transaction with tronweb (default trongrid public node, overridable via tronRpcUrl).

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

ChainHow 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-nileCalls 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-devnetCalls 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:

codeMeaning
payment_instruction_not_foundThe order has no payable on-chain instruction
wallet_provider_not_foundNo wallet provider for any candidate chain
wallet_user_rejectedThe user rejected the request in their wallet
wallet_tx_revertedOn-chain revert (usually thrown via confirmation)
unsupported_chainChain not supported by the wallet helper
chain_config_not_foundMissing EVM chain config (supply chainConfigs)
invalid_amount / invalid_evm_address / invalid_tron_address / invalid_solana_addressInvalid input
tron_dependency_missing / solana_dependency_missingMissing 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

On this page