@mysten/sui
Version:
Sui TypeScript API
519 lines (441 loc) • 15.3 kB
text/typescript
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0
import { promiseWithResolvers } from '@mysten/utils';
import type { SuiObjectRef } from '../../bcs/types.js';
import type { ClientWithCoreApi } from '../../client/core.js';
import { coreClientResolveTransactionPlugin } from '../../client/core-resolver.js';
import type { SuiClientTypes } from '../../client/types.js';
import type { Signer } from '../../cryptography/index.js';
import type { ObjectCacheOptions } from '../ObjectCache.js';
import { Transaction } from '../Transaction.js';
import { TransactionDataBuilder } from '../TransactionData.js';
import { CachingTransactionExecutor } from './caching.js';
import { ParallelQueue, SerialQueue } from './queue.js';
const PARALLEL_EXECUTOR_DEFAULTS = {
coinBatchSize: 20,
initialCoinBalance: 200_000_000n,
minimumCoinBalance: 50_000_000n,
maxPoolSize: 50,
} satisfies Partial<ParallelTransactionExecutorCoinOptions>;
const EPOCH_BOUNDARY_WINDOW = 60_000;
interface ParallelTransactionExecutorBaseOptions extends Omit<ObjectCacheOptions, 'address'> {
client: ClientWithCoreApi;
signer: Signer;
/** The gasBudget to use if the transaction has not defined it's own gasBudget, defaults to `minimumCoinBalance` */
defaultGasBudget?: bigint;
/** The maximum number of transactions that can be execute in parallel, this also determines the maximum number of gas coins that will be created */
maxPoolSize?: number;
}
export interface ParallelTransactionExecutorCoinOptions extends ParallelTransactionExecutorBaseOptions {
/** Gas mode - use owned coins for gas payments (default) */
gasMode?: 'coins';
/** The number of coins to create in a batch when refilling the gas pool */
coinBatchSize?: number;
/** The initial balance of each coin created for the gas pool */
initialCoinBalance?: bigint;
/** The minimum balance of a coin that can be reused for future transactions. If the gasCoin is below this value, it will be used when refilling the gasPool */
minimumCoinBalance?: bigint;
/** An initial list of coins used to fund the gas pool, uses all owned SUI coins by default */
sourceCoins?: string[];
}
export interface ParallelTransactionExecutorAddressBalanceOptions extends ParallelTransactionExecutorBaseOptions {
/** Gas mode - use address balance for gas payments instead of owned coins */
gasMode: 'addressBalance';
}
/** Options for ParallelTransactionExecutor - discriminated union based on gasMode */
export type ParallelTransactionExecutorOptions =
| ParallelTransactionExecutorCoinOptions
| ParallelTransactionExecutorAddressBalanceOptions;
interface CoinWithBalance {
id: string;
version: string;
digest: string;
balance: bigint;
}
export class ParallelTransactionExecutor {
#signer: Signer;
#client: ClientWithCoreApi;
#gasMode: 'coins' | 'addressBalance';
#coinBatchSize: number;
#initialCoinBalance: bigint;
#minimumCoinBalance: bigint;
#defaultGasBudget: bigint;
#maxPoolSize: number;
#sourceCoins: Map<string, SuiObjectRef | null> | null;
#coinPool: CoinWithBalance[] = [];
#cache: CachingTransactionExecutor;
#objectIdQueues = new Map<string, (() => void)[]>();
#buildQueue = new SerialQueue();
#executeQueue: ParallelQueue;
#lastDigest: string | null = null;
#cacheLock: Promise<void> | null = null;
#pendingTransactions = 0;
#epochInfo: null | {
epoch: string;
price: bigint;
expiration: number;
chainIdentifier: string;
} = null;
#epochInfoPromise: Promise<void> | null = null;
constructor(options: ParallelTransactionExecutorOptions) {
this.#signer = options.signer;
this.#client = options.client;
this.#gasMode = options.gasMode ?? 'coins';
if (this.#gasMode === 'coins') {
const coinOptions = options as ParallelTransactionExecutorCoinOptions;
this.#coinBatchSize = coinOptions.coinBatchSize ?? PARALLEL_EXECUTOR_DEFAULTS.coinBatchSize;
this.#initialCoinBalance =
coinOptions.initialCoinBalance ?? PARALLEL_EXECUTOR_DEFAULTS.initialCoinBalance;
this.#minimumCoinBalance =
coinOptions.minimumCoinBalance ?? PARALLEL_EXECUTOR_DEFAULTS.minimumCoinBalance;
this.#sourceCoins = coinOptions.sourceCoins
? new Map(coinOptions.sourceCoins.map((id) => [id, null]))
: null;
} else {
this.#coinBatchSize = 0;
this.#initialCoinBalance = 0n;
this.#minimumCoinBalance = PARALLEL_EXECUTOR_DEFAULTS.minimumCoinBalance;
this.#sourceCoins = null;
}
this.#defaultGasBudget = options.defaultGasBudget ?? this.#minimumCoinBalance;
this.#maxPoolSize = options.maxPoolSize ?? PARALLEL_EXECUTOR_DEFAULTS.maxPoolSize;
this.#cache = new CachingTransactionExecutor({
client: options.client,
cache: options.cache,
});
this.#executeQueue = new ParallelQueue(this.#maxPoolSize);
}
resetCache() {
this.#epochInfo = null;
return this.#updateCache(() => this.#cache.reset());
}
async waitForLastTransaction() {
await this.#updateCache(() => this.#waitForLastDigest());
}
async executeTransaction<Include extends SuiClientTypes.TransactionInclude = {}>(
transaction: Transaction,
include?: Include & SuiClientTypes.TransactionInclude,
additionalSignatures: string[] = [],
): Promise<SuiClientTypes.TransactionResult<Include & { effects: true }>> {
const { promise, resolve, reject } =
promiseWithResolvers<SuiClientTypes.TransactionResult<Include & { effects: true }>>();
const usedObjects = await this.#getUsedObjects(transaction);
const execute = () => {
this.#executeQueue.runTask(() => {
const promise = this.#execute(transaction, usedObjects, include, additionalSignatures);
return promise.then(resolve, reject);
});
};
const conflicts = new Set<string>();
usedObjects.forEach((objectId) => {
const queue = this.#objectIdQueues.get(objectId);
if (queue) {
conflicts.add(objectId);
this.#objectIdQueues.get(objectId)!.push(() => {
conflicts.delete(objectId);
if (conflicts.size === 0) {
execute();
}
});
} else {
this.#objectIdQueues.set(objectId, []);
}
});
if (conflicts.size === 0) {
execute();
}
return promise;
}
async #getUsedObjects(transaction: Transaction) {
const usedObjects = new Set<string>();
let serialized = false;
transaction.addSerializationPlugin(async (blockData, _options, next) => {
await next();
if (serialized) {
return;
}
serialized = true;
blockData.inputs.forEach((input) => {
if (input.Object?.ImmOrOwnedObject?.objectId) {
usedObjects.add(input.Object.ImmOrOwnedObject.objectId);
} else if (input.Object?.Receiving?.objectId) {
usedObjects.add(input.Object.Receiving.objectId);
} else if (
input.UnresolvedObject?.objectId &&
!input.UnresolvedObject.initialSharedVersion
) {
usedObjects.add(input.UnresolvedObject.objectId);
}
});
});
await transaction.prepareForSerialization({ client: this.#client });
return usedObjects;
}
async #execute<Include extends SuiClientTypes.TransactionInclude = {}>(
transaction: Transaction,
usedObjects: Set<string>,
include?: Include,
additionalSignatures: string[] = [],
): Promise<SuiClientTypes.TransactionResult<Include & { effects: true }>> {
let gasCoin: CoinWithBalance | null = null;
try {
transaction.setSenderIfNotSet(this.#signer.toSuiAddress());
await this.#buildQueue.runTask(async () => {
const data = transaction.getData();
if (!data.gasData.price) {
transaction.setGasPrice(await this.#getGasPrice());
}
transaction.setGasBudgetIfNotSet(this.#defaultGasBudget);
await this.#updateCache();
this.#pendingTransactions++;
if (this.#gasMode === 'addressBalance') {
// Address balance mode: use empty gas payment with ValidDuring expiration
transaction.setGasPayment([]);
transaction.setExpiration(await this.#getValidDuringExpiration());
} else {
// Coin mode: use gas coin from pool
gasCoin = await this.#getGasCoin();
transaction.setGasPayment([
{
objectId: gasCoin.id,
version: gasCoin.version,
digest: gasCoin.digest,
},
]);
}
// Resolve cached references
await this.#cache.buildTransaction({ transaction, onlyTransactionKind: true });
});
const bytes = await transaction.build({ client: this.#client });
const { signature } = await this.#signer.signTransaction(bytes);
const results = await this.#cache.executeTransaction({
transaction: bytes,
signatures: [signature, ...additionalSignatures],
include,
});
const tx = results.$kind === 'Transaction' ? results.Transaction : results.FailedTransaction;
const effects = tx.effects!;
const gasObject = effects.gasObject;
const gasUsed = effects.gasUsed;
if (gasCoin && gasUsed && gasObject) {
const coin = gasCoin as CoinWithBalance;
const gasOwner = gasObject.outputOwner?.AddressOwner ?? gasObject.outputOwner?.ObjectOwner;
if (gasOwner === this.#signer.toSuiAddress()) {
const totalUsed =
BigInt(gasUsed.computationCost) +
BigInt(gasUsed.storageCost) -
BigInt(gasUsed.storageRebate);
const remainingBalance = coin.balance - totalUsed;
let usesGasCoin = false;
new TransactionDataBuilder(transaction.getData()).mapArguments((arg) => {
if (arg.$kind === 'GasCoin') {
usesGasCoin = true;
}
return arg;
});
const gasRef = {
objectId: gasObject.objectId,
version: gasObject.outputVersion!,
digest: gasObject.outputDigest!,
};
if (!usesGasCoin && remainingBalance >= this.#minimumCoinBalance) {
this.#coinPool.push({
id: gasRef.objectId,
version: gasRef.version,
digest: gasRef.digest,
balance: remainingBalance,
});
} else {
if (!this.#sourceCoins) {
this.#sourceCoins = new Map();
}
this.#sourceCoins.set(gasRef.objectId, gasRef);
}
}
}
this.#lastDigest = tx.digest;
return results as SuiClientTypes.TransactionResult<Include & { effects: true }>;
} catch (error) {
if (gasCoin) {
if (!this.#sourceCoins) {
this.#sourceCoins = new Map();
}
this.#sourceCoins.set((gasCoin as CoinWithBalance).id, null);
}
await this.#updateCache(async () => {
await Promise.all([
this.#cache.cache.deleteObjects([...usedObjects]),
this.#waitForLastDigest(),
]);
});
throw error;
} finally {
usedObjects.forEach((objectId) => {
const queue = this.#objectIdQueues.get(objectId);
if (queue && queue.length > 0) {
queue.shift()!();
} else if (queue) {
this.#objectIdQueues.delete(objectId);
}
});
this.#pendingTransactions--;
}
}
/** Helper for synchronizing cache updates, by ensuring only one update happens at a time. This can also be used to wait for any pending cache updates */
async #updateCache(fn?: () => Promise<void>) {
if (this.#cacheLock) {
await this.#cacheLock;
}
this.#cacheLock =
fn?.().then(
() => {
this.#cacheLock = null;
},
() => {},
) ?? null;
}
async #waitForLastDigest() {
const digest = this.#lastDigest;
if (digest) {
this.#lastDigest = null;
await this.#client.core.waitForTransaction({ digest });
}
}
async #getGasCoin() {
if (this.#coinPool.length === 0 && this.#pendingTransactions <= this.#maxPoolSize) {
await this.#refillCoinPool();
}
if (this.#coinPool.length === 0) {
throw new Error('No coins available');
}
const coin = this.#coinPool.shift()!;
return coin;
}
async #getGasPrice(): Promise<bigint> {
await this.#ensureEpochInfo();
return this.#epochInfo!.price;
}
async #getValidDuringExpiration() {
await this.#ensureEpochInfo();
const currentEpoch = BigInt(this.#epochInfo!.epoch);
return {
ValidDuring: {
minEpoch: String(currentEpoch),
maxEpoch: String(currentEpoch + 1n),
minTimestamp: null,
maxTimestamp: null,
chain: this.#epochInfo!.chainIdentifier,
nonce: (Math.random() * 0x100000000) >>> 0,
},
};
}
async #ensureEpochInfo(): Promise<void> {
if (this.#epochInfo && this.#epochInfo.expiration - EPOCH_BOUNDARY_WINDOW - Date.now() > 0) {
return;
}
if (this.#epochInfoPromise) {
await this.#epochInfoPromise;
return;
}
this.#epochInfoPromise = this.#fetchEpochInfo();
try {
await this.#epochInfoPromise;
} finally {
this.#epochInfoPromise = null;
}
}
async #fetchEpochInfo(): Promise<void> {
const [{ systemState }, { chainIdentifier }] = await Promise.all([
this.#client.core.getCurrentSystemState(),
this.#client.core.getChainIdentifier(),
]);
this.#epochInfo = {
epoch: systemState.epoch,
price: BigInt(systemState.referenceGasPrice),
expiration:
Number(systemState.epochStartTimestampMs) + Number(systemState.parameters.epochDurationMs),
chainIdentifier,
};
}
async #refillCoinPool() {
const batchSize = Math.min(
this.#coinBatchSize,
this.#maxPoolSize - (this.#coinPool.length + this.#pendingTransactions) + 1,
);
if (batchSize === 0) {
return;
}
const txb = new Transaction();
const address = this.#signer.toSuiAddress();
txb.setSender(address);
if (this.#sourceCoins) {
const refs = [];
const ids = [];
for (const [id, ref] of this.#sourceCoins) {
if (ref) {
refs.push(ref);
} else {
ids.push(id);
}
}
if (ids.length > 0) {
const { objects } = await this.#client.core.getObjects({
objectIds: ids,
});
refs.push(
...objects
.filter((obj): obj is SuiClientTypes.Object => !(obj instanceof Error))
.map((obj) => ({
objectId: obj.objectId,
version: obj.version,
digest: obj.digest,
})),
);
}
txb.setGasPayment(refs);
this.#sourceCoins = new Map();
}
const amounts = new Array(batchSize).fill(this.#initialCoinBalance);
const splitResults = txb.splitCoins(txb.gas, amounts);
const coinResults = [];
for (let i = 0; i < amounts.length; i++) {
coinResults.push(splitResults[i]);
}
txb.transferObjects(coinResults, address);
await this.waitForLastTransaction();
txb.addBuildPlugin(coreClientResolveTransactionPlugin);
const bytes = await txb.build({ client: this.#client });
const { signature } = await this.#signer.signTransaction(bytes);
const result = await this.#client.core.executeTransaction({
transaction: bytes,
signatures: [signature],
include: { effects: true },
});
const tx = result.$kind === 'Transaction' ? result.Transaction : result.FailedTransaction;
const effects = tx.effects!;
effects.changedObjects.forEach((changedObj) => {
if (
changedObj.objectId === effects.gasObject?.objectId ||
changedObj.outputState !== 'ObjectWrite'
) {
return;
}
this.#coinPool.push({
id: changedObj.objectId,
version: changedObj.outputVersion!,
digest: changedObj.outputDigest!,
balance: BigInt(this.#initialCoinBalance),
});
});
if (!this.#sourceCoins) {
this.#sourceCoins = new Map();
}
const gasObject = effects.gasObject!;
this.#sourceCoins!.set(gasObject.objectId, {
objectId: gasObject.objectId,
version: gasObject.outputVersion!,
digest: gasObject.outputDigest!,
});
await this.#client.core.waitForTransaction({ digest: tx.digest });
}
}