fix(walrus): make WAL payments resilient to on-chain price changes#1128
fix(walrus): make WAL payments resilient to on-chain price changes#1128hayes-mysten wants to merge 5 commits into
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
|
Read the full diff — yes, this aligns, and in a few places it's better than what I had in mind:
One gap worth a decision, because it's the exact surface the production incident shipped through: Tiny one: 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. |
|
Went through the branch — the client-execution side looks right to me: single-coin threading in The one open item from my earlier review is still open, and it's the exact surface the production incident shipped through: even though that string unambiguously names I put together a small, conservative fix — widen the helper to also accept a raw message string, matching only when it names both the 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 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. |
|
@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 |
|
Agreed — and having dug into my own relayer, you're right to keep string-matching out of the SDK. Let me withdraw the 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 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 That leaves the real gap unchanged, though:
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 If it'd help pin the target for the Enoki-side change, I'm happy to capture the exact current |
Description
Fixes #1127.
@mysten/walruspre-funded every WAL payment as exact change computed from a cachedsystemState()price, then asserted the payment coin was empty with0x2::coin::destroy_zero. Any change instorage_price_per_unit_size/write_price_per_unit_sizebetween 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 publicstaking::update_prices, which writes straight into theSystemobject), 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.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.coinWithBalance, and the remainder is sent to the sender's address balance via0x2::coin::send_funds(which accepts zero balances, so nothing asserts). The recipient is the result of a0x2::tx_context::sendercall 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.registerBlobfunds storage + write costs with a single coin threaded through the nestedcreateStorage.SimulationError— the gRPC client resolves transactions by simulating on the node, so aborts usually appear here) or from an executedFailedTransaction(reachable when gas is preset, or on non-simulating transports). Classification checks for aMoveAbortin0x2::balanceand, when the aborting command index is available, requires it to be a command added for payments (a walrussystem::reserve_space/register_blob/extend_blobcall 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 exportedStalePriceError(extendsRetryableWalrusClientError). 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.isStalePriceAbortis exported for transactions executed outside the client (wallets, sponsors): feed it the execution error, and callclient.reset()to refresh prices.Buffer default
Historical
PricesUpdatedevents (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: 0restores exact funding.Behavior notes
walCoin, estimate + buffer is split from the coin and the unspent remainder merged back, instead of "exactly the estimate or abort" (docstrings updated).coinWithBalancesources from address balances, so they're fully spendable by subsequent writes.Test plan
isStalePriceAbort; full walrus unit suite passes (74/74).mainagainst testnet by monkey-patchingsystemState()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.writeBlobandextendBlob:send_funds+tx_context::sender).StalePriceError, exactly one cache reset observed, and a new attempt on the same client succeeds with freshly loaded prices (no auto-retry).walCoinwith +2% stale prices → succeeds; only the actual cost (205947 FROST) deducted from the coin, remainder merged back.SuiJsonRpcClientwith preset gas budget + payment (no dry-run), the stale-price abort executed on-chain and the resultingFailedTransactionwas classified asStalePriceError(digestFdZuMoqgotvfs7jrSzFX8zLY6fFyxrGSRb2kReCtugN5), confirming the command-index narrowing works against real effects.pnpm turbo build --filter=@mysten/walrus, lint, prettier, and docs validation all pass.AI Assistance Notice
🤖 Generated with Claude Code