Cycle

The Scriptorium

Smart Assembly code templates and tools for on-chain development in Eve Frontier.

TypeScript PTBs for EVE Frontier
AdvancedChapter 4 of 415 min read

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:

typescript
const 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:

typescript
try {
  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:
typescript
const [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:

typescript
async 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:

typescript
if (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:

typescript
const 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:

typescript
class 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:

typescript
const 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.
  • Wrap wallet components in error boundaries to prevent crashes from propagating.

Sign in to track your progress.