Skip to content

fix(walrus): make WAL payments resilient to on-chain price changes#1128

Draft
hayes-mysten wants to merge 5 commits into
mainfrom
walrus-price-drift-payments
Draft

fix(walrus): make WAL payments resilient to on-chain price changes#1128
hayes-mysten wants to merge 5 commits into
mainfrom
walrus-price-drift-payments

Conversation

@hayes-mysten

@hayes-mysten hayes-mysten commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #1127.

@mysten/walrus pre-funded every WAL payment as exact change computed from a cached systemState() price, then asserted the payment coin was empty with 0x2::coin::destroy_zero. Any change in storage_price_per_unit_size / write_price_per_unit_size between cache-fill and execution made transactions abort deterministically — and price history shows prices change mid-epoch, roughly every ~90 seconds on mainnet (nodes re-cast price votes through the public staking::update_prices, which writes straight into the System object), so this was a standing hazard rather than an epoch-boundary edge case.

The walrus payment functions (reserve_space, register_blob, extend_blob) all take &mut Coin<WAL> and deduct only the actual on-chain cost, which allows a simple funding model: fund the estimated cost plus a configurable buffer (costBufferBps, default 10%) to absorb upward price drift, pay, and return whatever wasn't consumed.

  • Caller-provided walCoin: the buffered amount is split from the coin and the remainder is merged back into it after the payment — so only the actual cost is deducted, and the spend from the source coin is capped at estimate + buffer.
  • Funding from the signer's balance (the default): the coin comes from coinWithBalance, and the remainder is sent to the sender's address balance via 0x2::coin::send_funds (which accepts zero balances, so nothing asserts). The recipient is the result of a 0x2::tx_context::sender call in the same PTB, resolved on-chain at execution — no sender needs to be set while building, so this composes cleanly with executors, wallets, sponsorship, and serialized transactions.
  • registerBlob funds storage + write costs with a single coin threaded through the nested createStorage.
  • Stale-price aborts are classified whether they surface from transaction resolution (SimulationError — the gRPC client resolves transactions by simulating on the node, so aborts usually appear here) or from an executed FailedTransaction (reachable when gas is preset, or on non-simulating transports). Classification checks for a MoveAbort in 0x2::balance and, when the aborting command index is available, requires it to be a command added for payments (a walrus system::reserve_space/register_blob/extend_blob call or a coin split/merge) so unrelated aborts in caller-composed transactions aren't misclassified. On a match the client resets its caches and throws the new exported StalePriceError (extends RetryableWalrusClientError). There is deliberately no automatic retry: a rebuilt transaction needs a new signature anyway — the cache reset guarantees the caller's next attempt is built with freshly loaded prices.
  • isStalePriceAbort is exported for transactions executed outside the client (wallets, sponsors): feed it the execution error, and call client.reset() to refresh prices.

Buffer default

Historical PricesUpdated events (mainnet, May–July 2026, ~613 changes) show single upward repricing steps up to +5.8% and worst 5-minute windows of +9.9%. Since the buffer is fully refunded on-chain (it only needs to be held, not spent), the default is 10%, covering the worst observed burst — important for wallet/sponsored flows where minutes can pass between building and signing. costBufferBps: 0 restores exact funding.

Behavior notes

  • Funding now requires holding estimate × 1.1 WAL at build time (refunded on-chain if unused).
  • With a caller-provided walCoin, estimate + buffer is split from the coin and the unspent remainder merged back, instead of "exactly the estimate or abort" (docstrings updated).
  • Signer-funded remainders accumulate in the sender's address balance; coinWithBalance sources from address balances, so they're fully spendable by subsequent writes.

