Skip to content

[walrus] Exact-change WAL payment + destroy_zero races against on-chain price changes (took out mainnet writes for a prod service today) #1127

Description

@DrVelvetFog

Summary

@mysten/walrus pre-funds every WAL payment as exact change computed from a cached on-chain price, then asserts the payment coin is empty with 0x2::coin::destroy_zero. Any change in storage_price_per_unit_size / write_price_per_unit_size between the cache fill and execution makes the transaction abort deterministically. This took out 100% of mainnet writes for a production service earlier today (MystenLabs/MemWal#351); @ducnmm root-caused it in MystenLabs/MemWal#352 and noted it's worth hardening upstream — filing that here.

The pattern

#withWal splits exactly amount (or creates it via coinWithBalance), hands the coin to the payment call, then unconditionally appends:

tx.moveCall({
    target: '0x2::coin::destroy_zero',
    typeArguments: [walType],
    arguments: [coin],
});

amount comes from storageCost(), which reads systemState() through the SuiObjectDataLoader cache. The cache is only cleared by reset() / #retryOnPossibleEpochChange, which triggers on RetryableWalrusClientError — a MoveAbort in destroy_zero is not classified as one, so callers see a permanent-looking failure.

All three payment paths share the race: createStorage (storage cost → reserve_space), registerBlob (write cost → register_blob), extendBlob (storage cost → extend_blob).

Failure modes

  • Price moves down → the contract deducts less than the split amount, the leftover trips destroy_zero (ENonZero, abort code 0, surfaces at 0x2::balance::destroy_zero via coin::destroy_zero).
  • Price moves up → the split coin underfunds the payment and the register/reserve/extend call itself aborts.

Two aggravating factors from today's incident:

  1. Under sponsorship the abort is masked. With Enoki the failure surfaces at the sponsor's dry-run as dry_run_failed: could not automatically determine a budget, so operators debug the sponsor before finding the stale price (that's exactly how Mainnet writes fail on both prod relayers: Enoki dry_run_failed (MoveAbort balance::destroy_zero in sponsored upload) MemWal#351 was reported).
  2. It reads as permanent. Because the error isn't classified retryable, downstream job systems dead-letter every write until someone restarts the process (observed: storage_price_per_unit_size moved 71464 → 70922 and a relayer flipped from healthy to 100% failure at the price transition — see fix(relayer): recover Walrus register destroy_zero from stale WAL price (#351) MemWal#352 for the evidence).

Confirmed unchanged on latest @mysten/walrus (1.2.3).

Possible directions

Any subset of these would help; the first eliminates the downward-drift race rather than narrowing it:

  1. Return the remainder instead of asserting zero. When a walCoin source is provided, tx.mergeCoins(source, [coin]) after the payment call; otherwise transfer the (usually zero) remainder to the sender. No behavior change on the happy path beyond not asserting.
  2. Optional over-funding tolerance (small buffer on amount, remainder returned per 1) — also covers upward drift.
  3. Classify the abort as retryable: recognize ENonZero aborts on the SDK's own appended destroy_zero command, reset() the cache, rebuild. Narrows the window (a fresh price can still drift before execution) but fixes the "permanent failure" misclassification cheaply.
  4. Export an error type/classifier so integrators don't have to string-match the MoveAbort to know it's a stale-price condition (the MemWal relayer's auto-heal missed it for exactly that reason — its matcher looked for balance+split).

Happy to put up a PR for (1) (+ (3) if wanted) if a direction here is agreeable — I don't want to guess at which trade-off you'd prefer in the payment path.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions