UNPKG

@mysten/sui

Version:
550 lines (481 loc) 15.3 kB
// Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 import { toBase58 } from '@mysten/bcs'; import type { InferInput } from 'valibot'; import { parse } from 'valibot'; import { bcs } from '../bcs/index.js'; import { normalizeSuiAddress } from '../utils/sui-types.js'; import type { Argument, CallArg, Command, GasData, TransactionExpiration, TransactionData, } from './data/internal.js'; import { ArgumentSchema, TransactionDataSchema } from './data/internal.js'; import { transactionDataFromV1 } from './data/v1.js'; import type { SerializedTransactionDataV1 } from './data/v1.js'; import type { SerializedTransactionDataV2Schema } from './data/v2.js'; import { hashTypedData } from './hash.js'; import { getIdFromCallArg, remapCommandArguments } from './utils.js'; import type { TransactionResult } from './Transaction.js'; function prepareSuiAddress(address: string) { return normalizeSuiAddress(address).replace('0x', ''); } export class TransactionDataBuilder implements TransactionData { static fromKindBytes(bytes: Uint8Array) { const kind = bcs.TransactionKind.parse(bytes); const programmableTx = kind.ProgrammableTransaction; if (!programmableTx) { throw new Error('Unable to deserialize from bytes.'); } return TransactionDataBuilder.restore({ version: 2, sender: null, expiration: null, gasData: { budget: null, owner: null, payment: null, price: null, }, inputs: programmableTx.inputs, commands: programmableTx.commands, }); } static fromBytes(bytes: Uint8Array) { const rawData = bcs.TransactionData.parse(bytes); const data = rawData?.V1; const programmableTx = data.kind.ProgrammableTransaction; if (!data || !programmableTx) { throw new Error('Unable to deserialize from bytes.'); } return TransactionDataBuilder.restore({ version: 2, sender: data.sender, expiration: data.expiration, gasData: data.gasData, inputs: programmableTx.inputs, commands: programmableTx.commands, }); } static restore( data: | InferInput<typeof SerializedTransactionDataV2Schema> | InferInput<typeof SerializedTransactionDataV1>, ) { if (data.version === 2) { return new TransactionDataBuilder(parse(TransactionDataSchema, data)); } else { return new TransactionDataBuilder(parse(TransactionDataSchema, transactionDataFromV1(data))); } } /** * Generate transaction digest. * * @param bytes BCS serialized transaction data * @returns transaction digest. */ static getDigestFromBytes(bytes: Uint8Array) { const hash = hashTypedData('TransactionData', bytes); return toBase58(hash); } version = 2 as const; sender: string | null; expiration: TransactionExpiration | null; gasData: GasData; inputs: CallArg[]; commands: Command[]; constructor(clone?: TransactionData) { this.sender = clone?.sender ?? null; this.expiration = clone?.expiration ?? null; this.inputs = clone?.inputs ?? []; this.commands = clone?.commands ?? []; this.gasData = clone?.gasData ?? { budget: null, price: null, owner: null, payment: null, }; } build({ maxSizeBytes = Infinity, overrides, onlyTransactionKind, }: { maxSizeBytes?: number; overrides?: { expiration?: TransactionExpiration; sender?: string; gasData?: Partial<GasData>; }; onlyTransactionKind?: boolean; } = {}) { // TODO validate that inputs and intents are actually resolved const inputs = this.inputs as (typeof bcs.CallArg.$inferInput)[]; const commands = this.commands as Extract< Command<Exclude<Argument, { IntentResult: unknown } | { NestedIntentResult: unknown }>>, { Upgrade: unknown } >[]; const kind = { ProgrammableTransaction: { inputs, commands, }, }; if (onlyTransactionKind) { return bcs.TransactionKind.serialize(kind, { maxSize: maxSizeBytes }).toBytes(); } const expiration = overrides?.expiration ?? this.expiration; const sender = overrides?.sender ?? this.sender; const gasData = { ...this.gasData, ...overrides?.gasData }; if (!sender) { throw new Error('Missing transaction sender'); } if (!gasData.budget) { throw new Error('Missing gas budget'); } if (!gasData.payment) { throw new Error('Missing gas payment'); } if (!gasData.price) { throw new Error('Missing gas price'); } const transactionData = { sender: prepareSuiAddress(sender), expiration: expiration ? expiration : { None: true }, gasData: { payment: gasData.payment, owner: prepareSuiAddress(this.gasData.owner ?? sender), price: BigInt(gasData.price), budget: BigInt(gasData.budget), }, kind: { ProgrammableTransaction: { inputs, commands, }, }, }; return bcs.TransactionData.serialize( { V1: transactionData }, { maxSize: maxSizeBytes }, ).toBytes(); } addInput<T extends 'object' | 'pure' | 'withdrawal'>(type: T, arg: CallArg) { const index = this.inputs.length; this.inputs.push(arg); return { Input: index, type, $kind: 'Input' as const }; } getInputUses(index: number, fn: (arg: Argument, command: Command) => void) { this.mapArguments((arg, command) => { if (arg.$kind === 'Input' && arg.Input === index) { fn(arg, command); } return arg; }); } mapCommandArguments( index: number, fn: (arg: Argument, command: Command, commandIndex: number) => Argument, ) { const command = this.commands[index]; switch (command.$kind) { case 'MoveCall': command.MoveCall.arguments = command.MoveCall.arguments.map((arg) => fn(arg, command, index), ); break; case 'TransferObjects': command.TransferObjects.objects = command.TransferObjects.objects.map((arg) => fn(arg, command, index), ); command.TransferObjects.address = fn(command.TransferObjects.address, command, index); break; case 'SplitCoins': command.SplitCoins.coin = fn(command.SplitCoins.coin, command, index); command.SplitCoins.amounts = command.SplitCoins.amounts.map((arg) => fn(arg, command, index), ); break; case 'MergeCoins': command.MergeCoins.destination = fn(command.MergeCoins.destination, command, index); command.MergeCoins.sources = command.MergeCoins.sources.map((arg) => fn(arg, command, index), ); break; case 'MakeMoveVec': command.MakeMoveVec.elements = command.MakeMoveVec.elements.map((arg) => fn(arg, command, index), ); break; case 'Upgrade': command.Upgrade.ticket = fn(command.Upgrade.ticket, command, index); break; case '$Intent': const inputs = command.$Intent.inputs; command.$Intent.inputs = {}; for (const [key, value] of Object.entries(inputs)) { command.$Intent.inputs[key] = Array.isArray(value) ? value.map((arg) => fn(arg, command, index)) : fn(value, command, index); } break; case 'Publish': break; default: throw new Error(`Unexpected transaction kind: ${(command as { $kind: unknown }).$kind}`); } } mapArguments(fn: (arg: Argument, command: Command, commandIndex: number) => Argument) { for (const commandIndex of this.commands.keys()) { this.mapCommandArguments(commandIndex, fn); } } replaceCommand( index: number, replacement: Command | Command[], resultIndex: number | { Result: number } | { NestedResult: [number, number] } = index, ) { if (!Array.isArray(replacement)) { this.commands[index] = replacement; return; } const sizeDiff = replacement.length - 1; this.commands.splice(index, 1, ...structuredClone(replacement)); this.mapArguments((arg, _command, commandIndex) => { if (commandIndex < index + replacement.length) { return arg; } if (typeof resultIndex !== 'number') { if ( (arg.$kind === 'Result' && arg.Result === index) || (arg.$kind === 'NestedResult' && arg.NestedResult[0] === index) ) { if (!('NestedResult' in arg) || arg.NestedResult[1] === 0) { return parse(ArgumentSchema, structuredClone(resultIndex)); } else { throw new Error( `Cannot replace command ${index} with a specific result type: NestedResult[${index}, ${arg.NestedResult[1]}] references a nested element that cannot be mapped to the replacement result`, ); } } } // Handle adjustment of other references switch (arg.$kind) { case 'Result': if (arg.Result === index && typeof resultIndex === 'number') { arg.Result = resultIndex; } if (arg.Result > index) { arg.Result += sizeDiff; } break; case 'NestedResult': if (arg.NestedResult[0] === index && typeof resultIndex === 'number') { return { $kind: 'NestedResult', NestedResult: [resultIndex, arg.NestedResult[1]], }; } if (arg.NestedResult[0] > index) { arg.NestedResult[0] += sizeDiff; } break; } return arg; }); } replaceCommandWithTransaction( index: number, otherTransaction: TransactionData, result: TransactionResult, ) { if (result.$kind !== 'Result' && result.$kind !== 'NestedResult') { throw new Error('Result must be of kind Result or NestedResult'); } this.insertTransaction(index, otherTransaction); this.replaceCommand( index + otherTransaction.commands.length, [], 'Result' in result ? { NestedResult: [result.Result + index, 0] } : { NestedResult: [ (result as { NestedResult: [number, number] }).NestedResult[0] + index, (result as { NestedResult: [number, number] }).NestedResult[1], ] as [number, number], }, ); } insertTransaction(atCommandIndex: number, otherTransaction: TransactionData) { const inputMapping = new Map<number, number>(); const commandMapping = new Map<number, number>(); for (let i = 0; i < otherTransaction.inputs.length; i++) { const otherInput = otherTransaction.inputs[i]; const id = getIdFromCallArg(otherInput); let existingIndex = -1; if (id !== undefined) { existingIndex = this.inputs.findIndex((input) => getIdFromCallArg(input) === id); if ( existingIndex !== -1 && this.inputs[existingIndex].Object?.SharedObject && otherInput.Object?.SharedObject ) { this.inputs[existingIndex].Object!.SharedObject!.mutable = this.inputs[existingIndex].Object!.SharedObject!.mutable || otherInput.Object.SharedObject.mutable; } } if (existingIndex !== -1) { inputMapping.set(i, existingIndex); } else { const newIndex = this.inputs.length; this.inputs.push(otherInput); inputMapping.set(i, newIndex); } } for (let i = 0; i < otherTransaction.commands.length; i++) { commandMapping.set(i, atCommandIndex + i); } const remappedCommands: Command[] = []; for (let i = 0; i < otherTransaction.commands.length; i++) { const command = structuredClone(otherTransaction.commands[i]); remapCommandArguments(command, inputMapping, commandMapping); remappedCommands.push(command); } this.commands.splice(atCommandIndex, 0, ...remappedCommands); const sizeDiff = remappedCommands.length; if (sizeDiff > 0) { this.mapArguments((arg, _command, commandIndex) => { if ( commandIndex >= atCommandIndex && commandIndex < atCommandIndex + remappedCommands.length ) { return arg; } switch (arg.$kind) { case 'Result': if (arg.Result >= atCommandIndex) { arg.Result += sizeDiff; } break; case 'NestedResult': if (arg.NestedResult[0] >= atCommandIndex) { arg.NestedResult[0] += sizeDiff; } break; } return arg; }); } } getDigest() { const bytes = this.build({ onlyTransactionKind: false }); return TransactionDataBuilder.getDigestFromBytes(bytes); } snapshot(): TransactionData { return parse(TransactionDataSchema, this); } shallowClone() { return new TransactionDataBuilder({ version: this.version, sender: this.sender, expiration: this.expiration, gasData: { ...this.gasData, }, inputs: [...this.inputs], commands: [...this.commands], }); } applyResolvedData(resolved: TransactionData) { if (!this.sender) { this.sender = resolved.sender ?? null; } if (!this.expiration) { this.expiration = resolved.expiration ?? null; } if (!this.gasData.budget) { this.gasData.budget = resolved.gasData.budget; } if (!this.gasData.owner) { this.gasData.owner = resolved.gasData.owner ?? null; } if (!this.gasData.payment) { this.gasData.payment = resolved.gasData.payment; } if (!this.gasData.price) { this.gasData.price = resolved.gasData.price; } for (let i = 0; i < this.inputs.length; i++) { const input = this.inputs[i]; const resolvedInput = resolved.inputs[i]; switch (input.$kind) { case 'UnresolvedPure': if (resolvedInput.$kind !== 'Pure') { throw new Error( `Expected input at index ${i} to resolve to a Pure argument, but got ${JSON.stringify( resolvedInput, )}`, ); } this.inputs[i] = resolvedInput; break; case 'UnresolvedObject': if (resolvedInput.$kind !== 'Object') { throw new Error( `Expected input at index ${i} to resolve to an Object argument, but got ${JSON.stringify( resolvedInput, )}`, ); } if ( resolvedInput.Object.$kind === 'ImmOrOwnedObject' || resolvedInput.Object.$kind === 'Receiving' ) { const original = input.UnresolvedObject; const resolved = resolvedInput.Object.ImmOrOwnedObject ?? resolvedInput.Object.Receiving!; if ( normalizeSuiAddress(original.objectId) !== normalizeSuiAddress(resolved.objectId) || (original.version != null && original.version !== resolved.version) || (original.digest != null && original.digest !== resolved.digest) || // Objects with shared object properties should not resolve to owned objects original.mutable != null || original.initialSharedVersion != null ) { throw new Error( `Input at index ${i} did not match unresolved object. ${JSON.stringify(original)} is not compatible with ${JSON.stringify(resolved)}`, ); } } else if (resolvedInput.Object.$kind === 'SharedObject') { const original = input.UnresolvedObject; const resolved = resolvedInput.Object.SharedObject; if ( normalizeSuiAddress(original.objectId) !== normalizeSuiAddress(resolved.objectId) || (original.initialSharedVersion != null && original.initialSharedVersion !== resolved.initialSharedVersion) || (original.mutable != null && original.mutable !== resolved.mutable) || // Objects with owned object properties should not resolve to shared objects original.version != null || original.digest != null ) { throw new Error( `Input at index ${i} did not match unresolved object. ${JSON.stringify(original)} is not compatible with ${JSON.stringify(resolved)}`, ); } } else { throw new Error( `Input at index ${i} resolved to an unexpected Object kind: ${JSON.stringify( resolvedInput.Object, )}`, ); } this.inputs[i] = resolvedInput; break; } } } }