Error Handling
SUI transactions can fail in several ways. Some failures happen before the transaction reaches the blockchain (client-side), some during execution (on-chain), and some are caused by stale data. Understanding these error categories helps you build resilient dApps.
Error Categories
1. Dry Run Failures
Before submitting a transaction, the SUI SDK performs a "dry run" -- a simulation that estimates gas costs and catches obvious errors. If the dry run fails, the transaction is never submitted:
``
Error: Dry run failed, could not automatically determine a budget
`
This commonly happens with complex PTBs (especially the borrow-use-return pattern) or when the SDK cannot resolve object types. The workaround is to set an explicit gas budget:
typescriptconst tx = new Transaction();
// ... build your PTB ...
// Skip automatic gas estimation by setting a budget
tx.setGasBudget(50_000_000n);
const result = await signAndExecute({ transaction: tx });
Choose a budget that is generous but not wasteful.
50_000_000 (50M MIST = 0.05 SUI) is a reasonable default for most extension operations.
2. Move Abort Errors
When a Move
assert! fails, the transaction aborts with an error code and message:
`
MoveAbort(0, "Character is not in the configured tribe")
`
These are the custom errors defined in your Move code:
move#[error(code = 0)]
const ENotTribeMember: vector<u8> = b"Character is not in the configured tribe";
#[error(code = 1)]
const ENoTribeConfig: vector<u8> = b"Tribe configuration not set on ExtensionConfig";
Handle these in TypeScript by parsing the error message:
typescripttry {
const result = await signAndExecute({ transaction: tx });
const parsed = extractDigest(result);
if (parsed?.failed) {
setError(Transaction failed: ${parsed.digest});
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Transaction failed';
if (message.includes('not in the configured tribe')) {
setError('Your character is not in the correct tribe for this SSU');
} else if (message.includes('Tribe configuration not set')) {
setError('This extension has not been configured yet');
} else {
setError(message);
}
}
3. Object Equivocation
Object equivocation occurs when two transactions try to use the same owned object at the same time. SUI detects this and rejects one (or both) transactions:
`
Object 0xabc... is equivocated - Loss of Safety
`
This is a serious error that can temporarily lock the affected object. Common causes:
- Rapidly clicking a button that sends transactions
- Using the same
AdminCap from two browser tabs
Background processes competing for the same object
Prevention:
typescriptconst [isPending, setIsPending] = useState(false);
const handleAction = async () => {
if (isPending) return; // Guard against double-submission
setIsPending(true);
try {
await signAndExecute({ transaction: tx });
} finally {
setIsPending(false);
}
};
4. Stale Object References
When you build a PTB using
ReceivingRef, the version and digest must match the object's current on-chain state. If another transaction modified the object between when you fetched its data and when your transaction executes, you get:
`
Object version mismatch for object 0xabc...
`
Solutions:
Fetch the object data as close to transaction submission as possible:
typescript// Fetch immediately before building the PTB
const capRef = await fetchObjectRef(characterOwnerCapId);
if (!capRef) {
setError('Could not fetch OwnerCap. It may have been used in another transaction.');
return;
}
// Build and submit immediately after
If the error persists, add a retry mechanism:
typescriptasync function executeWithRetry(
buildTx: () => Promise<Transaction>,
signAndExecute: (params: { transaction: unknown }) => Promise<unknown>,
maxRetries = 2,
): Promise<unknown> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const tx = await buildTx(); // Re-fetch object refs on each attempt
return await signAndExecute({ transaction: tx });
} catch (err) {
const msg = err instanceof Error ? err.message : '';
if (msg.includes('version mismatch') && attempt < maxRetries) {
continue; // Retry with fresh object data
}
throw err;
}
}
}
5. Insufficient Gas
`
InsufficientGas: Gas balance is X, but need Y
`
The user's wallet does not have enough SUI to cover the gas cost. Display a user-friendly message:
typescriptif (message.includes('InsufficientGas') || message.includes('balance')) {
setError('Insufficient SUI balance for gas. Get testnet SUI from the faucet.');
}
Reporting Transaction Effects
After a successful transaction, you should report the effects back to the wallet SDK. This updates the wallet's internal cache of object versions, preventing stale reference errors in subsequent transactions:
typescript// If using DAppKit
const result = await signAndExecute({ transaction: tx });
// Some wallet implementations need explicit effect reporting
if (client.reportTransactionEffects && result.effects) {
await client.reportTransactionEffects(result.effects);
}
Waiting for Transaction Finality
When you need to read updated on-chain state after a transaction, wait for it to be finalized:
typescriptconst result = await signAndExecute({ transaction: tx });
const digest = extractDigest(result);
if (digest) {
// Wait for the transaction to be fully processed
await client.waitForTransaction({
digest: digest.digest,
options: { showEffects: true },
});
// Now safe to read updated state
const updatedData = await fetchObjectRef(ssuId);
}
Without
waitForTransaction, subsequent reads might return stale data because the transaction has not been indexed yet.
Error Boundary Pattern
For React dApps, wrap wallet-dependent components in an error boundary:
typescriptclass SuiErrorBoundary extends Component<
{ children: React.ReactNode },
{ hasError: boolean }
> {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.children; // Render without wallet features
}
return this.props.children;
}
}
This prevents wallet-related crashes from taking down the entire application. The AncientStorage dApp uses this pattern in
SuiWalletProvider.
Complete Error Handling Example
Here is the pattern used in the production
useTribeStorageTransaction hook:
typescriptconst withdraw = useCallback(async (
ssuId: string,
characterObjectId: string,
typeId: number,
quantity: number,
): Promise<string | null> => {
if (!signAndExecute) {
setError('Wallet not connected');
return null;
}
setIsPending(true);
setError(null);
try {
const tx = new Transaction();
tx.moveCall({
target: ${PACKAGE_ID}::tribe_storage::withdraw,
arguments: [
tx.object(CONFIG_ID),
tx.object(ssuId),
tx.object(characterObjectId),
tx.pure.u64(typeId),
tx.pure.u32(quantity),
tx.pure.bool(isOwner),
],
});
const result = await signAndExecute({ transaction: tx });
const parsed = extractDigest(result);
if (!parsed) {
setError(Unexpected response: ${JSON.stringify(result)});
return null;
}
if (parsed.failed) {
setError(Transaction failed: ${parsed.digest});
return null;
}
setLastTxDigest(parsed.digest);
return parsed.digest;
} catch (err) {
setError(err instanceof Error ? err.message : 'Transaction failed');
return null;
} finally {
setIsPending(false);
}
}, [signAndExecute]);
Key Takeaways
- Set explicit gas budgets with
tx.setGasBudget() to avoid dry run failures on complex PTBs.
Move abort errors include the message from #[error] attributes -- parse them for user-friendly displays.
Guard against object equivocation by disabling UI during pending transactions.
Stale ReceivingRef data causes version mismatch errors -- fetch object refs immediately before building PTBs.
Use waitForTransaction` before reading updated state after a transaction.
Sign in to track your progress.