From 56c25812fc927d779eb8c48492bdcb8065194636 Mon Sep 17 00:00:00 2001 From: Michael Hayes Date: Thu, 2 Jul 2026 12:28:14 -0700 Subject: [PATCH 1/5] fix(walrus): make WAL payments resilient to on-chain price changes 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 (MystenLabs/ts-sdks#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 --- .changeset/tidy-pandas-repeat.md | 12 ++ packages/docs/content/walrus/index.mdx | 7 + .../walrus/examples/verify-1127-extend.ts | 117 +++++++++++ packages/walrus/examples/verify-1127-fix.ts | 181 +++++++++++++++++ packages/walrus/src/client.ts | 185 ++++++++++++------ packages/walrus/src/error.ts | 8 + packages/walrus/src/flows/write-blob.ts | 9 +- packages/walrus/src/types.ts | 17 +- packages/walrus/src/utils/send-funds.ts | 77 ++++++++ packages/walrus/test/unit/send-funds.test.ts | 60 ++++++ 10 files changed, 608 insertions(+), 65 deletions(-) create mode 100644 .changeset/tidy-pandas-repeat.md create mode 100644 packages/walrus/examples/verify-1127-extend.ts create mode 100644 packages/walrus/examples/verify-1127-fix.ts create mode 100644 packages/walrus/src/utils/send-funds.ts create mode 100644 packages/walrus/test/unit/send-funds.test.ts diff --git a/.changeset/tidy-pandas-repeat.md b/.changeset/tidy-pandas-repeat.md new file mode 100644 index 000000000..38d07285f --- /dev/null +++ b/.changeset/tidy-pandas-repeat.md @@ -0,0 +1,12 @@ +--- +'@mysten/walrus': minor +--- + +Make WAL payments resilient to on-chain price changes. Payment coins are no longer split as exact +change and destroyed with `destroy_zero` (which aborted whenever storage or write prices changed +between cost estimation and execution). Instead, provided `walCoin` sources are used directly (only +the actual on-chain cost is deducted), and payments funded from the signer's balance include a +configurable buffer (`costBufferBps`, default 10%) with any unspent WAL returned to the signer's +address balance. Payment aborts caused by stale cached prices now reset the client's caches, are +retried once automatically, and surface as the new `StalePriceError` (a +`RetryableWalrusClientError`). diff --git a/packages/docs/content/walrus/index.mdx b/packages/docs/content/walrus/index.mdx index c2c66bae1..83838a6f1 100644 --- a/packages/docs/content/walrus/index.mdx +++ b/packages/docs/content/walrus/index.mdx @@ -174,6 +174,13 @@ the blob. The exact costs depend on the size of the blobs, as well as the current gas and storage prices. +Because storage and write prices can change between when costs are estimated and when a transaction +executes, the client funds payments with a small buffer on top of the estimated cost (10% by +default, configurable with the `costBufferBps` client option). Any WAL that isn't consumed by the +payment is automatically returned to the signer's address balance, so only the actual on-chain cost +is spent. If prices increase by more than the buffer, the transaction is retried once with freshly +loaded prices, and a `StalePriceError` is thrown if it still fails. + ```ts const results: { id: string; diff --git a/packages/walrus/examples/verify-1127-extend.ts b/packages/walrus/examples/verify-1127-extend.ts new file mode 100644 index 000000000..3f68951ac --- /dev/null +++ b/packages/walrus/examples/verify-1127-extend.ts @@ -0,0 +1,117 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Verifies the extendBlob payment path against simulated price drift (issue #1127). +// Writes a small blob, then extends it twice: once with the cached price inflated +// +2% (previously aborted in destroy_zero) and once deflated -15% + stale-until-reset +// (exercises StalePriceError + retry in executeExtendBlobTransaction). + +import { SuiGrpcClient } from '@mysten/sui/grpc'; +import { Agent, setGlobalDispatcher } from 'undici'; + +import { walrus } from '../src/client.js'; +import { getFundedKeypair } from './funded-keypair.js'; + +setGlobalDispatcher( + new Agent({ + connectTimeout: 60_000, + connect: { timeout: 60_000 }, + }), +); + +function makeClient() { + return new SuiGrpcClient({ + network: 'testnet', + baseUrl: 'https://fullnode.testnet.sui.io:443', + }).$extend( + walrus({ + storageNodeClientOptions: { + timeout: 60_000, + }, + }), + ); +} + +function patchSystemStatePrice( + client: ReturnType, + numer: bigint, + denom: bigint, +) { + const walrusClient = client.walrus; + const real = walrusClient.systemState.bind(walrusClient); + const realReset = walrusClient.reset.bind(walrusClient); + const state = { stale: true, resetCalls: 0 }; + + (walrusClient as any).reset = () => { + state.resetCalls += 1; + state.stale = false; + realReset(); + }; + + (walrusClient as any).systemState = async () => { + const s = structuredClone(await real()); + if (!state.stale) { + return s; + } + s.storage_price_per_unit_size = String((BigInt(s.storage_price_per_unit_size) * numer) / denom); + s.write_price_per_unit_size = String((BigInt(s.write_price_per_unit_size) * numer) / denom); + return s; + }; + + return state; +} + +async function main() { + const keypair = await getFundedKeypair(); + let failures = 0; + + console.log('writing a blob to extend...'); + const setupClient = makeClient(); + const { blobObject } = await setupClient.walrus.writeBlob({ + blob: new TextEncoder().encode('verify 1127 extend ' + Date.now()), + epochs: 1, + deletable: false, + signer: keypair, + }); + console.log('blob object:', blobObject.id, 'end epoch:', blobObject.storage.end_epoch); + + console.log('\n=== extend with cached price +2% (expect success) ==='); + try { + const client = makeClient(); + patchSystemStatePrice(client, 102n, 100n); + const { digest } = await client.walrus.executeExtendBlobTransaction({ + blobObjectId: blobObject.id, + epochs: 1, + signer: keypair, + }); + console.log(' SUCCESS, digest:', digest); + } catch (err) { + failures += 1; + console.log(' FAILED:', (err as Error).message); + } + + console.log('\n=== extend with cached price -15%, stale until reset (expect retry, success) ==='); + try { + const client = makeClient(); + const state = patchSystemStatePrice(client, 85n, 100n); + const { digest } = await client.walrus.executeExtendBlobTransaction({ + blobObjectId: blobObject.id, + epochs: 1, + signer: keypair, + }); + console.log(' SUCCESS, digest:', digest); + console.log(' reset() calls (should be >= 1):', state.resetCalls); + if (state.resetCalls === 0) { + failures += 1; + console.log(' UNEXPECTED: succeeded without hitting the retry path'); + } + } catch (err) { + failures += 1; + console.log(' FAILED:', (err as Error).message); + } + + console.log(failures === 0 ? '\nALL EXTEND SCENARIOS PASSED' : `\n${failures} FAILED`); + process.exit(failures === 0 ? 0 : 1); +} + +main(); diff --git a/packages/walrus/examples/verify-1127-fix.ts b/packages/walrus/examples/verify-1127-fix.ts new file mode 100644 index 000000000..8546f46b2 --- /dev/null +++ b/packages/walrus/examples/verify-1127-fix.ts @@ -0,0 +1,181 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Verification for the fix to issue #1127 (stale-price WAL payments). +// +// Simulates a stale cached price by monkey-patching systemState() on the client +// instance and confirms: +// A. cached price HIGHER than actual -> succeeds; remainder goes to the +// sender's address balance instead of aborting in destroy_zero. +// B. cached price LOWER than actual (beyond the cost buffer, stale until +// reset()) -> first attempt aborts, is classified as StalePriceError, +// caches reset, and the write-blob flow retries and succeeds. +// C. cached price LOWER than actual but within the default 10% cost buffer +// -> succeeds on the first attempt. +// D. control run without overrides -> succeeds; the unused buffer is +// returned to the sender's address balance. + +import { SuiGrpcClient } from '@mysten/sui/grpc'; +import { Agent, setGlobalDispatcher } from 'undici'; + +import { walrus } from '../src/client.js'; +import { getFundedKeypair } from './funded-keypair.js'; + +setGlobalDispatcher( + new Agent({ + connectTimeout: 60_000, + connect: { timeout: 60_000 }, + }), +); + +const WAL_TYPE = '0x8270feb7375eee355e64fdb69c50abb6b5f9393a722883c1cf45f8e26048810a::wal::WAL'; + +function makeClient() { + return new SuiGrpcClient({ + network: 'testnet', + baseUrl: 'https://fullnode.testnet.sui.io:443', + }).$extend( + walrus({ + storageNodeClientOptions: { + timeout: 60_000, + }, + }), + ); +} + +/** + * Simulate a stale cache: systemState() reports prices scaled by numer/denom + * until client.reset() is called (like a real stale cache, which is cleared by + * reset). Returns counters for observing what happened. + */ +function patchSystemStatePrice( + client: ReturnType, + numer: bigint, + denom: bigint, +) { + const walrusClient = client.walrus; + const real = walrusClient.systemState.bind(walrusClient); + const realReset = walrusClient.reset.bind(walrusClient); + const state = { stale: true, resetCalls: 0 }; + + (walrusClient as any).reset = () => { + state.resetCalls += 1; + state.stale = false; + console.log(' client.reset() called -> cache now returns real prices'); + realReset(); + }; + + (walrusClient as any).systemState = async () => { + const s = structuredClone(await real()); + if (!state.stale) { + return s; + } + s.storage_price_per_unit_size = String((BigInt(s.storage_price_per_unit_size) * numer) / denom); + s.write_price_per_unit_size = String((BigInt(s.write_price_per_unit_size) * numer) / denom); + return s; + }; + + return state; +} + +async function walBalance(owner: string) { + const suiClient = new SuiGrpcClient({ + network: 'testnet', + baseUrl: 'https://fullnode.testnet.sui.io:443', + }); + const { balance } = await suiClient.getBalance({ owner, coinType: WAL_TYPE }); + return { total: BigInt(balance.balance), address: BigInt(balance.addressBalance) }; +} + +async function writeSmallBlob( + client: ReturnType, + keypair: Awaited>, + tag: string, +) { + const blob = new TextEncoder().encode('verify 1127 fix ' + tag + ' ' + Date.now()); + return client.walrus.writeBlob({ + blob, + epochs: 1, + deletable: true, + signer: keypair, + }); +} + +async function main() { + const keypair = await getFundedKeypair(); + const address = keypair.toSuiAddress(); + console.log('address:', address); + console.log('WAL balance:', await walBalance(address)); + + let failures = 0; + + // Scenario A: cached price INFLATED +2%. Previously aborted in destroy_zero. + console.log('\n=== A: cached price +2% (expect success, remainder to address balance) ==='); + try { + const client = makeClient(); + patchSystemStatePrice(client, 102n, 100n); + const before = await walBalance(address); + const res = await writeSmallBlob(client, keypair, 'inflated'); + const after = await walBalance(address); + console.log(' SUCCESS, blobId:', res.blobId); + console.log(' WAL spent:', before.total - after.total); + console.log(' address balance delta:', after.address - before.address); + } catch (err) { + failures += 1; + console.log(' FAILED:', (err as Error).message); + } + + // Scenario B: cached price DEFLATED -15% (beyond the 10% buffer), stale until reset. + console.log('\n=== B: cached price -15%, stale until reset (expect retry, then success) ==='); + try { + const client = makeClient(); + const state = patchSystemStatePrice(client, 85n, 100n); + const res = await writeSmallBlob(client, keypair, 'deflated-beyond-buffer'); + console.log(' SUCCESS, blobId:', res.blobId); + console.log(' reset() calls (should be >= 1, proving retry path):', state.resetCalls); + if (state.resetCalls === 0) { + failures += 1; + console.log(' UNEXPECTED: succeeded without hitting the retry path'); + } + } catch (err) { + failures += 1; + console.log(' FAILED:', (err as Error).message); + } + + // Scenario C: cached price DEFLATED -0.5% (within the default 10% buffer). + console.log('\n=== C: cached price -0.5%, within buffer (expect success, no retry) ==='); + try { + const client = makeClient(); + const state = patchSystemStatePrice(client, 995n, 1000n); + const res = await writeSmallBlob(client, keypair, 'deflated-within-buffer'); + console.log(' SUCCESS, blobId:', res.blobId); + console.log(' reset() calls (should be 0):', state.resetCalls); + if (state.resetCalls > 0) { + failures += 1; + console.log(' UNEXPECTED: retry path was hit'); + } + } catch (err) { + failures += 1; + console.log(' FAILED:', (err as Error).message); + } + + // Control: no overrides. + console.log('\n=== D: control, no override (expect success) ==='); + try { + const client = makeClient(); + const before = await walBalance(address); + const res = await writeSmallBlob(client, keypair, 'control'); + const after = await walBalance(address); + console.log(' SUCCESS, blobId:', res.blobId); + console.log(' WAL spent:', before.total - after.total); + console.log(' address balance delta:', after.address - before.address); + } catch (err) { + failures += 1; + console.log(' FAILED:', (err as Error).message); + } + + console.log(failures === 0 ? '\nALL SCENARIOS PASSED' : `\n${failures} SCENARIO(S) FAILED`); + process.exit(failures === 0 ? 0 : 1); +} + +main(); diff --git a/packages/walrus/src/client.ts b/packages/walrus/src/client.ts index e3b7301fa..180bab3b8 100644 --- a/packages/walrus/src/client.ts +++ b/packages/walrus/src/client.ts @@ -4,10 +4,11 @@ import type { InferBcsType } from '@mysten/bcs'; import { bcs } from '@mysten/bcs'; import type { Signer } from '@mysten/sui/cryptography'; -import type { ClientCache, ClientWithCoreApi } from '@mysten/sui/client'; +import type { ClientCache, ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; +import { SimulationError } from '@mysten/sui/client'; import type { TransactionObjectArgument, TransactionResult } from '@mysten/sui/transactions'; import { coinWithBalance, Transaction } from '@mysten/sui/transactions'; -import { normalizeStructTag, parseStructTag } from '@mysten/sui/utils'; +import { normalizeStructTag, normalizeSuiAddress, parseStructTag } from '@mysten/sui/utils'; import { MAINNET_WALRUS_PACKAGE_CONFIG, @@ -47,6 +48,7 @@ import { NotEnoughSliversReceivedError, NoVerifiedBlobStatusReceivedError, RetryableWalrusClientError, + StalePriceError, WalrusClientError, } from './error.js'; import { StorageNodeClient } from './storage-node/client.js'; @@ -118,6 +120,7 @@ import { toShardIndex, } from './utils/index.js'; import { SuiObjectDataLoader } from './utils/object-loader.js'; +import { sendFundsToSender } from './utils/send-funds.js'; import { shuffle, weightedShuffle } from './utils/randomness.js'; import { getWasmBindings } from './wasm.js'; import { chunk } from '@mysten/utils'; @@ -173,6 +176,7 @@ export class WalrusClient { #objectLoader: SuiObjectDataLoader; #blobMetadataConcurrencyLimit = 10; + #costBufferBps: bigint; #readCommittee?: CommitteeInfo | Promise | null; #cache: ClientCache; @@ -199,6 +203,13 @@ export class WalrusClient { this.#wasmUrl = config.wasmUrl; this.#storageNodeUrlScheme = config.storageNodeUrlScheme ?? 'https'; + const costBufferBps = config.costBufferBps ?? 1_000; + if (!Number.isSafeInteger(costBufferBps) || costBufferBps < 0) { + throw new WalrusClientError( + `costBufferBps must be a non-negative integer, got ${costBufferBps}`, + ); + } + this.#costBufferBps = BigInt(costBufferBps); this.#uploadRelayConfig = config.uploadRelay ?? null; if (this.#uploadRelayConfig) { this.#uploadRelayClient = new UploadRelayClient(this.#uploadRelayConfig); @@ -771,23 +782,28 @@ export class WalrusClient { fn: (coin: TransactionObjectArgument, tx: Transaction) => T | Promise, ) { return async (tx: Transaction): Promise => { + // Payment coins are passed by mutable reference, and only the current on-chain cost + // is deducted, so a provided source coin can be used directly and keeps whatever + // isn't consumed. + if (source) { + return await fn(source, tx); + } + const walType = await this.#walType(); - const coin = source - ? tx.splitCoins(source, [amount])[0] - : tx.add( - coinWithBalance({ - balance: amount, - type: walType, - }), - ); + // `amount` is estimated from cached prices, so fund slightly more than the estimate + // to cover on-chain price increases. + const coin = tx.add( + coinWithBalance({ + balance: amount + (amount * this.#costBufferBps) / 10_000n, + type: walType, + }), + ); const result = await fn(coin, tx); - tx.moveCall({ - target: '0x2::coin::destroy_zero', - typeArguments: [walType], - arguments: [coin], - }); + // The amount deducted on-chain may not match the estimate, so instead of asserting + // an empty coin, return the remainder to the sender's address balance. + tx.add(sendFundsToSender({ coin, coinType: walType })); return result; }; @@ -828,17 +844,19 @@ export class WalrusClient { signer, ...options }: StorageWithSizeOptions & { transaction?: Transaction; signer: Signer }) { - const transaction = this.createStorageTransaction({ - ...options, - owner: options.transaction?.getData().sender ?? signer.toSuiAddress(), - }); const blobType = await this.getBlobType(); - const { digest, effects } = await this.#executeTransaction( - transaction, - signer, - 'create storage', - ); + const execute = () => + this.#executeTransaction( + this.createStorageTransaction({ + ...options, + owner: options.transaction?.getData().sender ?? signer.toSuiAddress(), + }), + signer, + 'create storage', + ); + + const { digest, effects } = await this.#executeWithRetry(options.transaction, execute); const createdObjectIds = effects?.changedObjects .filter((object) => object.idOperation === 'Created') @@ -883,23 +901,25 @@ export class WalrusClient { attributes, }: RegisterBlobOptions) { return async (tx: Transaction) => { - const { writeCost } = await this.storageCost(size, epochs); + const { totalCost } = await this.storageCost(size, epochs); const walrusPackageId = await this.#getWalrusPackageId(); + // A single coin covers both the storage and write payments, so it is funded with + // the total cost and passed to `createStorage` as the `walCoin` source. return tx.add( - this.#withWal(writeCost, walCoin ?? null, async (writeCoin, tx) => { + this.#withWal(totalCost, walCoin ?? null, async (coin, tx) => { const blob = tx.add( registerBlob({ package: walrusPackageId, arguments: { self: tx.object(this.#packageConfig.systemObjectId), - storage: this.createStorage({ size, epochs, walCoin }), + storage: this.createStorage({ size, epochs, walCoin: coin }), blobId: blobIdToInt(blobId), rootHash: BigInt(bcs.u256().parse(rootHash)), size, encodingType: 1, deletable, - writePayment: writeCoin, + writePayment: coin, }, }), ); @@ -1064,16 +1084,19 @@ export class WalrusClient { blob: (typeof Blob)['$inferType']; digest: string; }> { - const transaction = this.registerBlobTransaction({ - ...options, - owner: options.owner ?? options.transaction?.getData().sender ?? signer.toSuiAddress(), - }); const blobType = await this.getBlobType(); - const { digest, effects } = await this.#executeTransaction( - transaction, - signer, - 'register blob', - ); + + const execute = () => + this.#executeTransaction( + this.registerBlobTransaction({ + ...options, + owner: options.owner ?? options.transaction?.getData().sender ?? signer.toSuiAddress(), + }), + signer, + 'register blob', + ); + + const { digest, effects } = await this.#executeWithRetry(options.transaction, execute); const createdObjectIds = effects?.changedObjects .filter((object) => object.idOperation === 'Created') @@ -1424,11 +1447,10 @@ export class WalrusClient { signer, ...options }: ExtendBlobOptions & { signer: Signer; transaction?: Transaction }) { - const { digest } = await this.#executeTransaction( - await this.extendBlobTransaction(options), - signer, - 'extend blob', - ); + const execute = async () => + this.#executeTransaction(await this.extendBlobTransaction(options), signer, 'extend blob'); + + const { digest } = await this.#executeWithRetry(options.transaction, execute); return { digest }; } @@ -2064,18 +2086,51 @@ export class WalrusClient { return encoded; } + // WAL payment amounts are estimated from cached prices, so an abort in `0x2::balance` + // (an underfunded payment split, or a non-zero remainder in older transactions) usually + // means the cached prices are stale. + #isStalePriceAbort(error: SuiClientTypes.ExecutionError | null | undefined) { + if (error?.$kind !== 'MoveAbort') { + return false; + } + + const location = error.MoveAbort.location; + + return ( + location?.module === 'balance' && + (!location.package || normalizeSuiAddress(location.package) === normalizeSuiAddress('0x2')) + ); + } + async #executeTransaction(transaction: Transaction, signer: Signer, action: string) { transaction.setSenderIfNotSet(signer.toSuiAddress()); - const result = await signer.signAndExecuteTransaction({ - transaction, - client: this.#suiClient, - }); + let result; + try { + result = await signer.signAndExecuteTransaction({ + transaction, + client: this.#suiClient, + }); + } catch (error) { + // Gas budget resolution simulates the transaction, so payment aborts usually + // surface here rather than from an executed transaction. + if (error instanceof SimulationError && this.#isStalePriceAbort(error.executionError)) { + this.reset(); + throw new StalePriceError(`Failed to ${action}: ${error.message}`, { cause: error }); + } + throw error; + } if (result.FailedTransaction) { - throw new WalrusClientError( - `Failed to ${action} (${result.FailedTransaction.digest}): ${result.FailedTransaction.status.error?.message}`, - ); + const { digest, status } = result.FailedTransaction; + const message = `Failed to ${action} (${digest}): ${status.error?.message}`; + + if (this.#isStalePriceAbort(status.error)) { + this.reset(); + throw new StalePriceError(message); + } + + throw new WalrusClientError(message); } const { digest, effects } = result.Transaction; @@ -2150,18 +2205,27 @@ export class WalrusClient { this.#cache.clear(); } - #retryOnPossibleEpochChange Promise>(fn: T): T { - return (async (...args: Parameters) => { - try { - return await fn.apply(this, args); - } catch (error) { - if (error instanceof RetryableWalrusClientError) { - this.reset(); - return await fn.apply(this, args); - } - throw error; + async #retryOnRetryableError(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if (error instanceof RetryableWalrusClientError) { + this.reset(); + return await fn(); } - }) as T; + throw error; + } + } + + // Retrying re-executes `fn`, which re-adds commands to the transaction it executes, so + // this only retries when the caller didn't provide their own transaction. + #executeWithRetry(callerTransaction: Transaction | undefined, fn: () => Promise) { + return callerTransaction ? fn() : this.#retryOnRetryableError(fn); + } + + #retryOnPossibleEpochChange Promise>(fn: T): T { + return ((...args: Parameters) => + this.#retryOnRetryableError(() => fn.apply(this, args))) as T; } async getBlob({ blobId }: { blobId: string }) { @@ -2291,6 +2355,7 @@ export class WalrusClient { hasUploadRelay: () => !!this.#uploadRelayClient, executeTransaction: (transaction, signer, action) => this.#executeTransaction(transaction, signer, action), + retryOnRetryableError: (fn) => this.#retryOnRetryableError(fn), getCreatedBlob: (digest) => this.#getCreatedBlob(digest), loadBlobObject: (objectId) => this.#objectLoader.load(objectId, Blob), }; diff --git a/packages/walrus/src/error.ts b/packages/walrus/src/error.ts index 90371633a..bbcd71aad 100644 --- a/packages/walrus/src/error.ts +++ b/packages/walrus/src/error.ts @@ -29,5 +29,13 @@ export class BlobNotCertifiedError extends RetryableWalrusClientError {} /** Thrown when a blob was determined to be incorrectly encoded. */ export class InconsistentBlobError extends WalrusClientError {} +/** + * Thrown when a transaction aborts in a way that usually indicates the WAL payment was + * computed from stale cached storage prices (it can also indicate an insufficient WAL balance). + * The client's caches are reset before this error is thrown, so retrying will rebuild the + * transaction with fresh prices. + */ +export class StalePriceError extends RetryableWalrusClientError {} + /** Thrown when blob is blocked by a quorum of storage nodes. */ export class BlobBlockedError extends Error {} diff --git a/packages/walrus/src/flows/write-blob.ts b/packages/walrus/src/flows/write-blob.ts index 63ef7dbff..6f875db39 100644 --- a/packages/walrus/src/flows/write-blob.ts +++ b/packages/walrus/src/flows/write-blob.ts @@ -32,6 +32,8 @@ export interface WriteBlobFlowContext { signer: Signer, action: string, ): Promise<{ digest: string }>; + /** Runs `fn`, and retries it once with reset caches if it throws a retryable error. */ + retryOnRetryableError(fn: () => Promise): Promise; getCreatedBlob(digest: string): Promise<(typeof Blob)['$inferType']>; loadBlobObject(objectId: string): Promise<(typeof Blob)['$inferType']>; } @@ -246,8 +248,11 @@ export function createWriteBlobFlow( signer, ...options }: WriteBlobFlowRegisterOptions & { signer: Signer }): Promise => { - const transaction = register(options); - const { digest } = await ctx.executeTransaction(transaction, signer, 'register blob'); + // A retry builds a new registration transaction, so it will use fresh on-chain state + // after errors like StalePriceError reset the client's caches. + const { digest } = await ctx.retryOnRetryableError(() => + ctx.executeTransaction(register(options), signer, 'register blob'), + ); return { step: 'registered' as const, diff --git a/packages/walrus/src/types.ts b/packages/walrus/src/types.ts index e6075e232..9681dc3ee 100644 --- a/packages/walrus/src/types.ts +++ b/packages/walrus/src/types.ts @@ -77,6 +77,17 @@ interface BaseWalrusClientConfig { * terminate TLS. */ storageNodeUrlScheme?: 'http' | 'https'; + /** + * Extra WAL added on top of estimated storage and write costs (in basis points of the + * estimated cost) when funding payments from the signer's balance. This protects against + * on-chain price increases between cost estimation and execution. Any WAL that isn't + * consumed by the payment is returned to the sender's address balance, so the buffer is + * only spent if prices actually increased. Defaults to `1000` (10%). + * + * This does not apply when an explicit `walCoin` is provided, since payments are deducted + * directly from that coin. + */ + costBufferBps?: number; } /** @@ -118,7 +129,7 @@ export interface StorageWithSizeOptions { size: number; /** The number of epoch the storage will be reserved for. */ epochs: number; - /** optionally specify a WAL coin pay for the registration. This will consume WAL from the signer by default. */ + /** Optionally specify a WAL coin to pay from. The actual on-chain cost is deducted directly from this coin. By default WAL is consumed from the signer's balance instead. */ walCoin?: TransactionObjectArgument; } @@ -126,7 +137,7 @@ export interface RegisterBlobOptions extends StorageWithSizeOptions { blobId: string; rootHash: Uint8Array; deletable: boolean; - /** optionally specify a WAL coin pay for the registration. This will consume WAL from the signer by default. */ + /** Optionally specify a WAL coin to pay from. The actual on-chain cost is deducted directly from this coin. By default WAL is consumed from the signer's balance instead. */ walCoin?: TransactionObjectArgument; /** The attributes to write for the blob. */ attributes?: Record; @@ -384,7 +395,7 @@ export interface DeleteBlobOptions { export type ExtendBlobOptions = { blobObjectId: string; - /** optionally specify a WAL coin pay for the registration. This will consume WAL from the signer by default. */ + /** Optionally specify a WAL coin to pay from. The actual on-chain cost is deducted directly from this coin. By default WAL is consumed from the signer's balance instead. */ walCoin?: TransactionObjectArgument; } & ( | { diff --git a/packages/walrus/src/utils/send-funds.ts b/packages/walrus/src/utils/send-funds.ts new file mode 100644 index 000000000..2fd06ef01 --- /dev/null +++ b/packages/walrus/src/utils/send-funds.ts @@ -0,0 +1,77 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { bcs } from '@mysten/sui/bcs'; +import type { + Argument, + BuildTransactionOptions, + Transaction, + TransactionDataBuilder, + TransactionResult, +} from '@mysten/sui/transactions'; +import { Inputs, TransactionCommands } from '@mysten/sui/transactions'; + +const SEND_FUNDS_TO_SENDER = 'WalrusSendFundsToSender'; + +/** + * Sends the remaining balance of `coin` to the transaction sender's address balance. + * + * The sender isn't known until the transaction is built, so this adds an intent that is + * resolved to a `0x2::coin::send_funds` call (which accepts zero balances) at build time. + */ +export function sendFundsToSender({ + coin, + coinType, +}: { + coin: TransactionResult; + coinType: string; +}) { + return (tx: Transaction) => { + tx.addIntentResolver(SEND_FUNDS_TO_SENDER, resolveSendFundsToSender); + tx.add( + TransactionCommands.Intent({ + name: SEND_FUNDS_TO_SENDER, + inputs: { coin }, + data: { coinType }, + }), + ); + }; +} + +async function resolveSendFundsToSender( + transactionData: TransactionDataBuilder, + _buildOptions: BuildTransactionOptions, + next: () => Promise, +) { + // Executors and wallets may prepare transactions before a sender is set. The intent is + // left unresolved in that case, and is resolved when the transaction is built (which + // requires a sender for the coinWithBalance intent this is always paired with anyways). + if (!transactionData.sender) { + return next(); + } + + for (const [index, command] of transactionData.commands.entries()) { + if (command.$kind !== '$Intent' || command.$Intent.name !== SEND_FUNDS_TO_SENDER) { + continue; + } + + const { coinType } = command.$Intent.data as { coinType: string }; + + transactionData.replaceCommand( + index, + TransactionCommands.MoveCall({ + target: '0x2::coin::send_funds', + typeArguments: [coinType], + arguments: [ + command.$Intent.inputs.coin as Argument, + transactionData.addInput( + 'pure', + Inputs.Pure(bcs.Address.serialize(transactionData.sender)), + ), + ], + }), + ); + } + + return next(); +} diff --git a/packages/walrus/test/unit/send-funds.test.ts b/packages/walrus/test/unit/send-funds.test.ts new file mode 100644 index 000000000..6d9bfdab7 --- /dev/null +++ b/packages/walrus/test/unit/send-funds.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { bcs } from '@mysten/sui/bcs'; +import { Transaction } from '@mysten/sui/transactions'; +import { describe, expect, it } from 'vitest'; + +import { sendFundsToSender } from '../../src/utils/send-funds.js'; + +const SENDER = '0x2fe12c9e2ab4e4d5ba484212a0e0f4c92eef6a6bcd50cb03e1a3b8bea4a4d267'; +const WAL_TYPE = '0x8270feb7375eee355e64fdb69c50abb6b5f9393a722883c1cf45f8e26048810a::wal::WAL'; + +describe('sendFundsToSender', () => { + it('resolves to a coin::send_funds call with the sender address', async () => { + const tx = new Transaction(); + tx.setSender(SENDER); + + const coin = tx.splitCoins(tx.gas, [100n]); + tx.add(sendFundsToSender({ coin, coinType: WAL_TYPE })); + + await tx.prepareForSerialization({}); + + const commands = tx.getData().commands; + const moveCall = commands.find((command) => command.$kind === 'MoveCall')?.MoveCall; + + expect(moveCall).toMatchObject({ + package: '0x0000000000000000000000000000000000000000000000000000000000000002', + module: 'coin', + function: 'send_funds', + typeArguments: [WAL_TYPE], + }); + + expect(moveCall?.arguments[0]).toMatchObject({ $kind: 'Result', Result: 0 }); + + const recipient = moveCall?.arguments[1]; + expect(recipient?.$kind).toBe('Input'); + const input = tx.getData().inputs[(recipient as { Input: number }).Input]; + expect(input.Pure?.bytes).toBe(bcs.Address.serialize(SENDER).toBase64()); + }); + + it('stays unresolved until a sender is set', async () => { + const tx = new Transaction(); + const coin = tx.splitCoins(tx.gas, [100n]); + tx.add(sendFundsToSender({ coin, coinType: WAL_TYPE })); + + // Executors prepare transactions before setting a sender, so the intent is deferred. + await tx.prepareForSerialization({}); + expect( + tx.getData().commands.some((command) => command.$Intent?.name === 'WalrusSendFundsToSender'), + ).toBe(true); + + tx.setSender(SENDER); + await tx.prepareForSerialization({}); + + const moveCall = tx + .getData() + .commands.find((command) => command.$kind === 'MoveCall')?.MoveCall; + expect(moveCall?.function).toBe('send_funds'); + }); +}); From 3d39c74489ccae2cd1c5d6f6f3bff3601d941be7 Mon Sep 17 00:00:00 2001 From: Michael Hayes Date: Thu, 2 Jul 2026 14:55:13 -0700 Subject: [PATCH 2/5] refactor(walrus): return payment remainders without intents or auto-retry - 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 --- .changeset/tidy-pandas-repeat.md | 12 +- packages/docs/content/walrus/index.mdx | 4 +- packages/walrus/examples/funded-keypair.ts | 17 ++- .../walrus/examples/verify-1127-extend.ts | 36 ++++-- packages/walrus/examples/verify-1127-fix.ts | 62 ++++++++-- packages/walrus/src/client.ts | 108 ++++++++---------- packages/walrus/src/error.ts | 4 +- packages/walrus/src/flows/write-blob.ts | 8 +- packages/walrus/src/types.ts | 18 ++- packages/walrus/src/utils/send-funds.ts | 77 ------------- packages/walrus/test/unit/send-funds.test.ts | 60 ---------- 11 files changed, 160 insertions(+), 246 deletions(-) delete mode 100644 packages/walrus/src/utils/send-funds.ts delete mode 100644 packages/walrus/test/unit/send-funds.test.ts diff --git a/.changeset/tidy-pandas-repeat.md b/.changeset/tidy-pandas-repeat.md index 38d07285f..17adf706b 100644 --- a/.changeset/tidy-pandas-repeat.md +++ b/.changeset/tidy-pandas-repeat.md @@ -4,9 +4,9 @@ Make WAL payments resilient to on-chain price changes. Payment coins are no longer split as exact change and destroyed with `destroy_zero` (which aborted whenever storage or write prices changed -between cost estimation and execution). Instead, provided `walCoin` sources are used directly (only -the actual on-chain cost is deducted), and payments funded from the signer's balance include a -configurable buffer (`costBufferBps`, default 10%) with any unspent WAL returned to the signer's -address balance. Payment aborts caused by stale cached prices now reset the client's caches, are -retried once automatically, and surface as the new `StalePriceError` (a -`RetryableWalrusClientError`). +between cost estimation and execution). Payments now fund the estimated cost plus a configurable +buffer (`costBufferBps`, default 10%), and since the walrus contracts only deduct the actual +on-chain cost, any unspent WAL is merged back into the provided `walCoin`, or returned to the +signer's address balance when paying from the signer's balance. Payment aborts caused by stale +cached prices reset the client's caches and surface as the new `StalePriceError` (a +`RetryableWalrusClientError`), so transactions built after the error use freshly loaded prices. diff --git a/packages/docs/content/walrus/index.mdx b/packages/docs/content/walrus/index.mdx index 83838a6f1..280d2a3e0 100644 --- a/packages/docs/content/walrus/index.mdx +++ b/packages/docs/content/walrus/index.mdx @@ -178,8 +178,8 @@ Because storage and write prices can change between when costs are estimated and executes, the client funds payments with a small buffer on top of the estimated cost (10% by default, configurable with the `costBufferBps` client option). Any WAL that isn't consumed by the payment is automatically returned to the signer's address balance, so only the actual on-chain cost -is spent. If prices increase by more than the buffer, the transaction is retried once with freshly -loaded prices, and a `StalePriceError` is thrown if it still fails. +is spent. If prices increase by more than the buffer, a `StalePriceError` is thrown and the client's +cached prices are reset, so transactions built after the error will use freshly loaded prices. ```ts const results: { diff --git a/packages/walrus/examples/funded-keypair.ts b/packages/walrus/examples/funded-keypair.ts index 126460761..e932fd84c 100644 --- a/packages/walrus/examples/funded-keypair.ts +++ b/packages/walrus/examples/funded-keypair.ts @@ -25,10 +25,19 @@ export async function getFundedKeypair() { }); if (BigInt(balance.balance) < MIST_PER_SUI) { - await requestSuiFromFaucetV2({ - host: getFaucetHost('testnet'), - recipient: keypair.toSuiAddress(), - }); + try { + await requestSuiFromFaucetV2({ + host: getFaucetHost('testnet'), + recipient: keypair.toSuiAddress(), + }); + } catch (error) { + // The faucet is only topping up the balance, so a rate limited request can be + // ignored as long as there is still enough SUI to pay for gas. + if (BigInt(balance.balance) < MIST_PER_SUI / 10n) { + throw error; + } + console.warn('faucet request failed, continuing with current balance:', balance.balance); + } } const walBalance = await suiClient.getBalance({ diff --git a/packages/walrus/examples/verify-1127-extend.ts b/packages/walrus/examples/verify-1127-extend.ts index 3f68951ac..cdf39e5f7 100644 --- a/packages/walrus/examples/verify-1127-extend.ts +++ b/packages/walrus/examples/verify-1127-extend.ts @@ -4,12 +4,14 @@ // Verifies the extendBlob payment path against simulated price drift (issue #1127). // Writes a small blob, then extends it twice: once with the cached price inflated // +2% (previously aborted in destroy_zero) and once deflated -15% + stale-until-reset -// (exercises StalePriceError + retry in executeExtendBlobTransaction). +// (exercises the StalePriceError classification and cache reset, after which a new +// attempt succeeds with freshly loaded prices). import { SuiGrpcClient } from '@mysten/sui/grpc'; import { Agent, setGlobalDispatcher } from 'undici'; import { walrus } from '../src/client.js'; +import { StalePriceError } from '../src/error.js'; import { getFundedKeypair } from './funded-keypair.js'; setGlobalDispatcher( @@ -90,20 +92,32 @@ async function main() { console.log(' FAILED:', (err as Error).message); } - console.log('\n=== extend with cached price -15%, stale until reset (expect retry, success) ==='); + console.log('\n=== extend with cached price -15% (expect StalePriceError, then success) ==='); try { const client = makeClient(); const state = patchSystemStatePrice(client, 85n, 100n); - const { digest } = await client.walrus.executeExtendBlobTransaction({ - blobObjectId: blobObject.id, - epochs: 1, - signer: keypair, - }); - console.log(' SUCCESS, digest:', digest); - console.log(' reset() calls (should be >= 1):', state.resetCalls); - if (state.resetCalls === 0) { + try { + await client.walrus.executeExtendBlobTransaction({ + blobObjectId: blobObject.id, + epochs: 1, + signer: keypair, + }); failures += 1; - console.log(' UNEXPECTED: succeeded without hitting the retry path'); + console.log(' UNEXPECTED SUCCESS (expected StalePriceError)'); + } catch (err) { + console.log(' first attempt failed with:', (err as Error).constructor.name); + console.log(' reset() calls (should be >= 1):', state.resetCalls); + if (!(err instanceof StalePriceError) || state.resetCalls === 0) { + failures += 1; + console.log(' UNEXPECTED error type or missing cache reset:', (err as Error).message); + } else { + const { digest } = await client.walrus.executeExtendBlobTransaction({ + blobObjectId: blobObject.id, + epochs: 1, + signer: keypair, + }); + console.log(' SUCCESS on new attempt with fresh prices, digest:', digest); + } } } catch (err) { failures += 1; diff --git a/packages/walrus/examples/verify-1127-fix.ts b/packages/walrus/examples/verify-1127-fix.ts index 8546f46b2..dbe1ef5f9 100644 --- a/packages/walrus/examples/verify-1127-fix.ts +++ b/packages/walrus/examples/verify-1127-fix.ts @@ -8,17 +8,21 @@ // A. cached price HIGHER than actual -> succeeds; remainder goes to the // sender's address balance instead of aborting in destroy_zero. // B. cached price LOWER than actual (beyond the cost buffer, stale until -// reset()) -> first attempt aborts, is classified as StalePriceError, -// caches reset, and the write-blob flow retries and succeeds. +// reset()) -> the write fails with StalePriceError and resets the price +// cache, so a new attempt succeeds with freshly loaded prices. // C. cached price LOWER than actual but within the default 10% cost buffer // -> succeeds on the first attempt. // D. control run without overrides -> succeeds; the unused buffer is // returned to the sender's address balance. +// E. paying from an explicit walCoin with stale prices -> succeeds; the +// buffered split is merged back into the walCoin after the payment. import { SuiGrpcClient } from '@mysten/sui/grpc'; +import { coinWithBalance, Transaction } from '@mysten/sui/transactions'; import { Agent, setGlobalDispatcher } from 'undici'; import { walrus } from '../src/client.js'; +import { StalePriceError } from '../src/error.js'; import { getFundedKeypair } from './funded-keypair.js'; setGlobalDispatcher( @@ -126,16 +130,26 @@ async function main() { } // Scenario B: cached price DEFLATED -15% (beyond the 10% buffer), stale until reset. - console.log('\n=== B: cached price -15%, stale until reset (expect retry, then success) ==='); + console.log( + '\n=== B: cached price -15% (expect StalePriceError, then success on new attempt) ===', + ); try { const client = makeClient(); const state = patchSystemStatePrice(client, 85n, 100n); - const res = await writeSmallBlob(client, keypair, 'deflated-beyond-buffer'); - console.log(' SUCCESS, blobId:', res.blobId); - console.log(' reset() calls (should be >= 1, proving retry path):', state.resetCalls); - if (state.resetCalls === 0) { + try { + const res = await writeSmallBlob(client, keypair, 'deflated-beyond-buffer'); failures += 1; - console.log(' UNEXPECTED: succeeded without hitting the retry path'); + console.log(' UNEXPECTED SUCCESS (expected StalePriceError), blobId:', res.blobId); + } catch (err) { + console.log(' first attempt failed with:', (err as Error).constructor.name); + console.log(' reset() calls (should be >= 1):', state.resetCalls); + if (!(err instanceof StalePriceError) || state.resetCalls === 0) { + failures += 1; + console.log(' UNEXPECTED error type or missing cache reset:', (err as Error).message); + } else { + const res = await writeSmallBlob(client, keypair, 'deflated-new-attempt'); + console.log(' SUCCESS on new attempt with fresh prices, blobId:', res.blobId); + } } } catch (err) { failures += 1; @@ -143,7 +157,7 @@ async function main() { } // Scenario C: cached price DEFLATED -0.5% (within the default 10% buffer). - console.log('\n=== C: cached price -0.5%, within buffer (expect success, no retry) ==='); + console.log('\n=== C: cached price -0.5%, within buffer (expect success, no reset) ==='); try { const client = makeClient(); const state = patchSystemStatePrice(client, 995n, 1000n); @@ -174,6 +188,36 @@ async function main() { console.log(' FAILED:', (err as Error).message); } + // Scenario E: pay from an explicit walCoin with stale prices. The buffered amount is + // split from the walCoin and the remainder is merged back after the payment. + console.log( + '\n=== E: walCoin source, cached price +2% (expect success, remainder merged back) ===', + ); + try { + const client = makeClient(); + patchSystemStatePrice(client, 102n, 100n); + + const tx = new Transaction(); + const walCoin = tx.add(coinWithBalance({ balance: 10_000_000n, type: WAL_TYPE })); + const storage = tx.add(client.walrus.createStorage({ size: 1024, epochs: 1, walCoin })); + tx.transferObjects([storage, walCoin], address); + + const before = await walBalance(address); + const result = await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); + if (result.FailedTransaction) { + failures += 1; + console.log(' FAILED:', result.FailedTransaction.status.error?.message); + } else { + await client.core.waitForTransaction({ digest: result.Transaction.digest }); + const after = await walBalance(address); + console.log(' SUCCESS, digest:', result.Transaction.digest); + console.log(' WAL spent (should be actual cost only):', before.total - after.total); + } + } catch (err) { + failures += 1; + console.log(' FAILED:', (err as Error).message); + } + console.log(failures === 0 ? '\nALL SCENARIOS PASSED' : `\n${failures} SCENARIO(S) FAILED`); process.exit(failures === 0 ? 0 : 1); } diff --git a/packages/walrus/src/client.ts b/packages/walrus/src/client.ts index 180bab3b8..84097feba 100644 --- a/packages/walrus/src/client.ts +++ b/packages/walrus/src/client.ts @@ -120,7 +120,6 @@ import { toShardIndex, } from './utils/index.js'; import { SuiObjectDataLoader } from './utils/object-loader.js'; -import { sendFundsToSender } from './utils/send-funds.js'; import { shuffle, weightedShuffle } from './utils/randomness.js'; import { getWasmBindings } from './wasm.js'; import { chunk } from '@mysten/utils'; @@ -782,28 +781,36 @@ export class WalrusClient { fn: (coin: TransactionObjectArgument, tx: Transaction) => T | Promise, ) { return async (tx: Transaction): Promise => { - // Payment coins are passed by mutable reference, and only the current on-chain cost - // is deducted, so a provided source coin can be used directly and keeps whatever - // isn't consumed. + // `amount` is estimated from cached prices, so fund slightly more than the estimate + // to cover on-chain price increases. The payment calls only deduct the actual + // on-chain cost, and the remainder is returned after the payment. + const amountWithBuffer = amount + (amount * this.#costBufferBps) / 10_000n; + if (source) { - return await fn(source, tx); + const coin = tx.splitCoins(source, [amountWithBuffer])[0]; + const result = await fn(coin, tx); + tx.mergeCoins(source, [coin]); + + return result; } const walType = await this.#walType(); - // `amount` is estimated from cached prices, so fund slightly more than the estimate - // to cover on-chain price increases. const coin = tx.add( coinWithBalance({ - balance: amount + (amount * this.#costBufferBps) / 10_000n, + balance: amountWithBuffer, type: walType, }), ); const result = await fn(coin, tx); - // The amount deducted on-chain may not match the estimate, so instead of asserting - // an empty coin, return the remainder to the sender's address balance. - tx.add(sendFundsToSender({ coin, coinType: walType })); + // Return the remainder to the sender's address balance instead of asserting an + // empty coin (send_funds accepts zero balances). + tx.moveCall({ + target: '0x2::coin::send_funds', + typeArguments: [walType], + arguments: [coin, tx.moveCall({ target: '0x2::tx_context::sender' })], + }); return result; }; @@ -846,17 +853,14 @@ export class WalrusClient { }: StorageWithSizeOptions & { transaction?: Transaction; signer: Signer }) { const blobType = await this.getBlobType(); - const execute = () => - this.#executeTransaction( - this.createStorageTransaction({ - ...options, - owner: options.transaction?.getData().sender ?? signer.toSuiAddress(), - }), - signer, - 'create storage', - ); - - const { digest, effects } = await this.#executeWithRetry(options.transaction, execute); + const { digest, effects } = await this.#executeTransaction( + this.createStorageTransaction({ + ...options, + owner: options.transaction?.getData().sender ?? signer.toSuiAddress(), + }), + signer, + 'create storage', + ); const createdObjectIds = effects?.changedObjects .filter((object) => object.idOperation === 'Created') @@ -1086,17 +1090,14 @@ export class WalrusClient { }> { const blobType = await this.getBlobType(); - const execute = () => - this.#executeTransaction( - this.registerBlobTransaction({ - ...options, - owner: options.owner ?? options.transaction?.getData().sender ?? signer.toSuiAddress(), - }), - signer, - 'register blob', - ); - - const { digest, effects } = await this.#executeWithRetry(options.transaction, execute); + const { digest, effects } = await this.#executeTransaction( + this.registerBlobTransaction({ + ...options, + owner: options.owner ?? options.transaction?.getData().sender ?? signer.toSuiAddress(), + }), + signer, + 'register blob', + ); const createdObjectIds = effects?.changedObjects .filter((object) => object.idOperation === 'Created') @@ -1447,10 +1448,11 @@ export class WalrusClient { signer, ...options }: ExtendBlobOptions & { signer: Signer; transaction?: Transaction }) { - const execute = async () => - this.#executeTransaction(await this.extendBlobTransaction(options), signer, 'extend blob'); - - const { digest } = await this.#executeWithRetry(options.transaction, execute); + const { digest } = await this.#executeTransaction( + await this.extendBlobTransaction(options), + signer, + 'extend blob', + ); return { digest }; } @@ -2205,27 +2207,18 @@ export class WalrusClient { this.#cache.clear(); } - async #retryOnRetryableError(fn: () => Promise): Promise { - try { - return await fn(); - } catch (error) { - if (error instanceof RetryableWalrusClientError) { - this.reset(); - return await fn(); - } - throw error; - } - } - - // Retrying re-executes `fn`, which re-adds commands to the transaction it executes, so - // this only retries when the caller didn't provide their own transaction. - #executeWithRetry(callerTransaction: Transaction | undefined, fn: () => Promise) { - return callerTransaction ? fn() : this.#retryOnRetryableError(fn); - } - #retryOnPossibleEpochChange Promise>(fn: T): T { - return ((...args: Parameters) => - this.#retryOnRetryableError(() => fn.apply(this, args))) as T; + return (async (...args: Parameters) => { + try { + return await fn.apply(this, args); + } catch (error) { + if (error instanceof RetryableWalrusClientError) { + this.reset(); + return await fn.apply(this, args); + } + throw error; + } + }) as T; } async getBlob({ blobId }: { blobId: string }) { @@ -2355,7 +2348,6 @@ export class WalrusClient { hasUploadRelay: () => !!this.#uploadRelayClient, executeTransaction: (transaction, signer, action) => this.#executeTransaction(transaction, signer, action), - retryOnRetryableError: (fn) => this.#retryOnRetryableError(fn), getCreatedBlob: (digest) => this.#getCreatedBlob(digest), loadBlobObject: (objectId) => this.#objectLoader.load(objectId, Blob), }; diff --git a/packages/walrus/src/error.ts b/packages/walrus/src/error.ts index bbcd71aad..dba5e3cfb 100644 --- a/packages/walrus/src/error.ts +++ b/packages/walrus/src/error.ts @@ -32,8 +32,8 @@ export class InconsistentBlobError extends WalrusClientError {} /** * Thrown when a transaction aborts in a way that usually indicates the WAL payment was * computed from stale cached storage prices (it can also indicate an insufficient WAL balance). - * The client's caches are reset before this error is thrown, so retrying will rebuild the - * transaction with fresh prices. + * The client's caches are reset before this error is thrown, so transactions built after + * this error will use freshly loaded prices. */ export class StalePriceError extends RetryableWalrusClientError {} diff --git a/packages/walrus/src/flows/write-blob.ts b/packages/walrus/src/flows/write-blob.ts index 6f875db39..92e8bd429 100644 --- a/packages/walrus/src/flows/write-blob.ts +++ b/packages/walrus/src/flows/write-blob.ts @@ -32,8 +32,6 @@ export interface WriteBlobFlowContext { signer: Signer, action: string, ): Promise<{ digest: string }>; - /** Runs `fn`, and retries it once with reset caches if it throws a retryable error. */ - retryOnRetryableError(fn: () => Promise): Promise; getCreatedBlob(digest: string): Promise<(typeof Blob)['$inferType']>; loadBlobObject(objectId: string): Promise<(typeof Blob)['$inferType']>; } @@ -248,11 +246,7 @@ export function createWriteBlobFlow( signer, ...options }: WriteBlobFlowRegisterOptions & { signer: Signer }): Promise => { - // A retry builds a new registration transaction, so it will use fresh on-chain state - // after errors like StalePriceError reset the client's caches. - const { digest } = await ctx.retryOnRetryableError(() => - ctx.executeTransaction(register(options), signer, 'register blob'), - ); + const { digest } = await ctx.executeTransaction(register(options), signer, 'register blob'); return { step: 'registered' as const, diff --git a/packages/walrus/src/types.ts b/packages/walrus/src/types.ts index 9681dc3ee..13665e286 100644 --- a/packages/walrus/src/types.ts +++ b/packages/walrus/src/types.ts @@ -79,13 +79,11 @@ interface BaseWalrusClientConfig { storageNodeUrlScheme?: 'http' | 'https'; /** * Extra WAL added on top of estimated storage and write costs (in basis points of the - * estimated cost) when funding payments from the signer's balance. This protects against - * on-chain price increases between cost estimation and execution. Any WAL that isn't - * consumed by the payment is returned to the sender's address balance, so the buffer is - * only spent if prices actually increased. Defaults to `1000` (10%). - * - * This does not apply when an explicit `walCoin` is provided, since payments are deducted - * directly from that coin. + * estimated cost) when funding payments. This protects against on-chain price increases + * between cost estimation and execution. Only the actual on-chain cost is deducted from + * the funded amount: any remainder is merged back into the provided `walCoin`, or + * returned to the sender's address balance when paying from the signer's balance, so the + * buffer is only spent if prices actually increased. Defaults to `1000` (10%). */ costBufferBps?: number; } @@ -129,7 +127,7 @@ export interface StorageWithSizeOptions { size: number; /** The number of epoch the storage will be reserved for. */ epochs: number; - /** Optionally specify a WAL coin to pay from. The actual on-chain cost is deducted directly from this coin. By default WAL is consumed from the signer's balance instead. */ + /** Optionally specify a WAL coin to pay from. The estimated cost plus the configured buffer is split from this coin, and any WAL that isn't consumed by the payment is merged back into it. By default WAL is consumed from the signer's balance instead. */ walCoin?: TransactionObjectArgument; } @@ -137,7 +135,7 @@ export interface RegisterBlobOptions extends StorageWithSizeOptions { blobId: string; rootHash: Uint8Array; deletable: boolean; - /** Optionally specify a WAL coin to pay from. The actual on-chain cost is deducted directly from this coin. By default WAL is consumed from the signer's balance instead. */ + /** Optionally specify a WAL coin to pay from. The estimated cost plus the configured buffer is split from this coin, and any WAL that isn't consumed by the payment is merged back into it. By default WAL is consumed from the signer's balance instead. */ walCoin?: TransactionObjectArgument; /** The attributes to write for the blob. */ attributes?: Record; @@ -395,7 +393,7 @@ export interface DeleteBlobOptions { export type ExtendBlobOptions = { blobObjectId: string; - /** Optionally specify a WAL coin to pay from. The actual on-chain cost is deducted directly from this coin. By default WAL is consumed from the signer's balance instead. */ + /** Optionally specify a WAL coin to pay from. The estimated cost plus the configured buffer is split from this coin, and any WAL that isn't consumed by the payment is merged back into it. By default WAL is consumed from the signer's balance instead. */ walCoin?: TransactionObjectArgument; } & ( | { diff --git a/packages/walrus/src/utils/send-funds.ts b/packages/walrus/src/utils/send-funds.ts deleted file mode 100644 index 2fd06ef01..000000000 --- a/packages/walrus/src/utils/send-funds.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { bcs } from '@mysten/sui/bcs'; -import type { - Argument, - BuildTransactionOptions, - Transaction, - TransactionDataBuilder, - TransactionResult, -} from '@mysten/sui/transactions'; -import { Inputs, TransactionCommands } from '@mysten/sui/transactions'; - -const SEND_FUNDS_TO_SENDER = 'WalrusSendFundsToSender'; - -/** - * Sends the remaining balance of `coin` to the transaction sender's address balance. - * - * The sender isn't known until the transaction is built, so this adds an intent that is - * resolved to a `0x2::coin::send_funds` call (which accepts zero balances) at build time. - */ -export function sendFundsToSender({ - coin, - coinType, -}: { - coin: TransactionResult; - coinType: string; -}) { - return (tx: Transaction) => { - tx.addIntentResolver(SEND_FUNDS_TO_SENDER, resolveSendFundsToSender); - tx.add( - TransactionCommands.Intent({ - name: SEND_FUNDS_TO_SENDER, - inputs: { coin }, - data: { coinType }, - }), - ); - }; -} - -async function resolveSendFundsToSender( - transactionData: TransactionDataBuilder, - _buildOptions: BuildTransactionOptions, - next: () => Promise, -) { - // Executors and wallets may prepare transactions before a sender is set. The intent is - // left unresolved in that case, and is resolved when the transaction is built (which - // requires a sender for the coinWithBalance intent this is always paired with anyways). - if (!transactionData.sender) { - return next(); - } - - for (const [index, command] of transactionData.commands.entries()) { - if (command.$kind !== '$Intent' || command.$Intent.name !== SEND_FUNDS_TO_SENDER) { - continue; - } - - const { coinType } = command.$Intent.data as { coinType: string }; - - transactionData.replaceCommand( - index, - TransactionCommands.MoveCall({ - target: '0x2::coin::send_funds', - typeArguments: [coinType], - arguments: [ - command.$Intent.inputs.coin as Argument, - transactionData.addInput( - 'pure', - Inputs.Pure(bcs.Address.serialize(transactionData.sender)), - ), - ], - }), - ); - } - - return next(); -} diff --git a/packages/walrus/test/unit/send-funds.test.ts b/packages/walrus/test/unit/send-funds.test.ts deleted file mode 100644 index 6d9bfdab7..000000000 --- a/packages/walrus/test/unit/send-funds.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { bcs } from '@mysten/sui/bcs'; -import { Transaction } from '@mysten/sui/transactions'; -import { describe, expect, it } from 'vitest'; - -import { sendFundsToSender } from '../../src/utils/send-funds.js'; - -const SENDER = '0x2fe12c9e2ab4e4d5ba484212a0e0f4c92eef6a6bcd50cb03e1a3b8bea4a4d267'; -const WAL_TYPE = '0x8270feb7375eee355e64fdb69c50abb6b5f9393a722883c1cf45f8e26048810a::wal::WAL'; - -describe('sendFundsToSender', () => { - it('resolves to a coin::send_funds call with the sender address', async () => { - const tx = new Transaction(); - tx.setSender(SENDER); - - const coin = tx.splitCoins(tx.gas, [100n]); - tx.add(sendFundsToSender({ coin, coinType: WAL_TYPE })); - - await tx.prepareForSerialization({}); - - const commands = tx.getData().commands; - const moveCall = commands.find((command) => command.$kind === 'MoveCall')?.MoveCall; - - expect(moveCall).toMatchObject({ - package: '0x0000000000000000000000000000000000000000000000000000000000000002', - module: 'coin', - function: 'send_funds', - typeArguments: [WAL_TYPE], - }); - - expect(moveCall?.arguments[0]).toMatchObject({ $kind: 'Result', Result: 0 }); - - const recipient = moveCall?.arguments[1]; - expect(recipient?.$kind).toBe('Input'); - const input = tx.getData().inputs[(recipient as { Input: number }).Input]; - expect(input.Pure?.bytes).toBe(bcs.Address.serialize(SENDER).toBase64()); - }); - - it('stays unresolved until a sender is set', async () => { - const tx = new Transaction(); - const coin = tx.splitCoins(tx.gas, [100n]); - tx.add(sendFundsToSender({ coin, coinType: WAL_TYPE })); - - // Executors prepare transactions before setting a sender, so the intent is deferred. - await tx.prepareForSerialization({}); - expect( - tx.getData().commands.some((command) => command.$Intent?.name === 'WalrusSendFundsToSender'), - ).toBe(true); - - tx.setSender(SENDER); - await tx.prepareForSerialization({}); - - const moveCall = tx - .getData() - .commands.find((command) => command.$kind === 'MoveCall')?.MoveCall; - expect(moveCall?.function).toBe('send_funds'); - }); -}); From 45ff21d64fe03d16c8eac908885a92bff9c1a5d4 Mon Sep 17 00:00:00 2001 From: Michael Hayes Date: Thu, 2 Jul 2026 15:07:17 -0700 Subject: [PATCH 3/5] style(walrus): destructure splitCoins result Co-Authored-By: Claude Fable 5 --- packages/walrus/src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/walrus/src/client.ts b/packages/walrus/src/client.ts index 84097feba..8a210ea22 100644 --- a/packages/walrus/src/client.ts +++ b/packages/walrus/src/client.ts @@ -787,7 +787,7 @@ export class WalrusClient { const amountWithBuffer = amount + (amount * this.#costBufferBps) / 10_000n; if (source) { - const coin = tx.splitCoins(source, [amountWithBuffer])[0]; + const [coin] = tx.splitCoins(source, [amountWithBuffer]); const result = await fn(coin, tx); tx.mergeCoins(source, [coin]); From b284c55ca1b374c63d1b2f95b5def750a018a5ac Mon Sep 17 00:00:00 2001 From: Michael Hayes Date: Thu, 2 Jul 2026 15:49:09 -0700 Subject: [PATCH 4/5] fix(walrus): export isStalePriceAbort and narrow abort classification 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 --- .changeset/tidy-pandas-repeat.md | 4 +- packages/docs/content/walrus/index.mdx | 5 +- .../walrus/examples/verify-1127-extend.ts | 131 ---------- packages/walrus/examples/verify-1127-fix.ts | 225 ------------------ packages/walrus/src/client.ts | 33 ++- packages/walrus/src/error.ts | 26 ++ packages/walrus/test/unit/error.test.ts | 47 ++++ 7 files changed, 104 insertions(+), 367 deletions(-) delete mode 100644 packages/walrus/examples/verify-1127-extend.ts delete mode 100644 packages/walrus/examples/verify-1127-fix.ts create mode 100644 packages/walrus/test/unit/error.test.ts diff --git a/.changeset/tidy-pandas-repeat.md b/.changeset/tidy-pandas-repeat.md index 17adf706b..8f46b07af 100644 --- a/.changeset/tidy-pandas-repeat.md +++ b/.changeset/tidy-pandas-repeat.md @@ -9,4 +9,6 @@ buffer (`costBufferBps`, default 10%), and since the walrus contracts only deduc on-chain cost, any unspent WAL is merged back into the provided `walCoin`, or returned to the signer's address balance when paying from the signer's balance. Payment aborts caused by stale cached prices reset the client's caches and surface as the new `StalePriceError` (a -`RetryableWalrusClientError`), so transactions built after the error use freshly loaded prices. +`RetryableWalrusClientError`), so transactions built after the error use freshly loaded prices. The +new `isStalePriceAbort` helper classifies these aborts for transactions executed outside of the +client. diff --git a/packages/docs/content/walrus/index.mdx b/packages/docs/content/walrus/index.mdx index 280d2a3e0..84efde30d 100644 --- a/packages/docs/content/walrus/index.mdx +++ b/packages/docs/content/walrus/index.mdx @@ -179,7 +179,10 @@ executes, the client funds payments with a small buffer on top of the estimated default, configurable with the `costBufferBps` client option). Any WAL that isn't consumed by the payment is automatically returned to the signer's address balance, so only the actual on-chain cost is spent. If prices increase by more than the buffer, a `StalePriceError` is thrown and the client's -cached prices are reset, so transactions built after the error will use freshly loaded prices. +cached prices are reset, so transactions built after the error will use freshly loaded prices. When +transactions are executed outside of the client (for example through a wallet or a sponsor), the +exported `isStalePriceAbort` helper can detect this case from the execution error, and calling +`client.reset()` clears the cached prices. ```ts const results: { diff --git a/packages/walrus/examples/verify-1127-extend.ts b/packages/walrus/examples/verify-1127-extend.ts deleted file mode 100644 index cdf39e5f7..000000000 --- a/packages/walrus/examples/verify-1127-extend.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -// Verifies the extendBlob payment path against simulated price drift (issue #1127). -// Writes a small blob, then extends it twice: once with the cached price inflated -// +2% (previously aborted in destroy_zero) and once deflated -15% + stale-until-reset -// (exercises the StalePriceError classification and cache reset, after which a new -// attempt succeeds with freshly loaded prices). - -import { SuiGrpcClient } from '@mysten/sui/grpc'; -import { Agent, setGlobalDispatcher } from 'undici'; - -import { walrus } from '../src/client.js'; -import { StalePriceError } from '../src/error.js'; -import { getFundedKeypair } from './funded-keypair.js'; - -setGlobalDispatcher( - new Agent({ - connectTimeout: 60_000, - connect: { timeout: 60_000 }, - }), -); - -function makeClient() { - return new SuiGrpcClient({ - network: 'testnet', - baseUrl: 'https://fullnode.testnet.sui.io:443', - }).$extend( - walrus({ - storageNodeClientOptions: { - timeout: 60_000, - }, - }), - ); -} - -function patchSystemStatePrice( - client: ReturnType, - numer: bigint, - denom: bigint, -) { - const walrusClient = client.walrus; - const real = walrusClient.systemState.bind(walrusClient); - const realReset = walrusClient.reset.bind(walrusClient); - const state = { stale: true, resetCalls: 0 }; - - (walrusClient as any).reset = () => { - state.resetCalls += 1; - state.stale = false; - realReset(); - }; - - (walrusClient as any).systemState = async () => { - const s = structuredClone(await real()); - if (!state.stale) { - return s; - } - s.storage_price_per_unit_size = String((BigInt(s.storage_price_per_unit_size) * numer) / denom); - s.write_price_per_unit_size = String((BigInt(s.write_price_per_unit_size) * numer) / denom); - return s; - }; - - return state; -} - -async function main() { - const keypair = await getFundedKeypair(); - let failures = 0; - - console.log('writing a blob to extend...'); - const setupClient = makeClient(); - const { blobObject } = await setupClient.walrus.writeBlob({ - blob: new TextEncoder().encode('verify 1127 extend ' + Date.now()), - epochs: 1, - deletable: false, - signer: keypair, - }); - console.log('blob object:', blobObject.id, 'end epoch:', blobObject.storage.end_epoch); - - console.log('\n=== extend with cached price +2% (expect success) ==='); - try { - const client = makeClient(); - patchSystemStatePrice(client, 102n, 100n); - const { digest } = await client.walrus.executeExtendBlobTransaction({ - blobObjectId: blobObject.id, - epochs: 1, - signer: keypair, - }); - console.log(' SUCCESS, digest:', digest); - } catch (err) { - failures += 1; - console.log(' FAILED:', (err as Error).message); - } - - console.log('\n=== extend with cached price -15% (expect StalePriceError, then success) ==='); - try { - const client = makeClient(); - const state = patchSystemStatePrice(client, 85n, 100n); - try { - await client.walrus.executeExtendBlobTransaction({ - blobObjectId: blobObject.id, - epochs: 1, - signer: keypair, - }); - failures += 1; - console.log(' UNEXPECTED SUCCESS (expected StalePriceError)'); - } catch (err) { - console.log(' first attempt failed with:', (err as Error).constructor.name); - console.log(' reset() calls (should be >= 1):', state.resetCalls); - if (!(err instanceof StalePriceError) || state.resetCalls === 0) { - failures += 1; - console.log(' UNEXPECTED error type or missing cache reset:', (err as Error).message); - } else { - const { digest } = await client.walrus.executeExtendBlobTransaction({ - blobObjectId: blobObject.id, - epochs: 1, - signer: keypair, - }); - console.log(' SUCCESS on new attempt with fresh prices, digest:', digest); - } - } - } catch (err) { - failures += 1; - console.log(' FAILED:', (err as Error).message); - } - - console.log(failures === 0 ? '\nALL EXTEND SCENARIOS PASSED' : `\n${failures} FAILED`); - process.exit(failures === 0 ? 0 : 1); -} - -main(); diff --git a/packages/walrus/examples/verify-1127-fix.ts b/packages/walrus/examples/verify-1127-fix.ts deleted file mode 100644 index dbe1ef5f9..000000000 --- a/packages/walrus/examples/verify-1127-fix.ts +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -// Verification for the fix to issue #1127 (stale-price WAL payments). -// -// Simulates a stale cached price by monkey-patching systemState() on the client -// instance and confirms: -// A. cached price HIGHER than actual -> succeeds; remainder goes to the -// sender's address balance instead of aborting in destroy_zero. -// B. cached price LOWER than actual (beyond the cost buffer, stale until -// reset()) -> the write fails with StalePriceError and resets the price -// cache, so a new attempt succeeds with freshly loaded prices. -// C. cached price LOWER than actual but within the default 10% cost buffer -// -> succeeds on the first attempt. -// D. control run without overrides -> succeeds; the unused buffer is -// returned to the sender's address balance. -// E. paying from an explicit walCoin with stale prices -> succeeds; the -// buffered split is merged back into the walCoin after the payment. - -import { SuiGrpcClient } from '@mysten/sui/grpc'; -import { coinWithBalance, Transaction } from '@mysten/sui/transactions'; -import { Agent, setGlobalDispatcher } from 'undici'; - -import { walrus } from '../src/client.js'; -import { StalePriceError } from '../src/error.js'; -import { getFundedKeypair } from './funded-keypair.js'; - -setGlobalDispatcher( - new Agent({ - connectTimeout: 60_000, - connect: { timeout: 60_000 }, - }), -); - -const WAL_TYPE = '0x8270feb7375eee355e64fdb69c50abb6b5f9393a722883c1cf45f8e26048810a::wal::WAL'; - -function makeClient() { - return new SuiGrpcClient({ - network: 'testnet', - baseUrl: 'https://fullnode.testnet.sui.io:443', - }).$extend( - walrus({ - storageNodeClientOptions: { - timeout: 60_000, - }, - }), - ); -} - -/** - * Simulate a stale cache: systemState() reports prices scaled by numer/denom - * until client.reset() is called (like a real stale cache, which is cleared by - * reset). Returns counters for observing what happened. - */ -function patchSystemStatePrice( - client: ReturnType, - numer: bigint, - denom: bigint, -) { - const walrusClient = client.walrus; - const real = walrusClient.systemState.bind(walrusClient); - const realReset = walrusClient.reset.bind(walrusClient); - const state = { stale: true, resetCalls: 0 }; - - (walrusClient as any).reset = () => { - state.resetCalls += 1; - state.stale = false; - console.log(' client.reset() called -> cache now returns real prices'); - realReset(); - }; - - (walrusClient as any).systemState = async () => { - const s = structuredClone(await real()); - if (!state.stale) { - return s; - } - s.storage_price_per_unit_size = String((BigInt(s.storage_price_per_unit_size) * numer) / denom); - s.write_price_per_unit_size = String((BigInt(s.write_price_per_unit_size) * numer) / denom); - return s; - }; - - return state; -} - -async function walBalance(owner: string) { - const suiClient = new SuiGrpcClient({ - network: 'testnet', - baseUrl: 'https://fullnode.testnet.sui.io:443', - }); - const { balance } = await suiClient.getBalance({ owner, coinType: WAL_TYPE }); - return { total: BigInt(balance.balance), address: BigInt(balance.addressBalance) }; -} - -async function writeSmallBlob( - client: ReturnType, - keypair: Awaited>, - tag: string, -) { - const blob = new TextEncoder().encode('verify 1127 fix ' + tag + ' ' + Date.now()); - return client.walrus.writeBlob({ - blob, - epochs: 1, - deletable: true, - signer: keypair, - }); -} - -async function main() { - const keypair = await getFundedKeypair(); - const address = keypair.toSuiAddress(); - console.log('address:', address); - console.log('WAL balance:', await walBalance(address)); - - let failures = 0; - - // Scenario A: cached price INFLATED +2%. Previously aborted in destroy_zero. - console.log('\n=== A: cached price +2% (expect success, remainder to address balance) ==='); - try { - const client = makeClient(); - patchSystemStatePrice(client, 102n, 100n); - const before = await walBalance(address); - const res = await writeSmallBlob(client, keypair, 'inflated'); - const after = await walBalance(address); - console.log(' SUCCESS, blobId:', res.blobId); - console.log(' WAL spent:', before.total - after.total); - console.log(' address balance delta:', after.address - before.address); - } catch (err) { - failures += 1; - console.log(' FAILED:', (err as Error).message); - } - - // Scenario B: cached price DEFLATED -15% (beyond the 10% buffer), stale until reset. - console.log( - '\n=== B: cached price -15% (expect StalePriceError, then success on new attempt) ===', - ); - try { - const client = makeClient(); - const state = patchSystemStatePrice(client, 85n, 100n); - try { - const res = await writeSmallBlob(client, keypair, 'deflated-beyond-buffer'); - failures += 1; - console.log(' UNEXPECTED SUCCESS (expected StalePriceError), blobId:', res.blobId); - } catch (err) { - console.log(' first attempt failed with:', (err as Error).constructor.name); - console.log(' reset() calls (should be >= 1):', state.resetCalls); - if (!(err instanceof StalePriceError) || state.resetCalls === 0) { - failures += 1; - console.log(' UNEXPECTED error type or missing cache reset:', (err as Error).message); - } else { - const res = await writeSmallBlob(client, keypair, 'deflated-new-attempt'); - console.log(' SUCCESS on new attempt with fresh prices, blobId:', res.blobId); - } - } - } catch (err) { - failures += 1; - console.log(' FAILED:', (err as Error).message); - } - - // Scenario C: cached price DEFLATED -0.5% (within the default 10% buffer). - console.log('\n=== C: cached price -0.5%, within buffer (expect success, no reset) ==='); - try { - const client = makeClient(); - const state = patchSystemStatePrice(client, 995n, 1000n); - const res = await writeSmallBlob(client, keypair, 'deflated-within-buffer'); - console.log(' SUCCESS, blobId:', res.blobId); - console.log(' reset() calls (should be 0):', state.resetCalls); - if (state.resetCalls > 0) { - failures += 1; - console.log(' UNEXPECTED: retry path was hit'); - } - } catch (err) { - failures += 1; - console.log(' FAILED:', (err as Error).message); - } - - // Control: no overrides. - console.log('\n=== D: control, no override (expect success) ==='); - try { - const client = makeClient(); - const before = await walBalance(address); - const res = await writeSmallBlob(client, keypair, 'control'); - const after = await walBalance(address); - console.log(' SUCCESS, blobId:', res.blobId); - console.log(' WAL spent:', before.total - after.total); - console.log(' address balance delta:', after.address - before.address); - } catch (err) { - failures += 1; - console.log(' FAILED:', (err as Error).message); - } - - // Scenario E: pay from an explicit walCoin with stale prices. The buffered amount is - // split from the walCoin and the remainder is merged back after the payment. - console.log( - '\n=== E: walCoin source, cached price +2% (expect success, remainder merged back) ===', - ); - try { - const client = makeClient(); - patchSystemStatePrice(client, 102n, 100n); - - const tx = new Transaction(); - const walCoin = tx.add(coinWithBalance({ balance: 10_000_000n, type: WAL_TYPE })); - const storage = tx.add(client.walrus.createStorage({ size: 1024, epochs: 1, walCoin })); - tx.transferObjects([storage, walCoin], address); - - const before = await walBalance(address); - const result = await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); - if (result.FailedTransaction) { - failures += 1; - console.log(' FAILED:', result.FailedTransaction.status.error?.message); - } else { - await client.core.waitForTransaction({ digest: result.Transaction.digest }); - const after = await walBalance(address); - console.log(' SUCCESS, digest:', result.Transaction.digest); - console.log(' WAL spent (should be actual cost only):', before.total - after.total); - } - } catch (err) { - failures += 1; - console.log(' FAILED:', (err as Error).message); - } - - console.log(failures === 0 ? '\nALL SCENARIOS PASSED' : `\n${failures} SCENARIO(S) FAILED`); - process.exit(failures === 0 ? 0 : 1); -} - -main(); diff --git a/packages/walrus/src/client.ts b/packages/walrus/src/client.ts index 8a210ea22..b4113c89b 100644 --- a/packages/walrus/src/client.ts +++ b/packages/walrus/src/client.ts @@ -8,7 +8,7 @@ import type { ClientCache, ClientWithCoreApi, SuiClientTypes } from '@mysten/sui import { SimulationError } from '@mysten/sui/client'; import type { TransactionObjectArgument, TransactionResult } from '@mysten/sui/transactions'; import { coinWithBalance, Transaction } from '@mysten/sui/transactions'; -import { normalizeStructTag, normalizeSuiAddress, parseStructTag } from '@mysten/sui/utils'; +import { normalizeStructTag, parseStructTag } from '@mysten/sui/utils'; import { MAINNET_WALRUS_PACKAGE_CONFIG, @@ -42,6 +42,7 @@ import { BlobBlockedError, BlobNotCertifiedError, InconsistentBlobError, + isStalePriceAbort, NoBlobMetadataReceivedError, NoBlobStatusReceivedError, NotEnoughBlobConfirmationsError, @@ -2090,17 +2091,28 @@ export class WalrusClient { // WAL payment amounts are estimated from cached prices, so an abort in `0x2::balance` // (an underfunded payment split, or a non-zero remainder in older transactions) usually - // means the cached prices are stale. - #isStalePriceAbort(error: SuiClientTypes.ExecutionError | null | undefined) { - if (error?.$kind !== 'MoveAbort') { + // means the cached prices are stale. When the aborting command is known, only aborts from + // commands added for payments (walrus payment calls and coin splits/merges) are + // classified, since a caller-composed transaction can abort in `0x2::balance` for + // unrelated reasons. + #isStalePricePaymentAbort( + transaction: Transaction, + error: SuiClientTypes.ExecutionError | null | undefined, + ) { + if (!isStalePriceAbort(error)) { return false; } - const location = error.MoveAbort.location; + const command = error?.command != null ? transaction.getData().commands[error.command] : null; + + if (!command || command.$kind === 'SplitCoins' || command.$kind === 'MergeCoins') { + return true; + } return ( - location?.module === 'balance' && - (!location.package || normalizeSuiAddress(location.package) === normalizeSuiAddress('0x2')) + command.$kind === 'MoveCall' && + command.MoveCall.module === 'system' && + ['reserve_space', 'register_blob', 'extend_blob'].includes(command.MoveCall.function) ); } @@ -2116,7 +2128,10 @@ export class WalrusClient { } catch (error) { // Gas budget resolution simulates the transaction, so payment aborts usually // surface here rather than from an executed transaction. - if (error instanceof SimulationError && this.#isStalePriceAbort(error.executionError)) { + if ( + error instanceof SimulationError && + this.#isStalePricePaymentAbort(transaction, error.executionError) + ) { this.reset(); throw new StalePriceError(`Failed to ${action}: ${error.message}`, { cause: error }); } @@ -2127,7 +2142,7 @@ export class WalrusClient { const { digest, status } = result.FailedTransaction; const message = `Failed to ${action} (${digest}): ${status.error?.message}`; - if (this.#isStalePriceAbort(status.error)) { + if (this.#isStalePricePaymentAbort(transaction, status.error)) { this.reset(); throw new StalePriceError(message); } diff --git a/packages/walrus/src/error.ts b/packages/walrus/src/error.ts index dba5e3cfb..946054ab4 100644 --- a/packages/walrus/src/error.ts +++ b/packages/walrus/src/error.ts @@ -1,6 +1,9 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import type { SuiClientTypes } from '@mysten/sui/client'; +import { normalizeSuiAddress } from '@mysten/sui/utils'; + export class WalrusClientError extends Error {} export class RetryableWalrusClientError extends WalrusClientError {} @@ -37,5 +40,28 @@ export class InconsistentBlobError extends WalrusClientError {} */ export class StalePriceError extends RetryableWalrusClientError {} +/** + * Checks whether an execution or simulation error is a MoveAbort in `0x2::balance`, which for + * walrus payment transactions usually indicates the payment was computed from stale cached + * prices (it can also indicate an insufficient WAL balance). This can be used to detect stale + * prices when executing transactions outside of the walrus client (for example through a wallet + * or a sponsor). Calling `WalrusClient.reset()` clears the cached prices, so transactions built + * afterwards will use freshly loaded prices. + */ +export function isStalePriceAbort( + error: SuiClientTypes.ExecutionError | null | undefined, +): boolean { + if (error?.$kind !== 'MoveAbort') { + return false; + } + + const location = error.MoveAbort.location; + + return ( + location?.module === 'balance' && + (!location.package || normalizeSuiAddress(location.package) === normalizeSuiAddress('0x2')) + ); +} + /** Thrown when blob is blocked by a quorum of storage nodes. */ export class BlobBlockedError extends Error {} diff --git a/packages/walrus/test/unit/error.test.ts b/packages/walrus/test/unit/error.test.ts new file mode 100644 index 000000000..4bd1ed7cf --- /dev/null +++ b/packages/walrus/test/unit/error.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { SuiClientTypes } from '@mysten/sui/client'; +import { describe, expect, it } from 'vitest'; + +import { isStalePriceAbort } from '../../src/error.js'; + +function moveAbort(location?: SuiClientTypes.MoveLocation): SuiClientTypes.ExecutionError { + return { + $kind: 'MoveAbort', + MoveAbort: { abortCode: '2', location }, + message: 'MoveAbort', + } as SuiClientTypes.ExecutionError; +} + +describe('isStalePriceAbort', () => { + it('matches MoveAborts in 0x2::balance', () => { + expect(isStalePriceAbort(moveAbort({ package: '0x2', module: 'balance' }))).toBe(true); + expect( + isStalePriceAbort( + moveAbort({ + package: '0x0000000000000000000000000000000000000000000000000000000000000002', + module: 'balance', + }), + ), + ).toBe(true); + // Package may be omitted by some transports. + expect(isStalePriceAbort(moveAbort({ module: 'balance' }))).toBe(true); + }); + + it('does not match unrelated errors', () => { + expect(isStalePriceAbort(null)).toBe(false); + expect(isStalePriceAbort(undefined)).toBe(false); + expect(isStalePriceAbort(moveAbort(undefined))).toBe(false); + expect(isStalePriceAbort(moveAbort({ package: '0x2', module: 'coin' }))).toBe(false); + // A `balance` module in a third-party package is not the sui framework's. + expect(isStalePriceAbort(moveAbort({ package: '0xabc123', module: 'balance' }))).toBe(false); + expect( + isStalePriceAbort({ + $kind: 'Unknown', + Unknown: null, + message: 'unknown', + } as SuiClientTypes.ExecutionError), + ).toBe(false); + }); +}); From 52e8f7204aa1abcc5aad7a2f130763db7b5205db Mon Sep 17 00:00:00 2001 From: Michael Hayes Date: Thu, 2 Jul 2026 16:34:42 -0700 Subject: [PATCH 5/5] fix(walrus): only classify splits that fund payments as stale price aborts 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 --- packages/walrus/src/client.ts | 48 +++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/walrus/src/client.ts b/packages/walrus/src/client.ts index b4113c89b..ff3236aa9 100644 --- a/packages/walrus/src/client.ts +++ b/packages/walrus/src/client.ts @@ -6,7 +6,11 @@ import { bcs } from '@mysten/bcs'; import type { Signer } from '@mysten/sui/cryptography'; import type { ClientCache, ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; import { SimulationError } from '@mysten/sui/client'; -import type { TransactionObjectArgument, TransactionResult } from '@mysten/sui/transactions'; +import type { + Command, + TransactionObjectArgument, + TransactionResult, +} from '@mysten/sui/transactions'; import { coinWithBalance, Transaction } from '@mysten/sui/transactions'; import { normalizeStructTag, parseStructTag } from '@mysten/sui/utils'; @@ -2090,11 +2094,10 @@ export class WalrusClient { } // WAL payment amounts are estimated from cached prices, so an abort in `0x2::balance` - // (an underfunded payment split, or a non-zero remainder in older transactions) usually - // means the cached prices are stale. When the aborting command is known, only aborts from - // commands added for payments (walrus payment calls and coin splits/merges) are - // classified, since a caller-composed transaction can abort in `0x2::balance` for - // unrelated reasons. + // (an underfunded payment, or a non-zero remainder in older transactions) usually means + // the cached prices are stale. When the aborting command is known, only aborts from + // payment calls (or the buffered splits that fund them) are classified, since a + // caller-composed transaction can abort in `0x2::balance` for unrelated reasons. #isStalePricePaymentAbort( transaction: Transaction, error: SuiClientTypes.ExecutionError | null | undefined, @@ -2103,12 +2106,41 @@ export class WalrusClient { return false; } - const command = error?.command != null ? transaction.getData().commands[error.command] : null; + if (error?.command == null) { + return true; + } + + const commands = transaction.getData().commands; + const command = commands[error.command]; + + if (!command) { + return true; + } - if (!command || command.$kind === 'SplitCoins' || command.$kind === 'MergeCoins') { + if (this.#isPaymentCall(command)) { return true; } + // Payment coins are split from source coins using buffered estimates, so a SplitCoins + // abort is payment related when its result funds a payment call. + if (command.$kind === 'SplitCoins') { + const splitIndex = error.command; + return commands.some( + (cmd) => + cmd.$kind === 'MoveCall' && + this.#isPaymentCall(cmd) && + cmd.MoveCall.arguments.some( + (arg) => + (arg.$kind === 'Result' && arg.Result === splitIndex) || + (arg.$kind === 'NestedResult' && arg.NestedResult[0] === splitIndex), + ), + ); + } + + return false; + } + + #isPaymentCall(command: Command) { return ( command.$kind === 'MoveCall' && command.MoveCall.module === 'system' &&