You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
@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:
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.
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:
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.
Optional over-funding tolerance (small buffer on amount, remainder returned per 1) — also covers upward drift.
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.
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.
Summary
@mysten/walruspre-funds every WAL payment as exact change computed from a cached on-chain price, then asserts the payment coin is empty with0x2::coin::destroy_zero. Any change instorage_price_per_unit_size/write_price_per_unit_sizebetween 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
#withWalsplits exactlyamount(or creates it viacoinWithBalance), hands the coin to the payment call, then unconditionally appends:amountcomes fromstorageCost(), which readssystemState()through theSuiObjectDataLoadercache. The cache is only cleared byreset()/#retryOnPossibleEpochChange, which triggers onRetryableWalrusClientError— aMoveAbortindestroy_zerois 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
destroy_zero(ENonZero, abort code 0, surfaces at0x2::balance::destroy_zeroviacoin::destroy_zero).Two aggravating factors from today's incident:
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).storage_price_per_unit_sizemoved71464 → 70922and 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:
walCoinsource 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.amount, remainder returned per 1) — also covers upward drift.ENonZeroaborts on the SDK's own appendeddestroy_zerocommand,reset()the cache, rebuild. Narrows the window (a fresh price can still drift before execution) but fixes the "permanent failure" misclassification cheaply.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.