Skip to content

Accept x402 USDC payments on Sui

Sui is a Move L1 with sub-second (~400ms) finality and native Circle USDC. Name it 'sui' on any gate or client and the driver auto-mounts on first use: a single dynamic import, so a pure-EVM install never downloads it. Coins on Sui are owned objects identified by a CoinType (a package::module::TYPE path), not a contract address.

Terminal window
npm install @mysten/sui

@mysten/sui is an optional peer, so install it only on the chains you actually touch. See the architecture for how lazy auto-mount works.

The named-chain shorthand works exactly as on every other family. Name the chain, the token, the amount, and where to be paid:

import { requirePayment, PipRailClient } from '@piprail/sdk'
requirePayment({ chain: 'sui', token: 'USDC', amount: '0.10', payTo: '0x…' })
const client = new PipRailClient({
chain: 'sui',
wallet: { key: process.env.AGENT_KEY }, // suiprivkey1…
})

payTo is a Sui 0x… address (32-byte). The merchant verifies locally against an RPC; PipRail never holds funds. See PipRailClient for the full client API.

A Sui wallet is { key }, either a suiprivkey1… bech32 secret or the raw 32-byte secret as a Uint8Array, or a ready { keypair } you built yourself (an Ed25519Keypair from @mysten/sui):

import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
new PipRailClient({ wallet: { key: process.env.AGENT_KEY }, chain: 'sui' }) // suiprivkey1…
new PipRailClient({ wallet: { keypair: Ed25519Keypair.generate() }, chain: 'sui' })

The field is named key (shared with EVM/Tron), but the chain: 'sui' selector routes here and the format is validated by Ed25519Keypair, so a hex 0x… EVM key won’t parse and surfaces a clear WrongFamilyError. See wallets by family for every family’s shape.

USDC is built in, with its coin type and 6 decimals verified live on mainnet (suix_getCoinMetadata) before shipping. There is no native USDT on Sui, only a Wormhole-bridged token that is not Circle/Tether-redeemable, so it’s intentionally absent. The native coin SUI (9 decimals, MIST) is always a valid payment asset.

tokenWhat it is
'USDC'Native Circle USDC (6 decimals).
'native'SUI, the gas coin (9 decimals).
{ coinType, decimals }Any other coin, by its fully-qualified coin type.

To pay in a coin PipRail doesn’t ship, the bridged USDT or anything else, pass it inline:

requirePayment({
chain: 'sui',
token: { coinType: '0x…::usdc::USDC', decimals: 6 },
amount: '0.10',
payTo: '0x…',
})

Sui has no receive prerequisite. Any valid 0x… (32-byte) Sui address receives immediately: no trustline, no storage deposit, no opt-in, no activation. planPayment() will never raise a RECIPIENT_NOT_READY blocker on Sui.

The payer, however, needs SUI for gas even when paying USDC, and must already hold a coin object of the asset being sent. The SDK ships the standard self-gas Coin<USDC> transfer.

Because SUI is the gas coin even when the payment is in USDC, a payer can hold plenty of USDC and still be unable to settle: the classic gas-token shortfall. planPayment() reads balances and gas on-chain and reports it as a blocker before any send (it never throws on fundability):

const plan = await client.planPayment('https://api.example.com/report')
if (plan && !plan.payable) {
console.log(plan.fundingHint) // e.g. "needs more SUI for gas", one human-readable line
}
// → PaymentPlan | null (null when the URL isn't payment-gated)

When the payment token is fine but gas isn’t, the blocker is INSUFFICIENT_GAS (vs INSUFFICIENT_TOKEN when you’re short the USDC itself). If you instead let fetch(url, { autoRoute: true }) settle and no rail is fundable, it throws a PaymentDeclinedError whose .message is the same funding hint, before any send:

import { PaymentDeclinedError } from '@piprail/sdk'
try {
await client.fetch('https://api.example.com/report', { autoRoute: true })
} catch (err) {
if (err instanceof PaymentDeclinedError) {
// unsettleable on Sui, likely short on SUI gas or USDC; .message carries the hint
console.error(`Couldn't pay on Sui: ${err.message}`)
} else {
throw err
}
}

Sui verification is digest-bound, like EVM and Solana: the proof ref is the tx digest. The merchant reads the transaction’s balance changes and looks for a positive change of the required coin type to payTo. The watched recipient and coin type always come from the trusted accept, never the client-supplied ref, so a forged echo can’t redirect verification.

Because no memo binds the tx to this specific challenge beyond amount and recipient, the single-use proof set and a tight recency window are load-bearing on Sui, the same as on every digest-bound chain. For multi-instance deployments use a persistent isUsed / markUsed store and keep maxTimeoutSeconds tight.

requirePayment({
chain: 'sui', token: 'USDC', amount: '0.10', payTo: '0x…',
maxTimeoutSeconds: 120,
isUsed: (ref) => redis.exists(ref), // persistent across instances
markUsed: (ref) => redis.set(ref, 1),
})

See proof binding for how Templates A and B differ, and replay protection for the proof store.

Sui runs client-side: like solana and near, @mysten/sui resolves from the CDN via an import map that pins each non-EVM peer’s browser-ESM URL (see the browser story in Installation): the same one line as on Node, Bun, Deno, or Workers. The lazy import means a page that doesn’t name 'sui' never downloads @mysten/sui.

Holding the wrong token? quoteSwap() prices a same-chain swap read-only and swap() is the only call that moves anything. It is opt-in and never automatic: paying never swaps and planning never swaps.

Sui has no protocol-level swap, so a named venue routes it: Aftermath. It is keyless, and PipRail sets no integrator or platform fee on it.

1 real mainnet swap backs this route, and the transaction hash is published so you can read it back off the chain yourself.

const quote = await client.quoteSwap({ from: 'native', to: 'USDC', wantAmount: '0.50' })
if (quote) await client.swap(quote) // null means no route, never "no funds"

Full guide: Swapping tokens. Every route, indexed by chain as well as by venue, is at piprail.com/swaps.