Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/tidy-pandas-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/docs/content/walrus/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 13 additions & 4 deletions packages/walrus/examples/funded-keypair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
170 changes: 137 additions & 33 deletions packages/walrus/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -173,6 +180,7 @@ export class WalrusClient {
#objectLoader: SuiObjectDataLoader;

#blobMetadataConcurrencyLimit = 10;
#costBufferBps: bigint;
#readCommittee?: CommitteeInfo | Promise<CommitteeInfo> | null;

#cache: ClientCache;
Expand All @@ -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);
Expand Down Expand Up @@ -771,22 +786,35 @@ export class WalrusClient {
fn: (coin: TransactionObjectArgument, tx: Transaction) => T | Promise<T>,
) {
return async (tx: Transaction): Promise<T> => {
// `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;
Expand Down Expand Up @@ -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',
);
Expand Down Expand Up @@ -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,
},
}),
);
Expand Down Expand Up @@ -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',
);
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions packages/walrus/src/error.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Expand Down Expand Up @@ -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 {}
3 changes: 1 addition & 2 deletions packages/walrus/src/flows/write-blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,7 @@ export function createWriteBlobFlow(
signer,
...options
}: WriteBlobFlowRegisterOptions & { signer: Signer }): Promise<WriteBlobStepRegistered> => {
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,
Expand Down
Loading
Loading