Test plan

  • Unit tests for isStalePriceAbort; full walrus unit suite passes (74/74).
  • Reproduced the original bug on main against testnet by monkey-patching systemState() to simulate a stale cache: +2% stale → MoveAbort code 0 in 0x2::balance::destroy_zero, −2% stale → code 2 in 0x2::balance::split, matching the incident report exactly.
  • Verified the fix end-to-end on testnet with local verification scripts (not committed) covering writeBlob and extendBlob:
    • +2% stale cache → succeeds; net WAL spent identical to the control run (over-estimate fully returned to the address balance via send_funds + tx_context::sender).
    • −15% stale cache (beyond buffer) → fails with StalePriceError, exactly one cache reset observed, and a new attempt on the same client succeeds with freshly loaded prices (no auto-retry).
    • −0.5% (within buffer) → succeeds on the first attempt.
    • Explicit walCoin with +2% stale prices → succeeds; only the actual cost (205947 FROST) deducted from the coin, remainder merged back.
    • Execution-path classification: via SuiJsonRpcClient with preset gas budget + payment (no dry-run), the stale-price abort executed on-chain and the resulting FailedTransaction was classified as StalePriceError (digest FdZuMoqgotvfs7jrSzFX8zLY6fFyxrGSRb2kReCtugN5), confirming the command-index narrowing works against real effects.
    • Control → succeeds.
  • pnpm turbo build --filter=@mysten/walrus, lint, prettier, and docs validation all pass.

AI Assistance Notice

Please disclose the usage of AI. This is primarily to help inform reviewers of how careful they need to review PRs, and to keep track of AI usage across our team. Please fill this out accurately, and do not modify the content or heading for this section!

  • This PR was primarily written by AI.
  • I used AI for docs / tests, but manually wrote the source code.
  • I used AI to understand the problem space / repository.
  • I did not use AI for this PR.

🤖 Generated with Claude Code

