llms.txt
@mysten/sui v2.0 and a new dApp Kit are here! Check out the migration guide
Mysten Labs SDKs

Best Practices

Operate a Sui sponsor with clear policy, gas limits, and outcome handling

Gas sponsorship is a backend operating boundary. The user supplies transaction bytes; the sponsor decides whether to pay for them. Treat every sponsored transaction as untrusted input until your policy validates the exact bytes that get signed.

Prefer user-signed transaction bytes

For public sponsor services, prefer accepting already-built bytes plus userSignature. The client builds the transaction with the sponsor as gas owner, the user signs those final bytes, and the sponsor validates, co-signs, and executes.

This keeps the safer trust boundary: the sponsor only signs after the user has signed the exact transaction bytes. See Basic Usage for the recommended flow.

Understand the Sui gas model

The Sponsor SDK uses address-balance gas:

transaction.setGasOwner(sponsorAddress);
transaction.setGasPayment([]);

An empty gas payment means the sponsor pays from its SUI address balance. This avoids locking specific gas coin objects and allows parallel sponsor execution, but it also means the sponsor's address balance is the operating balance at risk.

Fund the address balance intentionally. Faucet coins are coin objects; move SUI into the address balance with a transaction such as 0x2::coin::send_funds.

Constrain sponsorship to product flows

Never expose an endpoint that blindly co-signs any user transaction. At minimum, constrain:

  • Allowed targets: Use allowedFunctions() for fully qualified package::module::function targets, not broad package allowlists, when the product flow only needs one function.
  • PTB templates: Validate the full Programmable Transaction Block shape your product expects: command count, command kinds, Move targets, type arguments, argument sources, object ownership, and value movement. A function allowlist is a starting point, not a complete policy.
  • Gas budget: Add gasBudget({ max }) so a valid but expensive transaction cannot use the protocol maximum.
  • Sender signature: Add userSignatureMatchesSender() so the supplied signature matches the transaction sender before the sponsor co-signs.
  • Value movement: Use analyzers such as balanceFlows when sponsorship depends on payment, refunds, or tenant-specific balances.

The sponsor server example uses a basic PTB template: exactly one command, and that command must be a call to one configured package::module::function. Real products should make the template as specific as their flow requires.

Keep defaults()

defaults() protects against common sponsorship mistakes:

ValidatorWhy it matters
validSender()Rejects unset senders and transactions where the sender is the sponsor
onlyAddressBalanceGas()Requires empty gas payment so users cannot nominate sponsor gas coin objects
gasCoinNotUsed()Rejects commands that use tx.gas, which belongs to the sponsor
onlySenderWithdrawals()Rejects address-balance withdrawals from the sponsor or any non-sender source
simulationSucceeds()Rejects transactions that dry-run as aborting
boundedExpiration()Rejects missing or too-far-ahead transaction expirations

Once you pass validate, defaults no longer run automatically. Include defaults() explicitly:

const sponsor = createSponsor({
	signer,
	client,
	validate: [defaults(), gasBudget({ max: 50_000_000n }), allowedFunctions([target])],
});

Gas budget is not the whole economic risk

A gas cap limits how much gas the sponsor is willing to cover, but it is not a full business-policy check.

Sui transactions can create, mutate, transfer, share, and delete objects. Storage costs and storage rebates can create economic incentives that are not obvious from the gas budget alone. If you sponsor arbitrary transactions, a user can try to externalize costs to the sponsor while directing objects, balances, or rebate-related value to themselves or another address.

For product flows with financial value, validate the command shape and inspect net value movement:

import { analyzers, createAnalyzer } from '@mysten-incubation/sponsor';

const USDC = '0x...::usdc::USDC';

const requirePayment = createAnalyzer({
	dependencies: { balanceFlows: analyzers.balanceFlows },
	analyze:
		() =>
		({ balanceFlows }) => {
			const paidToSponsor =
				balanceFlows.sponsor
					?.filter((flow) => flow.coinType === USDC)
					.reduce((sum, flow) => sum + flow.amount, 0n) ?? 0n;

			return paidToSponsor >= 10_000n
				? { result: null }
				: { result: [{ code: 'UNDERPAID', message: 'Sponsor was not paid enough.' }] };
		},
});

Keep sponsor balances intentional

If the sponsor signer is loaded from an environment variable, treat it as a hot key. Keep only the SUI needed for short operating windows in the sponsor address balance, and top it up from a more protected account.

For production services, prefer a managed signer:

  • Use a KMS-backed signer such as @mysten/aws-kms-signer or @mysten/gcp-kms-signer.
  • Restrict who can deploy or read the signer configuration.
  • Separate the sponsor hot account from treasury accounts.
  • Rotate keys and drain old sponsor balances when access changes.

The Sponsor SDK accepts any Signer, so KMS signers can be used in place of an in-memory keypair.

Dry-run is not an execution guarantee

simulationSucceeds() dry-runs the transaction before signing, but chain state can change before execution. A transaction that passed simulation can still abort later and charge the sponsor gas.

Use dry-run as one policy input, not as the only control. Add:

  • gas caps,
  • target allowlists,
  • command-shape checks,
  • rate limits,
  • authentication,
  • per-user or per-tenant quotas.

Onchain replay isn't something you handle here. Sui executes a signed transaction at most once, and boundedExpiration() (a default) supplies the bounded expiration it requires for address-balance gas. Application-level idempotency is still yours: if a flow should be sponsored only once per user, dedupe it with a request ID, nonce, or stored intent.

Handle rejection and failed execution separately

A Rejected result means the sponsor did not sign or execute. A FailedTransaction means the transaction executed onchain and failed, so the sponsor might have paid gas.

switch (result.$kind) {
	case 'Rejected':
		return { ok: false, status: 403, issues: result.issues };
	case 'FailedTransaction':
		return { ok: false, status: 502, digest: result.FailedTransaction.digest };
	case 'Transaction':
		return { ok: true, digest: result.Transaction.digest };
}

Do not collapse those outcomes into a single error. They have different accounting and retry semantics.

On this page