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

Building Offline

Build transactions without a network connection

Normally build() queries the network to resolve object versions, resolve intents like tx.coin(), fetch the gas price, estimate the gas budget, pick gas coins, and set an expiration when gas is paid from address balance. To build without a client, provide that information yourself. See also the Sui documentation on offline signing for the protocol-level details.

Building only the transaction kind

If the next step only needs the inputs and commands (for example, a sponsor or a backend that fills in gas), build with onlyTransactionKind. Sender, gas data, and expiration are left out:

import { Transaction } from '@mysten/sui/transactions';

const tx = new Transaction();
tx.moveCall({
	target: '0xPackage::module::function',
	arguments: [tx.pure.u64(100)],
});

const kindBytes = await tx.build({ onlyTransactionKind: true });

Inputs and intents still need to be resolved. Use full object references, tx.withdrawal(), or assumeSufficientAddressBalances so nothing needs a lookup.

Building full transaction bytes

A full offline build needs the sender and all gas data:

MethodDescription
setSender()The address executing the transaction
setGasPrice()Reference gas price (query getReferenceGasPrice() beforehand)
setGasBudget()Maximum gas to spend (in MIST). Estimating it requires a client
setGasPayment()Coin object references, or [] to pay gas from address balance
setGasOwner()Only for sponsored transactions. Defaults to the sender

Paying gas from address balance

With setGasPayment([]), gas is paid from the gas owner's SUI address balance. Nothing ties the transaction to a specific object version, so it also needs a ValidDuring expiration for replay protection:

import { Transaction } from '@mysten/sui/transactions';

// Look these up before going offline
const referenceGasPrice = 1000n;
const currentEpoch = 100;
const chainIdentifier = 'Base58ChainIdentifier'; // from getChainIdentifier()

const tx = new Transaction();

// FundsWithdrawal inputs contain the amount and type, so no object lookup is needed
tx.moveCall({
	target: '0x2::balance::send_funds',
	typeArguments: ['0x2::sui::SUI'],
	arguments: [tx.withdrawal({ amount: 1_000_000_000 }), tx.pure.address('0xRecipientAddress')],
});

tx.setSender('0xSenderAddress');
tx.setGasPrice(referenceGasPrice);
tx.setGasBudget(50_000_000);
tx.setGasPayment([]);

tx.setExpiration({
	ValidDuring: {
		minEpoch: currentEpoch,
		maxEpoch: currentEpoch + 1,
		minTimestamp: null,
		maxTimestamp: null,
		chain: chainIdentifier,
		// Must be unique for each transaction in the validity window, including across restarts
		nonce: await nonceStore.next(),
	},
});

const bytes = await tx.build();

Two otherwise-identical transactions with the same nonce have the same digest, and the second one is rejected as a duplicate. Use a counter that survives restarts, not a hard-coded value.

The address balance needs to cover the gas budget on top of any withdrawals. Don't add the budget to the withdrawal amount.

{ Epoch: n } expiration does not provide replay protection. Use ValidDuring.

Paying gas with coin objects

Gas coins need an exact version and digest. Other SUI can still come from address balance:

import { Transaction } from '@mysten/sui/transactions';

const tx = new Transaction();
tx.moveCall({
	target: '0x2::balance::send_funds',
	typeArguments: ['0x2::sui::SUI'],
	arguments: [
		tx.balance({ balance: 1_000_000, useGasCoin: false }),
		tx.pure.address('0xRecipientAddress'),
	],
});

tx.setSender('0xSenderAddress');
tx.setGasPrice(1000);
tx.setGasBudget(50_000_000);
tx.setGasPayment([{ objectId: '0xGasCoinId', version: '3', digest: 'Base58GasCoinDigest' }]);

// assumeSufficientAddressBalances resolves tx.balance() without a client (see below)
const bytes = await tx.build({ assumeSufficientAddressBalances: true });

Object inputs

Shared and party objects

Shared objects only need objectId and initialSharedVersion, both of which are stable:

tx.sharedObjectRef({
	objectId: '0xSharedObjectId',
	initialSharedVersion: '1',
	mutable: true,
});

Party objects are address-owned but consensus-versioned, with per-address permissions. They are referenced the same way as shared objects:

tx.sharedObjectRef({
	objectId: '0xPartyObjectId',
	initialSharedVersion: '1',
	mutable: true,
});

Key properties for offline building:

  • No version lookup needed: initialSharedVersion is stable and set once when the object becomes a party object
  • Enable pipelining: Submit multiple transactions on the same party object without waiting for each one to finalize
  • Cannot be used for gas: Pay gas from address balance or with a SUI coin object

Owned and immutable objects

Owned and immutable objects need the exact version and digest:

import { Transaction } from '@mysten/sui/transactions';

const tx = new Transaction();

// Owned objects need exact version and digest
tx.transferObjects(
	[
		tx.objectRef({
			objectId: '0xOwnedObjectId',
			version: '42',
			digest: 'abc123...',
		}),
	],
	'0xRecipientAddress',
);

// Receiving objects also need exact version and digest
tx.moveCall({
	target: '0xPackage::module::receive',
	arguments: [
		tx.objectRef({
			objectId: '0xParentId',
			version: '10',
			digest: 'def456...',
		}),
		tx.receivingRef({
			objectId: '0xReceivingId',
			version: '5',
			digest: 'ghi789...',
		}),
	],
});

Coin and balance intents

tx.coin() and tx.balance() normally look up the sender's address balance and coin objects. Pass assumeSufficientAddressBalances to skip the lookups and withdraw from address balance instead:

import { Transaction } from '@mysten/sui/transactions';

const tx = new Transaction();
tx.moveCall({
	target: '0x2::balance::send_funds',
	typeArguments: ['0xPackage::module::TOKEN'],
	arguments: [
		tx.balance({ type: '0xPackage::module::TOKEN', balance: 1_000_000 }),
		tx.pure.address('0xRecipientAddress'),
	],
});

// The sender is still required, since the withdrawal comes from its address balance
tx.setSender('0xSenderAddress');

const kindBytes = await tx.build({
	onlyTransactionKind: true,
	assumeSufficientAddressBalances: true,
});

Nothing is checked. The transaction builds, then fails at execution if the address balance doesn't cover it.

SUI intents withdraw from address balance too, whether or not the transaction also uses tx.gas. This matches an online build when the address balance is sufficient. Mixing default and useGasCoin: false SUI intents in one transaction is still an error.

On a full build, the option also sets an unset gas payment to [], but only when nothing else needs a client, the transaction doesn't use tx.gas, and a ValidDuring or Validity expiration is already set. Sender, gas price, and gas budget still need to be provided. Once set, the empty payment is part of the transaction, the same as calling setGasPayment([]).

Serialization

toJSON() resolves async thunks and intents but does not fill in gas or object versions. Pass supportedIntents to keep an intent for another system to resolve:

// Intents are resolved, so tx.coin() needs a client or assumeSufficientAddressBalances
const json = await tx.toJSON({ assumeSufficientAddressBalances: true });
const restored = Transaction.from(json);

// Or keep tx.coin() intents for the receiver to resolve
const jsonWithIntents = await tx.toJSON({ supportedIntents: ['CoinWithBalance'] });

Transaction.from() accepts JSON strings, BCS bytes, and base64-encoded BCS.

On this page