@mysten/sui
Version:
Sui TypeScript API(Work in Progress)
844 lines (716 loc) • 25 kB
text/typescript
// 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);
}
}