UNPKG

@mysten/sui

Version:

Sui TypeScript API(Work in Progress)

844 lines (716 loc) 25 kB
// Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 import type { SerializedBcs } from '@mysten/bcs'; import { fromBase64, isSerializedBcs } from '@mysten/bcs'; import type { InferInput } from 'valibot'; import { is, parse } from 'valibot'; import type { SuiClient } from '../client/index.js'; import type { SignatureWithBytes, Signer } from '../cryptography/index.js'; import { normalizeSuiAddress } from '../utils/sui-types.js'; import type { TransactionArgument } from './Commands.js'; import { Commands } from './Commands.js'; import type { CallArg, Command } from './data/internal.js'; import { Argument, NormalizedCallArg, ObjectRef, TransactionExpiration } from './data/internal.js'; import { serializeV1TransactionData } from './data/v1.js'; import { SerializedTransactionDataV2 } from './data/v2.js'; import { Inputs } from './Inputs.js'; import type { BuildTransactionOptions, SerializeTransactionOptions, TransactionPlugin, } from './json-rpc-resolver.js'; import { resolveTransactionData } from './json-rpc-resolver.js'; import { createObjectMethods } from './object.js'; import { createPure } from './pure.js'; import { TransactionDataBuilder } from './TransactionData.js'; import { getIdFromCallArg } from './utils.js'; export type TransactionObjectArgument = | Exclude<InferInput<typeof Argument>, { Input: unknown; type?: 'pure' }> | ((tx: Transaction) => Exclude<InferInput<typeof Argument>, { Input: unknown; type?: 'pure' }>) | AsyncTransactionThunk<TransactionResultArgument>; export type TransactionResult = Extract<Argument, { Result: unknown }> & Extract<Argument, { NestedResult: unknown }>[]; export type TransactionResultArgument = | Extract<Argument, { Result: unknown }> | readonly Extract<Argument, { NestedResult: unknown }>[]; export type AsyncTransactionThunk< T extends TransactionResultArgument | void = TransactionResultArgument | void, > = (tx: Transaction) => Promise<T | void>; function createTransactionResult(index: number, length = Infinity): TransactionResult { const baseResult = { $kind: 'Result' as const, Result: index }; const nestedResults: { $kind: 'NestedResult'; NestedResult: [number, number]; }[] = []; const nestedResultFor = ( resultIndex: number, ): { $kind: 'NestedResult'; NestedResult: [number, number]; } => (nestedResults[resultIndex] ??= { $kind: 'NestedResult' as const, NestedResult: [index, resultIndex], }); return new Proxy(baseResult, { set() { throw new Error( 'The transaction result is a proxy, and does not support setting properties directly', ); }, // TODO: Instead of making this return a concrete argument, we should ideally // make it reference-based (so that this gets resolved at build-time), which // allows re-ordering transactions. get(target, property) { // This allows this transaction argument to be used in the singular form: if (property in target) { return Reflect.get(target, property); } // Support destructuring: if (property === Symbol.iterator) { return function* () { let i = 0; while (i < length) { yield nestedResultFor(i); i++; } }; } if (typeof property === 'symbol') return; const resultIndex = parseInt(property, 10); if (Number.isNaN(resultIndex) || resultIndex < 0) return; return nestedResultFor(resultIndex); }, }) as TransactionResult; } const TRANSACTION_BRAND = Symbol.for('@mysten/transaction') as never; interface SignOptions extends BuildTransactionOptions { signer: Signer; } export function isTransaction(obj: unknown): obj is Transaction { return !!obj && typeof obj === 'object' && (obj as any)[TRANSACTION_BRAND] === true; } export type TransactionObjectInput = string | CallArg | TransactionObjectArgument; interface TransactionPluginRegistry { // eslint-disable-next-line @typescript-eslint/ban-types buildPlugins: Map<string | Function, TransactionPlugin>; // eslint-disable-next-line @typescript-eslint/ban-types serializationPlugins: Map<string | Function, TransactionPlugin>; } const modulePluginRegistry: TransactionPluginRegistry = { buildPlugins: new Map(), serializationPlugins: new Map(), }; const TRANSACTION_REGISTRY_KEY = Symbol.for('@mysten/transaction/registry'); function getGlobalPluginRegistry() { try { const target = globalThis as { [TRANSACTION_REGISTRY_KEY]?: TransactionPluginRegistry; }; if (!target[TRANSACTION_REGISTRY_KEY]) { target[TRANSACTION_REGISTRY_KEY] = modulePluginRegistry; } return target[TRANSACTION_REGISTRY_KEY]; } catch (e) { return modulePluginRegistry; } } type InputSection = (CallArg | InputSection)[]; type CommandSection = (Command | CommandSection)[]; /** * Transaction Builder */ export class Transaction { #serializationPlugins: TransactionPlugin[]; #buildPlugins: TransactionPlugin[]; #intentResolvers = new Map<string, TransactionPlugin>(); #inputSection: InputSection = []; #commandSection: CommandSection = []; #availableResults: Set<number> = new Set(); #pendingPromises = new Set<Promise<unknown>>(); #added = new Map<(...args: any[]) => unknown, unknown>(); /** * Converts from a serialize transaction kind (built with `build({ onlyTransactionKind: true })`) to a `Transaction` class. * Supports either a byte array, or base64-encoded bytes. */ static fromKind(serialized: string | Uint8Array) { const tx = new Transaction(); tx.#data = TransactionDataBuilder.fromKindBytes( typeof serialized === 'string' ? fromBase64(serialized) : serialized, ); tx.#inputSection = tx.#data.inputs; tx.#commandSection = tx.#data.commands; return tx; } /** * Converts from a serialized transaction format to a `Transaction` class. * There are two supported serialized formats: * - A string returned from `Transaction#serialize`. The serialized format must be compatible, or it will throw an error. * - A byte array (or base64-encoded bytes) containing BCS transaction data. */ static from(transaction: string | Uint8Array | Transaction) { const newTransaction = new Transaction(); if (isTransaction(transaction)) { newTransaction.#data = new TransactionDataBuilder(transaction.getData()); } else if (typeof transaction !== 'string' || !transaction.startsWith('{')) { newTransaction.#data = TransactionDataBuilder.fromBytes( typeof transaction === 'string' ? fromBase64(transaction) : transaction, ); } else { newTransaction.#data = TransactionDataBuilder.restore(JSON.parse(transaction)); } newTransaction.#inputSection = newTransaction.#data.inputs; newTransaction.#commandSection = newTransaction.#data.commands; return newTransaction; } /** @deprecated global plugins should be registered with a name */ static registerGlobalSerializationPlugin(step: TransactionPlugin): void; static registerGlobalSerializationPlugin(name: string, step: TransactionPlugin): void; static registerGlobalSerializationPlugin( stepOrStep: TransactionPlugin | string, step?: TransactionPlugin, ) { getGlobalPluginRegistry().serializationPlugins.set( stepOrStep, step ?? (stepOrStep as TransactionPlugin), ); } static unregisterGlobalSerializationPlugin(name: string) { getGlobalPluginRegistry().serializationPlugins.delete(name); } /** @deprecated global plugins should be registered with a name */ static registerGlobalBuildPlugin(step: TransactionPlugin): void; static registerGlobalBuildPlugin(name: string, step: TransactionPlugin): void; static registerGlobalBuildPlugin( stepOrStep: TransactionPlugin | string, step?: TransactionPlugin, ) { getGlobalPluginRegistry().buildPlugins.set( stepOrStep, step ?? (stepOrStep as TransactionPlugin), ); } static unregisterGlobalBuildPlugin(name: string) { getGlobalPluginRegistry().buildPlugins.delete(name); } addSerializationPlugin(step: TransactionPlugin) { this.#serializationPlugins.push(step); } addBuildPlugin(step: TransactionPlugin) { this.#buildPlugins.push(step); } addIntentResolver(intent: string, resolver: TransactionPlugin) { if (this.#intentResolvers.has(intent) && this.#intentResolvers.get(intent) !== resolver) { throw new Error(`Intent resolver for ${intent} already exists`); } this.#intentResolvers.set(intent, resolver); } setSender(sender: string) { this.#data.sender = sender; } /** * Sets the sender only if it has not already been set. * This is useful for sponsored transaction flows where the sender may not be the same as the signer address. */ setSenderIfNotSet(sender: string) { if (!this.#data.sender) { this.#data.sender = sender; } } setExpiration(expiration?: InferInput<typeof TransactionExpiration> | null) { this.#data.expiration = expiration ? parse(TransactionExpiration, expiration) : null; } setGasPrice(price: number | bigint) { this.#data.gasConfig.price = String(price); } setGasBudget(budget: number | bigint) { this.#data.gasConfig.budget = String(budget); } setGasBudgetIfNotSet(budget: number | bigint) { if (this.#data.gasData.budget == null) { this.#data.gasConfig.budget = String(budget); } } setGasOwner(owner: string) { this.#data.gasConfig.owner = owner; } setGasPayment(payments: ObjectRef[]) { this.#data.gasConfig.payment = payments.map((payment) => parse(ObjectRef, payment)); } #data: TransactionDataBuilder; /** @deprecated Use `getData()` instead. */ get blockData() { return serializeV1TransactionData(this.#data.snapshot()); } /** Get a snapshot of the transaction data, in JSON form: */ getData() { return this.#data.snapshot(); } // Used to brand transaction classes so that they can be identified, even between multiple copies // of the builder. get [TRANSACTION_BRAND]() { return true; } // Temporary workaround for the wallet interface accidentally serializing transactions via postMessage get pure(): ReturnType<typeof createPure<Argument>> { Object.defineProperty(this, 'pure', { enumerable: false, value: createPure<Argument>((value): Argument => { if (isSerializedBcs(value)) { return this.#addInput('pure', { $kind: 'Pure', Pure: { bytes: value.toBase64(), }, }); } // TODO: we can also do some deduplication here return this.#addInput( 'pure', is(NormalizedCallArg, value) ? parse(NormalizedCallArg, value) : value instanceof Uint8Array ? Inputs.Pure(value) : { $kind: 'UnresolvedPure', UnresolvedPure: { value } }, ); }), }); return this.pure; } constructor() { const globalPlugins = getGlobalPluginRegistry(); this.#data = new TransactionDataBuilder(); this.#buildPlugins = [...globalPlugins.buildPlugins.values()]; this.#serializationPlugins = [...globalPlugins.serializationPlugins.values()]; } /** Returns an argument for the gas coin, to be used in a transaction. */ get gas() { return { $kind: 'GasCoin' as const, GasCoin: true as const }; } /** * Add a new object input to the transaction. */ object: ReturnType< typeof createObjectMethods<{ $kind: 'Input'; Input: number; type?: 'object' }> > = createObjectMethods( (value: TransactionObjectInput): { $kind: 'Input'; Input: number; type?: 'object' } => { if (typeof value === 'function') { return this.object(this.add(value as (tx: Transaction) => TransactionObjectArgument)); } if (typeof value === 'object' && is(Argument, value)) { return value as { $kind: 'Input'; Input: number; type?: 'object' }; } const id = getIdFromCallArg(value); const inserted = this.#data.inputs.find((i) => id === getIdFromCallArg(i)); // Upgrade shared object inputs to mutable if needed: if ( inserted?.Object?.SharedObject && typeof value === 'object' && value.Object?.SharedObject ) { inserted.Object.SharedObject.mutable = inserted.Object.SharedObject.mutable || value.Object.SharedObject.mutable; } return inserted ? { $kind: 'Input', Input: this.#data.inputs.indexOf(inserted), type: 'object' } : this.#addInput( 'object', typeof value === 'string' ? { $kind: 'UnresolvedObject', UnresolvedObject: { objectId: normalizeSuiAddress(value) }, } : value, ); }, ); /** * Add a new object input to the transaction using the fully-resolved object reference. * If you only have an object ID, use `builder.object(id)` instead. */ objectRef(...args: Parameters<(typeof Inputs)['ObjectRef']>) { return this.object(Inputs.ObjectRef(...args)); } /** * Add a new receiving input to the transaction using the fully-resolved object reference. * If you only have an object ID, use `builder.object(id)` instead. */ receivingRef(...args: Parameters<(typeof Inputs)['ReceivingRef']>) { return this.object(Inputs.ReceivingRef(...args)); } /** * Add a new shared object input to the transaction using the fully-resolved shared object reference. * If you only have an object ID, use `builder.object(id)` instead. */ sharedObjectRef(...args: Parameters<(typeof Inputs)['SharedObjectRef']>) { return this.object(Inputs.SharedObjectRef(...args)); } #fork() { const fork = new Transaction(); fork.#data = this.#data; fork.#serializationPlugins = this.#serializationPlugins; fork.#buildPlugins = this.#buildPlugins; fork.#intentResolvers = this.#intentResolvers; fork.#pendingPromises = this.#pendingPromises; fork.#availableResults = new Set(this.#availableResults); fork.#added = this.#added; this.#inputSection.push(fork.#inputSection); this.#commandSection.push(fork.#commandSection); return fork; } /** Add a transaction to the transaction */ add<T extends Command>(command: T): TransactionResult; add<T extends void | TransactionResultArgument | TransactionArgument | Command>( thunk: (tx: Transaction) => T, ): T; add<T extends TransactionResultArgument | void>( asyncTransactionThunk: AsyncTransactionThunk<T>, ): T; add(command: Command | AsyncTransactionThunk | ((tx: Transaction) => unknown)): unknown { if (typeof command === 'function') { if (this.#added.has(command)) { return this.#added.get(command); } const fork = this.#fork(); const result = command(fork); if (!(result && typeof result === 'object' && 'then' in result)) { this.#availableResults = fork.#availableResults; this.#added.set(command, result); return result; } const placeholder = this.#addCommand({ $kind: '$Intent', $Intent: { name: 'AsyncTransactionThunk', inputs: {}, data: { result: null as TransactionResult | null, }, }, }); this.#pendingPromises.add( Promise.resolve(result as Promise<TransactionResult>).then((result) => { placeholder.$Intent.data.result = result; }), ); const txResult = createTransactionResult(this.#data.commands.length - 1); this.#added.set(command, txResult); return txResult; } else { this.#addCommand(command); } return createTransactionResult(this.#data.commands.length - 1); } #addCommand<T extends Command>(command: T) { const resultIndex = this.#data.commands.length; this.#commandSection.push(command); this.#availableResults.add(resultIndex); this.#data.commands.push(command); this.#data.mapCommandArguments(resultIndex, (arg) => { if (arg.$kind === 'Result' && !this.#availableResults.has(arg.Result)) { throw new Error( `Result { Result: ${arg.Result} } is not available to use the current transaction`, ); } if (arg.$kind === 'NestedResult' && !this.#availableResults.has(arg.NestedResult[0])) { throw new Error( `Result { NestedResult: [${arg.NestedResult[0]}, ${arg.NestedResult[1]}] } is not available to use the current transaction`, ); } if (arg.$kind === 'Input' && arg.Input >= this.#data.inputs.length) { throw new Error( `Input { Input: ${arg.Input} } references an input that does not exist in the current transaction`, ); } return arg; }); return command; } #addInput<T extends 'pure' | 'object'>(type: T, input: CallArg) { this.#inputSection.push(input); return this.#data.addInput(type, input); } #normalizeTransactionArgument(arg: TransactionArgument | SerializedBcs<any>) { if (isSerializedBcs(arg)) { return this.pure(arg); } return this.#resolveArgument(arg as TransactionArgument); } #resolveArgument(arg: TransactionArgument): Argument { if (typeof arg === 'function') { const resolved = this.add(arg as never); if (typeof resolved === 'function') { return this.#resolveArgument(resolved); } return parse(Argument, resolved); } return parse(Argument, arg); } // Method shorthands: splitCoins< const Amounts extends (TransactionArgument | SerializedBcs<any> | number | string | bigint)[], >(coin: TransactionObjectArgument | string, amounts: Amounts) { const command = Commands.SplitCoins( typeof coin === 'string' ? this.object(coin) : this.#resolveArgument(coin), amounts.map((amount) => typeof amount === 'number' || typeof amount === 'bigint' || typeof amount === 'string' ? this.pure.u64(amount) : this.#normalizeTransactionArgument(amount), ), ); this.#addCommand(command); return createTransactionResult(this.#data.commands.length - 1, amounts.length) as Extract< Argument, { Result: unknown } > & { [K in keyof Amounts]: Extract<Argument, { NestedResult: unknown }>; }; } mergeCoins( destination: TransactionObjectArgument | string, sources: (TransactionObjectArgument | string)[], ) { return this.add( Commands.MergeCoins( this.object(destination), sources.map((src) => this.object(src)), ), ); } publish({ modules, dependencies }: { modules: number[][] | string[]; dependencies: string[] }) { return this.add( Commands.Publish({ modules, dependencies, }), ); } upgrade({ modules, dependencies, package: packageId, ticket, }: { modules: number[][] | string[]; dependencies: string[]; package: string; ticket: TransactionObjectArgument | string; }) { return this.add( Commands.Upgrade({ modules, dependencies, package: packageId, ticket: this.object(ticket), }), ); } moveCall({ arguments: args, ...input }: | { package: string; module: string; function: string; arguments?: (TransactionArgument | SerializedBcs<any>)[]; typeArguments?: string[]; } | { target: string; arguments?: (TransactionArgument | SerializedBcs<any>)[]; typeArguments?: string[]; }) { return this.add( Commands.MoveCall({ ...input, arguments: args?.map((arg) => this.#normalizeTransactionArgument(arg)), } as Parameters<typeof Commands.MoveCall>[0]), ); } transferObjects( objects: (TransactionObjectArgument | string)[], address: TransactionArgument | SerializedBcs<any> | string, ) { return this.add( Commands.TransferObjects( objects.map((obj) => this.object(obj)), typeof address === 'string' ? this.pure.address(address) : this.#normalizeTransactionArgument(address), ), ); } makeMoveVec({ type, elements, }: { elements: (TransactionObjectArgument | string)[]; type?: string; }) { return this.add( Commands.MakeMoveVec({ type, elements: elements.map((obj) => this.object(obj)), }), ); } /** * @deprecated Use toJSON instead. * For synchronous serialization, you can use `getData()` * */ serialize() { return JSON.stringify(serializeV1TransactionData(this.#data.snapshot())); } async toJSON(options: SerializeTransactionOptions = {}): Promise<string> { await this.prepareForSerialization(options); return JSON.stringify( parse(SerializedTransactionDataV2, this.#data.snapshot()), (_key, value) => (typeof value === 'bigint' ? value.toString() : value), 2, ); } /** Build the transaction to BCS bytes, and sign it with the provided keypair. */ async sign(options: SignOptions): Promise<SignatureWithBytes> { const { signer, ...buildOptions } = options; const bytes = await this.build(buildOptions); return signer.signTransaction(bytes); } /** Build the transaction to BCS bytes. */ async build(options: BuildTransactionOptions = {}): Promise<Uint8Array> { await this.prepareForSerialization(options); await this.#prepareBuild(options); return this.#data.build({ onlyTransactionKind: options.onlyTransactionKind, }); } /** Derive transaction digest */ async getDigest( options: { client?: SuiClient; } = {}, ): Promise<string> { await this.#prepareBuild(options); return this.#data.getDigest(); } /** * Prepare the transaction by validating the transaction data and resolving all inputs * so that it can be built into bytes. */ async #prepareBuild(options: BuildTransactionOptions) { if (!options.onlyTransactionKind && !this.#data.sender) { throw new Error('Missing transaction sender'); } await this.#runPlugins([...this.#buildPlugins, resolveTransactionData], options); } async #runPlugins(plugins: TransactionPlugin[], options: SerializeTransactionOptions) { const createNext = (i: number) => { if (i >= plugins.length) { return () => {}; } const plugin = plugins[i]; return async () => { const next = createNext(i + 1); let calledNext = false; let nextResolved = false; await plugin(this.#data, options, async () => { if (calledNext) { throw new Error(`next() was call multiple times in TransactionPlugin ${i}`); } calledNext = true; await next(); nextResolved = true; }); if (!calledNext) { throw new Error(`next() was not called in TransactionPlugin ${i}`); } if (!nextResolved) { throw new Error(`next() was not awaited in TransactionPlugin ${i}`); } }; }; await createNext(0)(); this.#inputSection = this.#data.inputs; this.#commandSection = this.#data.commands; } async #waitForPendingTasks() { while (this.#pendingPromises.size > 0) { const newPromise = Promise.all(this.#pendingPromises); this.#pendingPromises.clear(); this.#pendingPromises.add(newPromise); await newPromise; this.#pendingPromises.delete(newPromise); } } #sortCommandsAndInputs() { const unorderedCommands = this.#data.commands; const unorderedInputs = this.#data.inputs; const orderedCommands = (this.#commandSection as Command[]).flat(Infinity); const orderedInputs = (this.#inputSection as CallArg[]).flat(Infinity); if (orderedCommands.length !== unorderedCommands.length) { throw new Error('Unexpected number of commands found in transaction data'); } if (orderedInputs.length !== unorderedInputs.length) { throw new Error('Unexpected number of inputs found in transaction data'); } const filteredCommands = orderedCommands.filter( (cmd) => cmd.$Intent?.name !== 'AsyncTransactionThunk', ); this.#data.commands = filteredCommands; this.#data.inputs = orderedInputs; this.#commandSection = filteredCommands; this.#inputSection = orderedInputs; function getOriginalIndex(index: number): number { const command = unorderedCommands[index]; if (command.$Intent?.name === 'AsyncTransactionThunk') { const result = command.$Intent.data.result as TransactionResult | null; if (result == null) { throw new Error('AsyncTransactionThunk has not been resolved'); } return getOriginalIndex(result.Result); } const updated = filteredCommands.indexOf(command); if (updated === -1) { throw new Error('Unable to find original index for command'); } return updated; } this.#data.mapArguments((arg) => { if (arg.$kind === 'Input') { const updated = orderedInputs.indexOf(unorderedInputs[arg.Input]); if (updated === -1) { throw new Error('Input has not been resolved'); } return { ...arg, Input: updated }; } else if (arg.$kind === 'Result') { const updated = getOriginalIndex(arg.Result); return { ...arg, Result: updated }; } else if (arg.$kind === 'NestedResult') { const updated = getOriginalIndex(arg.NestedResult[0]); return { ...arg, NestedResult: [updated, arg.NestedResult[1]] }; } return arg; }); } async prepareForSerialization(options: SerializeTransactionOptions) { await this.#waitForPendingTasks(); this.#sortCommandsAndInputs(); const intents = new Set<string>(); for (const command of this.#data.commands) { if (command.$Intent) { intents.add(command.$Intent.name); } } const steps = [...this.#serializationPlugins]; for (const intent of intents) { if (options.supportedIntents?.includes(intent)) { continue; } if (!this.#intentResolvers.has(intent)) { throw new Error(`Missing intent resolver for ${intent}`); } steps.push(this.#intentResolvers.get(intent)!); } await this.#runPlugins(steps, options); } }