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.
Recommended flow
Prefer services that expect already-built bytes plus the user's signature:
- The client gets the sponsor address.
- The client builds final transaction bytes with the sponsor as gas owner and address-balance gas.
- The user signs those exact bytes.
- 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],
});