Choose a Payment Model
Sui supports a range of payment models, from direct transfers to structured checkout flows built on Payment Kit. The right choice depends on whether you need receipts, duplicate prevention, or custom spending logic. Most integrations start from one of the common models below, and many combine several in a single transaction.
| Basic transfer | Payment Kit | |
|---|---|---|
| Complexity | Low | Medium |
| Best for | Wallets, peer-to-peer sends, treasury moves | Merchant checkout, invoicing, subscriptions |
| Receipts | None, so read from events or effects | Built-in PaymentReceipt objects |
| Duplicate prevention | None | Built-in through PaymentRegistry |
| Gas sponsorship | Yes | Yes |
| Onchain audit trail | Transfer events only | Receipt objects and events |
| Move code to write | None | None |
Basic transfer
A basic transfer moves value directly, with no receipt object and no onchain payment record. Read the outcome from transaction effects and events. There are two ways to deliver funds.
Send to an address balance
The address balance functions balance::send_funds and coin::send_funds deposit value into a canonical per-address balance. Deposits from different senders merge automatically, so the recipient manages no objects. This path also enables gasless stablecoin transfers.
const tx = new Transaction();
// Send 1 SUI to the recipient's address balance. The SDK selects the funding source.
tx.moveCall({
target: '0x2::balance::send_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [tx.balance({ balance: 1_000_000_000n }), tx.pure.address(recipientAddress)],
});
Transfer a coin object
Transfer a Coin<T> object when the recipient or an integrating contract expects a distinct object, such as a payment that a downstream Move call consumes:
const tx2 = new Transaction();
// Split from a specific coin object (not the gas coin).
const [coin] = tx2.splitCoins(tx2.object('0xSpecificCoinId'), [tx2.pure('u64', 500_000_000n)]);
tx2.transferObjects([coin], tx2.pure.address(recipientAddress));
Learn more in Using Address Balances and Building Programmable Transaction Blocks.
Payment Kit
Use Payment Kit when you need structured receipts and duplicate prevention without writing custom Move. Payment Kit offers 2 processing modes:
-
Registry payments: Process the payment through a
PaymentRegistry. The registry stores aPaymentRecordinternally, rejects duplicates, and optionally accumulates funds for later withdrawal. Use this mode when compliance, accounting, or retry safety requires payment history. -
Ephemeral payments: Process a one-time payment with no persistent record. The function transfers funds immediately, returns a receipt, and emits an event, at lower gas cost. Use this mode when an external system already tracks payments and duplicates are not a concern.
Process a registry payment
The registry entry point takes a nonce, the expected amount, and the payment coin:
module sui::payment_kit;
public fun process_registry_payment<T>(
registry: &mut PaymentRegistry,
nonce: String,
payment_amount: u64,
coin: Coin<T>,
receiver: Option<address>,
clock: &Clock,
ctx: &mut TxContext
)
The function verifies that the coin value matches payment_amount, checks the composite key for a duplicate, records the payment, transfers funds to receiver or the registry, returns a PaymentReceipt, and emits an event. It aborts with EDuplicatePayment when the same key was already processed, and with EPaymentAmountMismatch when the coin value differs from the expected amount.
Duplicate prevention relies on a PaymentKey derived from the nonce, amount, coin type, and receiver address. Generate the nonce as a UUIDv4 of at most 36 characters, and reuse the same nonce when you retry a payment so the retry cannot double-charge.
Encode a payment request as a URI
Payment Kit defines a transaction URI format that wallets parse into a ready-to-sign payment. Include the registry parameter to route through a registry, or omit it to process an ephemeral payment:
sui:pay?receiver=0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
&amount=1000000000
&coinType=0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI
&nonce=550e8400-e29b-41d4-a716-446655440000
&label=Coffee%20Shop
®istry=default-payment-registry
Percent-encode every parameter value according to RFC 3986. The amount value is the native amount of the coin type, so 1000000000 MIST represents 1 SUI.
Verify the receiver, amount, coin type, and package ID against your own expected values rather than trusting client-supplied or URI-supplied data, and confirm payment against onchain receipts and events rather than client state. For broader guidance, see Security Best Practices.
Common payment pitfalls
These 4 mistakes account for most payment integration bugs:
- Integer math only: SUI has 9 decimals (1 SUI = 1,000,000,000 MIST). Never use floating-point arithmetic on token amounts. Always work in the smallest unit, MIST for SUI, and convert for display only.
- Per-coin-type decimals: Not all tokens use 9 decimals. USDC on Sui has 6 decimals. Always check
CoinMetadata.decimalsfor the specific coin type before you convert between human-readable and onchain amounts. - Coin selection and merging: A user might hold several
Coinobjects of the same type. If the payment requires more than any single coin holds, merge them first withtx.mergeCoins()in your transaction, or usetx.splitCoins()to extract the exact amount you need. - Gas coin reservation: If you pay gas in SUI and also transfer SUI in the same transaction, the transaction implicitly uses the gas coin for gas. Split the transfer amount from the gas coin rather than selecting a separate SUI coin object, which avoids
insufficient gaserrors.
Gas strategies
Every payment transaction needs gas, except for qualifying stablecoin transfers. Choose a strategy based on your user experience goals.
User pays gas
The sender pays in SUI. This option takes the least work to implement, but your users must hold SUI. See Gas Fees.
Gasless stablecoin transfer
For allowlisted stablecoins sent through balance::send_funds, the network waives gas fees entirely, so the sender needs no SUI. Protocol configuration governs the allowlist, which currently includes USDC, USDsui, USDY, FDUSD, AUSD, USDB, and Ethena USDe. A transaction qualifies only when all of the following hold:
- The transaction consists of allowlisted
balanceorcoinoperations, primarily0x2::balance::send_funds<T>, on an allowlisted stablecoin type. gasPaymentis empty andgasPriceis 0.- The transaction writes no objects, and it consumes or converts all input coins to address balances.
Build the transfer with SuiGrpcClient, which detects eligibility during simulation and sets the gas price and budget to 0 for you:
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { Transaction } from '@mysten/sui/transactions';
const USDC = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC';
const client = new SuiGrpcClient({
network: 'mainnet',
baseUrl: 'https://fullnode.mainnet.sui.io:443',
});
const tx = new Transaction();
tx.setSender(senderAddress);
tx.moveCall({
target: '0x2::balance::send_funds',
typeArguments: [USDC],
arguments: [
tx.balance({ type: USDC, balance: 1_000_000n }), // 1 USDC
tx.pure.address(recipient),
],
});
const result = await client.signAndExecuteTransaction({
transaction: tx,
signer: keypair,
});
Gasless stablecoin transfers require a minimum transfer of 0.01, and the network deprioritizes them against fee-paying transactions when it is congested. Any other Move call in the same transaction, including a swap, a mint, or a Payment Kit call, disqualifies the transfer and the transaction then requires a normal gas payment.
See Gasless Stablecoin Transfers for the full allowlist and the JSON-RPC fallback.
Sponsored transaction
Sponsored transactions pay gas on behalf of the sender, which works with any transaction type:
// === User side ===
// Build the transaction kind (no gas info yet).
const tx = new Transaction();
tx.moveCall({
target: '0xPACKAGE::module::do_something',
arguments: [tx.object('0xSomeObject')],
});
// Serialize only the transaction kind bytes for the sponsor.
const kindBytes = await tx.build({ client, onlyTransactionKind: true });
// === Sponsor side ===
// Reconstruct the transaction from the kind bytes.
const sponsoredTx = Transaction.fromKind(kindBytes);
// Set the user as the sender, sponsor as the gas owner.
sponsoredTx.setSender(userAddress);
sponsoredTx.setGasOwner(sponsorAddress);
sponsoredTx.setGasPayment(sponsorGasCoins); // Sponsor's coin objects
// Build the full transaction bytes.
const txBytes = await sponsoredTx.build({ client });
// === Both sign ===
// The sponsor signs first.
const sponsorSig = await sponsorKeypair.signTransaction(txBytes);
// The user signs second.
const userSig = await userKeypair.signTransaction(txBytes);
// === Submit ===
const result = await client.executeTransaction({
transaction: txBytes,
signatures: [userSig.signature, sponsorSig.signature],
});
const sponsoredTx2 = Transaction.fromKind(kindBytes);
sponsoredTx2.setSender(userAddress);
sponsoredTx2.setGasOwner(sponsorAddress);
// Empty gas payment array tells the protocol to deduct gas from the
// sponsor's SUI address balance. The SDK sets the ValidDuring expiration
// and nonce automatically when you build with a connected client.
sponsoredTx2.setGasPayment([]);
// Building with a connected client resolves the expiration and nonce.
const txBytes2 = await sponsoredTx2.build({ client });
Sponsoring from an address balance removes the gas coin entirely. Pass an empty array to setGasPayment, which lets the user sign first and the sponsor sign afterward, and removes the risk that a competing transaction locks the gas coin:
const sponsoredTx2 = Transaction.fromKind(kindBytes);
sponsoredTx2.setSender(userAddress);
sponsoredTx2.setGasOwner(sponsorAddress);
// Empty gas payment array tells the protocol to deduct gas from the
// sponsor's SUI address balance. The SDK sets the ValidDuring expiration
// and nonce automatically when you build with a connected client.
sponsoredTx2.setGasPayment([]);
// Building with a connected client resolves the expiration and nonce.
const txBytes2 = await sponsoredTx2.build({ client });
Submit dual-signed transactions directly to a full node rather than through the sponsor, so the sponsor cannot delay or withhold them. See Sponsored Transactions for the full flow and risk considerations.