WAL payments were split as exact change computed from cached prices and
asserted empty with coin::destroy_zero after the payment call. Any price
change between cost estimation and execution made transactions abort
deterministically (#1127).

The walrus payment functions take the payment coin by mutable reference
and only deduct the actual on-chain cost, so:

- A provided walCoin is now passed through directly and keeps whatever
  isn't consumed, with no estimates involved.
- Payments funded from the signer's balance are over-funded by a
  configurable buffer (costBufferBps, default 10%) to cover upward price
  drift, and the remainder is returned to the sender's address balance
  via 0x2::coin::send_funds (which accepts zero balances) instead of
  asserting an empty coin.
- registerBlob funds storage + write costs with a single coin.
- Aborts in 0x2::balance during payment execution or gas estimation are
  classified as the new retryable StalePriceError, reset the client's
  caches, and are retried once in paths that own transaction
  construction (including the write-blob flow).

Verified on testnet with simulated price drift in both directions
(examples/verify-1127-fix.ts and examples/verify-1127-extend.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hayes-mysten
hayes-mysten temporarily deployed to sui-typescript-aws-kms-test-env July 2, 2026 21:20 — with GitHub Actions Inactive
@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sui-typescript-docs Ready Ready Preview, Comment Jul 2, 2026 11:36pm

Request Review

…etry

- Replace the send-funds intent with a direct PTB call: the remainder is
  sent to the result of 0x2::tx_context::sender, which is resolved
  on-chain at execution, so no sender needs to be set while building.
- When paying from a provided walCoin, split the buffered amount from it
  and merge the remainder back into it, which caps how much can be
  deducted from the source coin.
- Remove the automatic retry on StalePriceError: a rebuilt transaction
  requires a new signature anyways, so the client only resets its price
  cache and surfaces the typed error for callers to handle.
- Default costBufferBps to 10% based on observed on-chain repricing
  (mainnet prices change mid-epoch, with single steps up to +5.8% and
  5-minute windows up to +9.9%).
- Allow examples/funded-keypair.ts to continue past faucet rate limits
  when the balance is still sufficient for gas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/walrus/src/client.ts Outdated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/walrus/src/client.ts Outdated
Address review feedback:

- Export isStalePriceAbort so integrators executing transactions outside
  the client (wallets, sponsors) can detect stale-price aborts and reset
  the client's caches.
- Narrow the client's internal classification using the aborting command
  index: only aborts from walrus payment calls (system::reserve_space,
  register_blob, extend_blob) or coin splits/merges are classified,
  since caller-composed transactions can abort in 0x2::balance for
  unrelated reasons.
- Remove the price-drift verification scripts from the repo; they are
  kept locally for testing. Verified on testnet that both the
  simulation path (gRPC resolves transactions by simulating on the
  node) and the on-chain execution path (JSON-RPC with preset gas
  budget and payment) classify stale-price aborts as StalePriceError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…borts

A caller-composed transaction's own splitCoins over-drawing a coin is
the most common 0x2::balance abort, so blanket-classifying SplitCoins
commands defeated the command narrowing. SplitCoins aborts are now only
classified when the split result is consumed by a walrus payment call
(which covers the buffered walCoin split), and MergeCoins was dropped
since balance::join can't abort with ENotEnough.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@DrVelvetFog

Copy link
Copy Markdown

Read the full diff — yes, this aligns, and in a few places it's better than what I had in mind:

  • The single-coin threading in registerBlob (fund totalCost once, pass the same buffered coin through the nested createStorage and as writePayment) is cleaner than buffering each payment independently.
  • Resolving the refund recipient with an in-PTB tx_context::sender call is the right move — nothing about the fix breaks when the transaction is built unsigned and handed to a wallet or sponsor.
  • The buffer default being justified from actual PricesUpdated history (+5.8% single step, +9.9% worst window) rather than a round number is exactly the kind of thing that survives review later.
  • And with costBufferBps: 0 there's still no assert anywhere, so even exact funding degrades to a classified abort rather than the old hard failure.

One gap worth a decision, because it's the exact surface the production incident shipped through: isStalePriceAbort takes a structured ExecutionError, but on the sponsored path the abort arrived as Enoki's dry_run_failed HTTP 400 with the MoveAbort serialized as text (MoveAbort(MoveLocation { ... name: Identifier("balance") }, 0) in command 9). An executor sitting behind Enoki (or any sponsor that dry-runs server-side) never receives a SuiClientTypes.ExecutionError, so the one flow that actually hit this in production still needs hand-rolled string matching before it can use the classifier — which is how the downstream relayer's auto-heal missed it the first time. Two cheap options: a doc note showing how to map that error shape onto the helper, or letting isStalePriceAbort also accept a raw message string with a conservative matcher (MoveAbort + Identifier("balance") + the 0x2 address or no package). I'd lean toward the second since every sponsor surfaces some flavor of stringified dry-run failure.

Tiny one: #isStalePricePaymentAbort returns true when error.command is null — generous in the right direction for walrus-built transactions, but for caller-composed transactions passed into registerBlobTransaction an unrelated 0x2::balance abort with no command index would classify as stale-price and reset the caches. Probably acceptable (a spurious reset is cheap); may just deserve a sentence in the docstring.

I'll run this branch against my sponsored-upload setup (build → Enoki sponsorship → dry-run) and report back — that's the path where the old behavior was hardest to diagnose, so it's the one worth confirming end to end.

@DrVelvetFog

Copy link
Copy Markdown

Went through the branch — the client-execution side looks right to me: single-coin threading in registerBlob, the buffer defaulted from real PricesUpdated history, and #isStalePricePaymentAbort now discriminating on the command index (incl. the SplitCoins-that-funds-a-payment case) all address what I raised. Ran the unit suite, green.

The one open item from my earlier review is still open, and it's the exact surface the production incident shipped through: isStalePriceAbort is exported for out-of-client callers ("a wallet or a sponsor," per its docstring), but its signature only takes a structured ExecutionError. Sponsored/dry-run paths never get that — Enoki's dry_run_failed hands back the abort as serialized text. So the one caller the export was added for can't actually use it. Quick demonstration with the real string from the incident:

isStalePriceAbort('…MoveAbort(MoveLocation { … name: Identifier("balance") }, 0) in command 9')  // → false

even though that string unambiguously names 0x2::balance.

I put together a small, conservative fix — widen the helper to also accept a raw message string, matching only when it names both the balance module and a 0x2 framework address, so a third-party balance abort doesn't classify. Adds an exported isStalePriceAbortMessage for callers that only ever have text, keeps the structured path untouched, and I added tests covering the real Enoki string plus the negatives (third-party balance, framework coin, non-MoveAbort). It's +34/-1 in error.ts and +32/-1 in the test — happy to push it to this branch or open it as a follow-up PR, whichever you prefer:

export function isStalePriceAbort(
	error: SuiClientTypes.ExecutionError | string | null | undefined,
): boolean {
	if (typeof error === 'string') {
		return isStalePriceAbortMessage(error);
	}
	// ...unchanged structured path...
}

// The framework `balance` module in serialized MoveAbort text: Rust debug form
// (`Identifier("balance")`) or the `0x2::balance` form different transports emit.
const BALANCE_MODULE_TEXT = /Identifier\("balance"\)|0x0*2::balance/;
const FRAMEWORK_ADDRESS_TEXT = /\b0x0*2\b|\b0{63}2\b/;

export function isStalePriceAbortMessage(message: string): boolean {
	if (!message.includes('MoveAbort')) return false;
	return BALANCE_MODULE_TEXT.test(message) && FRAMEWORK_ADDRESS_TEXT.test(message);
}

One design question for you since it's your call on robustness: I kept the matcher strict (must name 0x2 explicitly). If you'd rather it also catch sponsor formats that drop the address qualifier, that's a one-line relax — but I leaned conservative to avoid ever resetting caches on an unrelated abort.

Still owe you the live confirmation against my sponsored-upload path (build → Enoki sponsorship → dry-run) — I'll run that against the branch and report the byte-level result separately.

@hayes-mysten

Copy link
Copy Markdown
Contributor Author

@DrVelvetFog the enoki case is a little tricky, and I am very hesitant about adding a brittle string matching check into the SDK. I can check in about improving the error messages surfaced by enoki to report more details.

I'm not sure what we should be doing in the SDK, but string matching on just 0x2 package methods seems too broad and brittle and may result in a lot of false positives, and feels likely to break with subtle backend changes to error handling

@DrVelvetFog

Copy link
Copy Markdown

Agreed — and having dug into my own relayer, you're right to keep string-matching out of the SDK. Let me withdraw the isStalePriceAbort(string) idea and give the concrete reason it's unsafe, because it's sharper than "brittle in general."

I run the sponsored path through an Enoki gas station with an auto-heal classifier that keys off the dry-run error text. It already treats any MoveAbort naming balance + split as gas-pool exhaustion — the sponsor's SUI gas coin is too fragmented to split for the budget, so 0x2::balance::split aborts ENotEnough (code 2) and the remedy is to rotate pool wallets. But per your own test plan the −stale-price WAL abort is also 0x2::balance::split ENotEnough (code 2). Same module, same function, same code. A matcher keyed on 0x2::balance genuinely can't tell "sponsor is out of gas" from "price moved and the payment under-funded" — mine files the latter as the former and rotates wallets instead of refreshing the price cache. That's the false-positive class you're worried about, and it's real rather than hypothetical.

The only thing that disambiguates them is the command index — which command aborted (the walrus payment split vs. the sponsor's gas-coin split) — which is exactly what #isStalePricePaymentAbort narrows on in the structured path, and exactly what a module-name match throws away. So the structured classifier is the right and only sound design; I shouldn't have suggested weakening it.

That leaves the real gap unchanged, though: isStalePriceAbort is exported for sponsors/wallets, but on that path the abort arrives as Enoki dry_run_failed text, and the text is inconsistent about carrying the command index. Two shapes I've seen:

  • …MoveAbort(MoveLocation { … name: Identifier("balance") }, 0) in command 9 — has it
  • {"code":"dry_run_failed","message":"Dry run failed: MoveAbort(MoveLocation { module: 0x2::balance, function_name: Some(\"split\") }, 2)"} — drops it

So even a caller willing to parse text can't reliably recover the one field that makes classification sound. Which is why fixing it at the Enoki layer is the right call: if dry_run_failed carried the aborting command index (plus module/function/code) as structured fields, the existing isStalePriceAbort(ExecutionError) would just work on the sponsored path with no string-matching anywhere. The SDK keeps its structured contract; the transport-specific mapping (Enoki body → ExecutionError) lives caller-side, which is where my relayer already does this sort of thing.

If it'd help pin the target for the Enoki-side change, I'm happy to capture the exact current dry_run_failed envelope for both stale cases against this branch — including whether it still carries the command index — and write it up as an Enoki issue with the vectors. Just say the word.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants