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

Basic Usage

Build a service that validates and executes user-signed sponsored bytes

In the examples below, userSigner is the user's Signer. In an app, the user signs through dapp-kit's signTransaction instead.

Prefer services that expect already-built bytes plus the user's signature:

  1. The client gets the sponsor address.
  2. The client builds final transaction bytes with the sponsor as gas owner and address-balance gas.
  3. The user signs those exact bytes.
  4. The backend validates the bytes, co-signs, and executes.

This means the sponsor knows exactly what it is co-signing, there is no half-signed transaction waiting on a user after the sponsor signs, and the flow avoids an extra sponsor-build round trip before the user can sign.

Provide the sponsor address

The client needs a sponsor address before it builds the transaction. For services where the sponsor account might rotate, expose the current address through a small config endpoint:

app.get('/config', (c) => c.json({ sponsor: sponsor.address }));

Prefer fetching this value dynamically when you want the backend to rotate the sponsor key or move sponsorship to a different account without forcing client deployments. Use the chosen address as the transaction gas owner. In local examples where the client and sponsor live in the same process, sponsor.address is the same value.

Client builds the transaction

The client sets the sender, the gas owner to the sponsor, and the gas payment to [] (address-balance gas). The user signs those final bytes and sends them to the sponsor service:

import { toBase64 } from '@mysten/sui/utils';

const { sponsor } = await fetch('/config').then((res) => res.json());

transaction.setSender(userAddress);
transaction.setGasOwner(sponsor); // gas is paid by the sponsor…
transaction.setGasPayment([]); // …from its address balance (no specific gas coins)
const bytes = await transaction.build({ client });

const { signature: userSignature } = await userSigner.signTransaction(bytes);

await fetch('/sponsor', {
	method: 'POST',
	headers: { 'content-type': 'application/json' },
	body: JSON.stringify({ transaction: toBase64(bytes), userSignature }),
});

The backend validates those bytes, co-signs, and executes:

const result = await sponsor.signAndExecuteTransaction({
	transaction: body.transaction,
	userSignature: body.userSignature,
});

switch (result.$kind) {
	case 'Rejected': // a validator declined; the sponsor never signed or executed
		throw new Error(result.issues.map((issue) => issue.message).join('; '));
	case 'FailedTransaction': // executed onchain but aborted, the sponsor still paid gas
		throw new Error(`Transaction failed onchain: ${result.FailedTransaction.digest}`);
	case 'Transaction': // executed successfully
		return result.Transaction.digest;
}

signAndExecuteTransaction has three outcomes: a policy Rejected (never executed), a FailedTransaction (executed but aborted onchain, where the sponsor still pays gas either way), and a successful Transaction. The non-obvious one is FailedTransaction: a result that isn't Rejected still isn't necessarily a success.

Pass include when the service needs extra transaction details in the response. The response always includes effects because the service needs them to distinguish successful execution from failed execution.

A user transaction

A sponsored transaction operates on the user's objects, never the gas coin. When the user spends SUI, source it from their own balance with useGasCoin: false:

function userPayment(amount: bigint) {
	const tx = new Transaction();
	const coin = tx.coin({ balance: amount, useGasCoin: false });
	tx.transferObjects([coin], recipient);
	return tx;
}

Over the network

The methods are transport-agnostic: they take transaction bytes as Uint8Array or base64 strings, and user signatures as signature strings. You own the HTTP shape, so serialize binary fields as base64. The client side is exactly the build-and-sign flow above; the backend maps each result $kind to a response. Every outcome is a $kind, so no try/catch is needed for a policy rejection:

// Server handler — wire it into your framework's route however you like.
async function handleSponsorRequest(body: { transaction: string; userSignature: string }) {
	const result = await sponsor.signAndExecuteTransaction({
		transaction: body.transaction,
		userSignature: body.userSignature,
	});
	switch (result.$kind) {
		case 'Rejected':
			return { ok: false as const, issues: result.issues };
		case 'FailedTransaction':
			return { ok: false as const, issues: [{ message: 'Transaction failed on-chain.' }] };
		case 'Transaction':
			return { ok: true as const, digest: result.Transaction.digest };
	}
}

Need per-request context, such as an auth token or tenant id? Validators read it from options, and the sponsor requires it as typed validationOptions. See Validation. Handle replay prevention and rate limiting in your service.

For a complete Hono server using this shape, see the sponsor server example.

Gas and unresolved transactions

When the client builds, the user already built and signed final bytes, so they must be resolved. The sponsor never rebuilds them.

When the sponsor builds the transaction, what you pass to signTransaction can be unresolved, with unresolved object inputs and no gas budget being fine. The sponsor sets itself as gas owner with address-balance gas and calls build({ client }), which in one pass resolves object inputs, sets the gas price and expiration, and (unless the transaction already pins one with tx.setGasBudget(…)) estimates the gas budget by dry-running. Validation then runs on the built transaction, so gasBudget({ max }) checks the resolved budget and analyzers see the exact bytes that get signed.

Advanced: sponsor builds the transaction

For public sponsorship services, prefer accepting final bytes plus userSignature. Use the sponsor-builds flow only when the backend owns transaction construction and you understand the trust boundary.

Alternatively, hand the sponsor the user's commands. The sponsor sets itself as gas owner, builds\n(address-balance gas, and a dry-run-estimated budget unless you pinned one), validates, and signs. The user then signs the returned bytes, and both signatures execute together. sender is optional, applied only when the transaction doesn't already set one (for example, bare commands):

const result = await sponsor.signTransaction({
	transaction,
	sender: userAddress, // optional — only used if `transaction` has no sender
});
if (result.$kind === 'Rejected') {
	throw new Error(result.issues.map((issue) => issue.message).join('; '));
}

const { signature: userSignature } = await userSigner.signTransaction(result.bytes);
await client.core.executeTransaction({
	transaction: result.bytes,
	signatures: [userSignature, result.sponsorSignature],
});

On this page