diff --git a/.changeset/tidy-pandas-repeat.md b/.changeset/tidy-pandas-repeat.md new file mode 100644 index 000000000..8f46b07af --- /dev/null +++ b/.changeset/tidy-pandas-repeat.md @@ -0,0 +1,14 @@ +--- +'@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). 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. 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 c2c66bae1..84efde30d 100644 --- a/packages/docs/content/walrus/index.mdx +++ b/packages/docs/content/walrus/index.mdx @@ -174,6 +174,16 @@ 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, a `StalePriceError` is thrown and the client's +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: { id: string; 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/src/client.ts b/packages/walrus/src/client.ts index e3b7301fa..ff3236aa9 100644 --- a/packages/walrus/src/client.ts +++ b/packages/walrus/src/client.ts @@ -4,8 +4,13 @@ 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 { TransactionObjectArgument, TransactionResult } from '@mysten/sui/transactions'; +import type { ClientCache, ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; +import { SimulationError } from '@mysten/sui/client'; +import type { + Command, + TransactionObjectArgument, + TransactionResult, +} from '@mysten/sui/transactions'; import { coinWithBalance, Transaction } from '@mysten/sui/transactions'; import { normalizeStructTag, parseStructTag } from '@mysten/sui/utils'; @@ -41,12 +46,14 @@ import { BlobBlockedError, BlobNotCertifiedError, InconsistentBlobError, + isStalePriceAbort, NoBlobMetadataReceivedError, NoBlobStatusReceivedError, NotEnoughBlobConfirmationsError, NotEnoughSliversReceivedError, NoVerifiedBlobStatusReceivedError, RetryableWalrusClientError, + StalePriceError, WalrusClientError, } from './error.js'; import { StorageNodeClient } from './storage-node/client.js'; @@ -173,6 +180,7 @@ export class WalrusClient { #objectLoader: SuiObjectDataLoader; #blobMetadataConcurrencyLimit = 10; + #costBufferBps: bigint; #readCommittee?: CommitteeInfo | Promise | null; #cache: ClientCache; @@ -199,6 +207,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,22 +786,35 @@ export class WalrusClient { fn: (coin: TransactionObjectArgument, tx: Transaction) => T | Promise, ) { return async (tx: Transaction): Promise => { + // `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) { + const [coin] = tx.splitCoins(source, [amountWithBuffer]); + const result = await fn(coin, tx); + tx.mergeCoins(source, [coin]); + + return result; + } + const walType = await this.#walType(); - const coin = source - ? tx.splitCoins(source, [amount])[0] - : tx.add( - coinWithBalance({ - balance: amount, - type: walType, - }), - ); + const coin = tx.add( + coinWithBalance({ + balance: amountWithBuffer, + type: walType, + }), + ); const result = await fn(coin, tx); + // 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::destroy_zero', + target: '0x2::coin::send_funds', typeArguments: [walType], - arguments: [coin], + arguments: [coin, tx.moveCall({ target: '0x2::tx_context::sender' })], }); return result; @@ -828,14 +856,13 @@ 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, + this.createStorageTransaction({ + ...options, + owner: options.transaction?.getData().sender ?? signer.toSuiAddress(), + }), signer, 'create storage', ); @@ -883,23 +910,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,13 +1093,13 @@ 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, + this.registerBlobTransaction({ + ...options, + owner: options.owner ?? options.transaction?.getData().sender ?? signer.toSuiAddress(), + }), signer, 'register blob', ); @@ -2064,18 +2093,93 @@ export class WalrusClient { return encoded; } + // WAL payment amounts are estimated from cached prices, so an abort in `0x2::balance` + // (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, + ) { + if (!isStalePriceAbort(error)) { + return false; + } + + if (error?.command == null) { + return true; + } + + const commands = transaction.getData().commands; + const command = commands[error.command]; + + if (!command) { + return true; + } + + 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' && + ['reserve_space', 'register_blob', 'extend_blob'].includes(command.MoveCall.function) + ); + } + 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.#isStalePricePaymentAbort(transaction, 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.#isStalePricePaymentAbort(transaction, status.error)) { + this.reset(); + throw new StalePriceError(message); + } + + throw new WalrusClientError(message); } const { digest, effects } = result.Transaction; diff --git a/packages/walrus/src/error.ts b/packages/walrus/src/error.ts index 90371633a..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 {} @@ -29,5 +32,36 @@ 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 transactions built after + * this error will use freshly loaded prices. + */ +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/src/flows/write-blob.ts b/packages/walrus/src/flows/write-blob.ts index 63ef7dbff..92e8bd429 100644 --- a/packages/walrus/src/flows/write-blob.ts +++ b/packages/walrus/src/flows/write-blob.ts @@ -246,8 +246,7 @@ export function createWriteBlobFlow( signer, ...options }: WriteBlobFlowRegisterOptions & { signer: Signer }): Promise => { - const transaction = register(options); - const { digest } = await ctx.executeTransaction(transaction, 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 e6075e232..13665e286 100644 --- a/packages/walrus/src/types.ts +++ b/packages/walrus/src/types.ts @@ -77,6 +77,15 @@ 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. 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; } /** @@ -118,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 pay for the registration. This will consume WAL from the signer by default. */ + /** 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; } @@ -126,7 +135,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 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; @@ -384,7 +393,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 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/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); + }); +});