UNPKG

@mysten/sui

Version:

Sui TypeScript API(Work in Progress)

466 lines (395 loc) 13 kB
// Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 import { toB64 } from '@mysten/bcs'; import { bcs } from '../../bcs/index.js'; import type { SuiObjectRef } from '../../bcs/types.js'; import type { SuiClient, SuiTransactionBlockResponse, SuiTransactionBlockResponseOptions, } from '../../client/index.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'; import { getGasCoinFromEffects } from './serial.js'; const PARALLEL_EXECUTOR_DEFAULTS = { coinBatchSize: 20, initialCoinBalance: 200_000_000n, minimumCoinBalance: 50_000_000n, maxPoolSize: 50, epochBoundaryWindow: 1_000, } satisfies Omit<ParallelTransactionExecutorOptions, 'signer' | 'client'>; export interface ParallelTransactionExecutorOptions extends Omit<ObjectCacheOptions, 'address'> { client: SuiClient; signer: Signer; /** 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; /** The gasBudget to use if the transaction has not defined it's own gasBudget, defaults to `minimumCoinBalance` */ defaultGasBudget?: bigint; /** * Time to wait before/after the expected epoch boundary before re-fetching the gas pool (in milliseconds). * Building transactions will be paused for up to 2x this duration around each epoch boundary to ensure the * gas price is up-to-date for the next epoch. * */ epochBoundaryWindow?: number; /** 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; /** An initial list of coins used to fund the gas pool, uses all owned SUI coins by default */ sourceCoins?: string[]; } interface CoinWithBalance { id: string; version: string; digest: string; balance: bigint; } export class ParallelTransactionExecutor { #signer: Signer; #client: SuiClient; #coinBatchSize: number; #initialCoinBalance: bigint; #minimumCoinBalance: bigint; #epochBoundaryWindow: number; #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; #gasPrice: null | { price: bigint; expiration: number; } = null; constructor(options: ParallelTransactionExecutorOptions) { this.#signer = options.signer; this.#client = options.client; this.#coinBatchSize = options.coinBatchSize ?? PARALLEL_EXECUTOR_DEFAULTS.coinBatchSize; this.#initialCoinBalance = options.initialCoinBalance ?? PARALLEL_EXECUTOR_DEFAULTS.initialCoinBalance; this.#minimumCoinBalance = options.minimumCoinBalance ?? PARALLEL_EXECUTOR_DEFAULTS.minimumCoinBalance; this.#defaultGasBudget = options.defaultGasBudget ?? this.#minimumCoinBalance; this.#epochBoundaryWindow = options.epochBoundaryWindow ?? PARALLEL_EXECUTOR_DEFAULTS.epochBoundaryWindow; this.#maxPoolSize = options.maxPoolSize ?? PARALLEL_EXECUTOR_DEFAULTS.maxPoolSize; this.#cache = new CachingTransactionExecutor({ client: options.client, cache: options.cache, }); this.#executeQueue = new ParallelQueue(this.#maxPoolSize); this.#sourceCoins = options.sourceCoins ? new Map(options.sourceCoins.map((id) => [id, null])) : null; } resetCache() { this.#gasPrice = null; return this.#updateCache(() => this.#cache.reset()); } async waitForLastTransaction() { await this.#updateCache(() => this.#waitForLastDigest()); } async executeTransaction(transaction: Transaction, options?: SuiTransactionBlockResponseOptions) { const { promise, resolve, reject } = promiseWithResolvers<{ digest: string; effects: string; data: SuiTransactionBlockResponse; }>(); const usedObjects = await this.#getUsedObjects(transaction); const execute = () => { this.#executeQueue.runTask(() => { const promise = this.#execute(transaction, usedObjects, options); 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( transaction: Transaction, usedObjects: Set<string>, options?: SuiTransactionBlockResponseOptions, ) { let gasCoin!: CoinWithBalance; 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(); gasCoin = await this.#getGasCoin(); this.#pendingTransactions++; 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, signature, options: { ...options, showEffects: true, }, }); const effectsBytes = Uint8Array.from(results.rawEffects!); const effects = bcs.TransactionEffects.parse(effectsBytes); const gasResult = getGasCoinFromEffects(effects); const gasUsed = effects.V2?.gasUsed; if (gasCoin && gasUsed && gasResult.owner === this.#signer.toSuiAddress()) { const totalUsed = BigInt(gasUsed.computationCost) + BigInt(gasUsed.storageCost) + BigInt(gasUsed.storageCost) - BigInt(gasUsed.storageRebate); let usesGasCoin = false; new TransactionDataBuilder(transaction.getData()).mapArguments((arg) => { if (arg.$kind === 'GasCoin') { usesGasCoin = true; } return arg; }); if (!usesGasCoin && gasCoin.balance >= this.#minimumCoinBalance) { this.#coinPool.push({ id: gasResult.ref.objectId, version: gasResult.ref.version, digest: gasResult.ref.digest, balance: gasCoin.balance - totalUsed, }); } else { if (!this.#sourceCoins) { this.#sourceCoins = new Map(); } this.#sourceCoins.set(gasResult.ref.objectId, gasResult.ref); } } this.#lastDigest = results.digest; return { digest: results.digest, effects: toB64(effectsBytes), data: results, }; } catch (error) { if (gasCoin) { if (!this.#sourceCoins) { this.#sourceCoins = new Map(); } this.#sourceCoins.set(gasCoin.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.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> { const remaining = this.#gasPrice ? this.#gasPrice.expiration - this.#epochBoundaryWindow - Date.now() : 0; if (remaining > 0) { return this.#gasPrice!.price; } if (this.#gasPrice) { const timeToNextEpoch = Math.max( this.#gasPrice.expiration + this.#epochBoundaryWindow - Date.now(), 1_000, ); await new Promise((resolve) => setTimeout(resolve, timeToNextEpoch)); } const state = await this.#client.getLatestSuiSystemState(); this.#gasPrice = { price: BigInt(state.referenceGasPrice), expiration: Number.parseInt(state.epochStartTimestampMs, 10) + Number.parseInt(state.epochDurationMs, 10), }; return this.#getGasPrice(); } 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 coins = await this.#client.multiGetObjects({ ids, }); refs.push( ...coins .filter((coin): coin is typeof coin & { data: object } => coin.data !== null) .map(({ data }) => ({ objectId: data.objectId, version: data.version, digest: data.digest, })), ); } txb.setGasPayment(refs); this.#sourceCoins = new Map(); } const amounts = new Array(batchSize).fill(this.#initialCoinBalance); const results = txb.splitCoins(txb.gas, amounts); const coinResults = []; for (let i = 0; i < amounts.length; i++) { coinResults.push(results[i]); } txb.transferObjects(coinResults, address); await this.waitForLastTransaction(); const result = await this.#client.signAndExecuteTransaction({ transaction: txb, signer: this.#signer, options: { showRawEffects: true, }, }); const effects = bcs.TransactionEffects.parse(Uint8Array.from(result.rawEffects!)); effects.V2?.changedObjects.forEach(([id, { outputState }], i) => { if (i === effects.V2?.gasObjectIndex || !outputState.ObjectWrite) { return; } this.#coinPool.push({ id, version: effects.V2!.lamportVersion, digest: outputState.ObjectWrite[0], balance: BigInt(this.#initialCoinBalance), }); }); if (!this.#sourceCoins) { this.#sourceCoins = new Map(); } const gasObject = getGasCoinFromEffects(effects).ref; this.#sourceCoins!.set(gasObject.objectId, gasObject); await this.#client.waitForTransaction({ digest: result.digest }); } } function promiseWithResolvers<T>() { let resolve: (value: T) => void; let reject: (reason: any) => void; const promise = new Promise<T>((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); return { promise, resolve: resolve!, reject: reject! }; }