Skip to main content

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 transferPayment Kit
ComplexityLowMedium
Best forWallets, peer-to-peer sends, treasury movesMerchant checkout, invoicing, subscriptions
ReceiptsNone, so read from events or effectsBuilt-in PaymentReceipt objects
Duplicate preventionNoneBuilt-in through PaymentRegistry
Gas sponsorshipYesYes
Onchain audit trailTransfer events onlyReceipt objects and events
Move code to writeNoneNone

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 a PaymentRecord internally, 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
&registry=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.

tip

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.decimals for the specific coin type before you convert between human-readable and onchain amounts.
  • Coin selection and merging: A user might hold several Coin objects of the same type. If the payment requires more than any single coin holds, merge them first with tx.mergeCoins() in your transaction, or use tx.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 gas errors.

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 balance or coin operations, primarily 0x2::balance::send_funds<T>, on an allowlisted stablecoin type.
  • gasPayment is empty and gasPrice is 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,
});
caution

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