Before broadcasting a Sui transaction, you can simulate it over JSON-RPC using devInspectTransaction or dryRunTransactionBlock. These methods execute the transaction against the current state without committing, returning the TransactionEffects and execution status. This lets you catch object version conflicts, insufficient gas, and command-level failures at zero cost, ensuring your transaction will succeed when actually submitted.
Direct Answer: Simulate First, Pay Later
Sui transactions are not executed in a vacuum: they operate on a set of owned and shared objects, each with a specific version. A transaction that succeeds against one object version may fail against another. To avoid wasting gas on failed transactions, Sui provides two JSON-RPC methods that let you simulate a transaction before broadcasting it: devInspectTransaction and dryRunTransactionBlock. Both return the TransactionEffects that would result, including an execution status of success or failure. By inspecting these effects, you can catch object version conflicts, insufficient gas, and command-level errors before they cost you anything.
This guide explains the mechanism behind these simulation methods, how to decode the returned effects, and provides a reproducible TypeScript script using the official @mysten/sui/client SDK. You'll learn to distinguish between a successful simulation and a failed one, and how to handle the common pitfalls that trip up developers.
Understanding Sui Transaction Execution and Object Versioning
In Sui, a transaction is a TransactionData structure that contains a programmable TransactionBlock. This block consists of commands that operate on inputs, which are either owned objects, shared objects, or pure values. Each object in Sui has a unique ID and a monotonically increasing version number. When a transaction is executed, validators check that the object versions referenced in the transaction match the current versions on-chain. If they don't, the transaction fails with an object version conflict.
This versioning is crucial for simulation: when you simulate a transaction, you must ensure that the object versions you provide are the ones you intend to use. If you fetch an object's reference (ID and version) and then construct a transaction, but the object is mutated by another transaction before you submit, your simulation may succeed while the real submission fails. Therefore, always fetch the latest object references immediately before constructing your transaction.
The two simulation methods differ in their approach. dryRunTransactionBlock executes the transaction as if it were being submitted, requiring a gas object and a gas budget. It returns the exact effects that would be committed, including the gas used. devInspectTransaction, on the other hand, runs the transaction against a set of supplied objects without charging gas and without requiring a gas object. It assumes an infinite gas budget and is intended for development and testing. The results from devInspectTransaction should not be taken as the exact outcome on mainnet, because it uses the objects you provide, not necessarily the current on-chain state.
Both methods are read-only: they do not mutate state. However, dryRunTransactionBlock requires a gas payment and will fail if the gas budget is insufficient, while devInspectTransaction does not. This makes devInspectTransaction ideal for 'what-if' scenarios, such as testing a new command against a hypothetical object state.
The Two Simulation Entry Points: dryRunTransactionBlock vs devInspectTransaction
The Sui JSON-RPC API provides two primary methods for simulating transactions. Note that method names have evolved: devInspectTransactionBlock was renamed to devInspectTransaction in recent SDK versions, and older names are marked deprecated in several references. Always use the current method names as per your SDK and endpoint's API version; the Sui JSON-RPC documentation and the Sui API references for dryRunTransactionBlock and devInspectTransaction describe the current signatures. For example, the @mysten/sui/client SDK exposes client.devInspectTransaction and client.dryRunTransactionBlock.
dryRunTransactionBlock takes a TransactionBlock (or its serialized bytes) and a sender address. It executes the transaction against the current state, using the gas object specified in the transaction. It returns a DryRunTransactionBlockResponse containing the effects and any error. The effects include the status (success or failure), the gasUsed, and the list of created, mutated, and deleted objects.
devInspectTransaction takes a sender address, a TransactionBlock, and optionally a list of gasPrice and epoch. It does not require a gas object; instead, it uses a mock gas coin with an infinite balance. It returns a DevInspectResponse with the effects and results for each command. The effects include a gasUsed summary, but since the gas budget is infinite, the actual gas cost is not representative of a real transaction.
Key difference: dryRunTransactionBlock will fail if the gas budget is insufficient, while devInspectTransaction will not. Therefore, to test whether your transaction will succeed with a specific gas budget, use dryRunTransactionBlock. To test the logic of your transaction without worrying about gas, use devInspectTransaction.
Decoding Execution Status and Effects
The most important part of a simulation response is the effects.status. This is an object with a status field that is either 'success' or 'failure'. If it's 'failure', the error field contains a string describing the error. Common errors include 'MoveAbort', 'MoveModule', 'U64Wrap', and object version conflicts.
A command-level failure occurs when a specific command in the transaction block fails. For example, a MoveAbort error indicates that a Move module aborted, often due to a failed assertion. An object version conflict occurs when the input object's version does not match the current on-chain version. This is a common issue when you construct a transaction using stale object references.
It's crucial to distinguish between an RPC error and a successful RPC with a failure status. If the simulation call itself returns an error (e.g., invalid parameters), that's a client-side issue. If the call succeeds but the effects.status is 'failure', that means the transaction would fail if submitted. Many developers mistakenly treat any error as an RPC failure, but you must check the effects.status field.
The effects also contain a gasUsed object with computationCost, storageCost, and storageRebate. In a dry run, these reflect the actual gas that would be used. In a dev inspect, the gas used is computed assuming an infinite budget, so the gasUsed may be higher than what you'd actually pay. Always use dryRunTransactionBlock to estimate real gas costs.
Reproducible Example: Simulating a Coin Transfer with @mysten/sui/client
The following TypeScript script demonstrates how to simulate a simple coin transfer using devInspectTransaction and dryRunTransactionBlock. It connects to a Sui endpoint, constructs a transaction that transfers a specific amount of SUI from one address to another, and then simulates it. The script prints the execution status, gas summary, and any errors.
To run this script, you'll need Node.js and the @mysten/sui package. Install it with npm install @mysten/sui. Replace the endpoint URL and addresses with your own. The script uses the devInspectTransaction method, but you can easily switch to dryRunTransactionBlock by uncommenting the relevant lines.
Note: The script assumes you have an object (a coin) to transfer. You'll need to provide the object ID and its version. In a real scenario, you'd fetch these using suix_getOwnedObjects or suix_getDynamicField.
import { SuiClient, getFullnodeUrl } from '@mysten/sui/client';
import { Transaction } from '@mysten/sui/transactions';
// Connect to a Sui endpoint (replace with your endpoint)
const client = new SuiClient({ url: getFullnodeUrl('mainnet') });
async function simulateTransfer() {
const sender = '0xYOUR_SENDER_ADDRESS';
const recipient = '0xRECIPIENT_ADDRESS';
const coinObjectId = '0xCOIN_OBJECT_ID';
const amount = 1000; // in MIST
// Build a transaction to transfer `amount` from the coin to recipient
const tx = new Transaction();
const coin = tx.object(coinObjectId);
tx.transferObjects([coin], recipient);
// Simulate using devInspectTransaction (no gas required)
const devInspectResult = await client.devInspectTransaction({
sender,
transactionBlock: tx,
});
console.log('DevInspect Status:', devInspectResult.effects.status);
console.log('DevInspect Gas Used:', devInspectResult.effects.gasUsed);
// To simulate with a gas budget, use dryRunTransactionBlock
// You need to set the gas budget and gas payment in the transaction
// tx.setGasBudget(1000);
// tx.setGasPayment([{ objectId: gasCoinId, version: gasCoinVersion, digest: gasCoinDigest }]);
// const dryRunResult = await client.dryRunTransactionBlock({
// transactionBlock: tx,
// sender,
// });
// console.log('DryRun Status:', dryRunResult.effects.status);
}
simulateTransfer().catch(console.error);Simulating Failure Scenarios: Object Version Conflicts and Insufficient Gas
To understand failure shapes, it's instructive to simulate an intentionally wrong object version or an insufficient gas budget. The following script demonstrates how to create a transaction that references an old version of an object, causing a version conflict, and how to simulate a transaction with a gas budget that is too low.
For the object version conflict, you can manually set the version of the coin object to an older version (e.g., version 1) while the current version is higher. This will cause the simulation to fail with an error indicating a version mismatch.
For insufficient gas, you can set a very low gas budget in the transaction and then call dryRunTransactionBlock. The simulation will return a failure status with an error about insufficient gas.
The table below summarizes common status strings and their meanings. Fill in the 'Fix' column based on your scenario.
// Simulate an object version conflict
async function simulateVersionConflict() {
const sender = '0xYOUR_SENDER_ADDRESS';
const recipient = '0xRECIPIENT_ADDRESS';
const coinObjectId = '0xCOIN_OBJECT_ID';
// Assume the current version is 5, but we use version 1
const staleVersion = 1;
const staleDigest = '0xSTALE_DIGEST';
const tx = new Transaction();
const coin = tx.objectRef({
objectId: coinObjectId,
version: staleVersion,
digest: staleDigest,
});
tx.transferObjects([coin], recipient);
const result = await client.devInspectTransaction({
sender,
transactionBlock: tx,
});
console.log('Version Conflict Status:', result.effects.status);
console.log('Error:', result.effects.status.error);
}
// Simulate insufficient gas using dryRunTransactionBlock
async function simulateInsufficientGas() {
const sender = '0xYOUR_SENDER_ADDRESS';
const recipient = '0xRECIPIENT_ADDRESS';
const coinObjectId = '0xCOIN_OBJECT_ID';
const gasCoinId = '0xGAS_COIN_ID';
const gasCoinVersion = 1;
const gasCoinDigest = '0xGAS_COIN_DIGEST';
const tx = new Transaction();
const coin = tx.object(coinObjectId);
tx.transferObjects([coin], recipient);
tx.setGasBudget(1); // absurdly low
tx.setGasPayment([{ objectId: gasCoinId, version: gasCoinVersion, digest: gasCoinDigest }]);
const result = await client.dryRunTransactionBlock({
transactionBlock: tx,
sender,
});
console.log('Insufficient Gas Status:', result.effects.status);
console.log('Error:', result.effects.status.error);
}Fill-in Decision Table for Simulation Statuses
When you simulate a transaction, you'll encounter various status strings. The table below lists common ones and their meanings. Use it to quickly diagnose issues.
Status / Error Meaning Fix successThe transaction would succeed. Submit it. failurewithMoveAbortA Move module aborted, often due to a failed assertion. Inspect the Move code and the abort code. failurewithMoveModuleAn error occurred in a Move module, such as a missing function or type mismatch. Check the module's interface and your transaction's commands. failurewithU64WrapAn unsigned 64-bit integer overflowed. Adjust your calculations to avoid overflow. failurewithInsufficientGasThe gas budget is too low. Increase the gas budget. failurewithObjectVersionConflictThe object version in the transaction does not match the current on-chain version. Fetch the latest object reference and rebuild the transaction. failurewithObjectDeletedThe object has been deleted. Use a different object or recreate it. failurewithCommandArgumentErrorA command argument is invalid. Check the arguments passed to the command.
Common Pitfalls and Troubleshooting Checklist
Simulating transactions is straightforward, but several pitfalls can lead to confusion. Use this checklist to avoid them:
- Always fetch the latest object references before constructing your transaction. Use
suix_getDynamicFieldorsuix_getOwnedObjectsto get the current version and digest. Stale references cause version conflicts.
- Check the
effects.statusfield, not just the RPC response. A successful RPC call can still return a failed transaction status.
- Understand the difference between
devInspectTransactionanddryRunTransactionBlock. The former does not require gas and uses an infinite budget; the latter simulates with your specified gas budget and payment.
- For gas estimation, use
dryRunTransactionBlock. ThegasUsedfromdevInspectTransactionis not representative because it assumes an infinite budget.
- Be aware of method naming changes.
devInspectTransactionBlockis deprecated; usedevInspectTransactionin current SDKs. Verify the method names supported by your endpoint's API version.
- When simulating shared objects, ensure you provide the correct shared object version. Shared objects have a version that increments with each transaction, so you must fetch the latest.
- If you're using a programmable transaction block, ensure all commands are valid and the inputs are correctly referenced. A single invalid command will cause the entire simulation to fail.
Limitations and Tradeoffs of Simulation
Simulation is a powerful tool, but it has limitations. devInspectTransaction does not charge gas and uses a mock gas coin, so it cannot detect insufficient gas errors. It also uses the objects you provide, which may not reflect the current on-chain state if you don't fetch them fresh. Therefore, a successful devInspectTransaction does not guarantee that the real transaction will succeed.
dryRunTransactionBlock is more accurate because it uses the actual gas object and current state. However, it still does not guarantee success because the state can change between the simulation and the actual submission. For example, another transaction could mutate an object you're using, causing a version conflict.
Another limitation is that simulation does not execute Move code that has side effects outside of the transaction's scope. For example, if your transaction calls a Move function that emits an event, the event will not be emitted during simulation. This is expected, as simulation is read-only.
Finally, simulation results are only as good as the data you provide. If you use stale object references or incorrect parameters, the simulation will be misleading. Always fetch the latest data before simulating.
Next Steps and Further Reading
Now that you understand how to simulate Sui transactions, you can integrate this into your development workflow to save time and money. To deepen your knowledge, explore the following resources:
- Sui RPC guide (RPC Assistant) for a comprehensive overview of Sui RPC methods.
- Sui WebSocket subscriptions to learn how to subscribe to transaction effects in real-time.
- Sui RPC timeouts to understand timeout behavior when simulating large transactions.
- Querying Sui historical state over RPC to fetch past object versions for testing.
- Monitoring RPC endpoints to ensure your endpoint is reliable for simulations.
- OnFinality Learn hub for more guides on Sui and other networks.
- Sui network overview for general information about Sui.
- API service to get a dedicated endpoint for your development.
- RPC pricing to understand the cost of RPC calls, including simulations.