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:
| Method | Description |
|---|---|
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:
initialSharedVersionis 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.