UNPKG

@layerzerolabs/lz-sui-sdk-v2

Version:

358 lines (335 loc) 16.6 kB
import { bcs } from '@mysten/sui/bcs' import { SuiClient } from '@mysten/sui/client' import { Transaction, TransactionResult } from '@mysten/sui/transactions' import { OAppInfoV1Bcs, VectorMoveCallBCS } from '../../bcs' import { Argument, BuilderPlaceholderInfo, DEFAULT_SIMULATION_TIMES, LzComposeVersion, MoveCall, OAppInfoVersion, SimulateResult, } from '../../types' import { simulateTransaction } from '../../utils/transaction' import { normalizeSuiPackageId } from '../../utils/type-name' const MOVE_CALL_MODULE_NAME = 'move_call' export const PtbBuilderErrorCode = { // MoveCallsBuilder related errors (matching move_calls_builder.move) MoveCallsBuilder_EInvalidMoveCallResult: 1, MoveCallsBuilder_EResultIDNotFound: 2, // Argument related errors (matching argument.move) Argument_EInvalidArgument: 1, } as const export class PtbBuilder { public packageId: string public readonly client: SuiClient constructor(packageId: string, client: SuiClient) { this.packageId = packageId this.client = client } /** * Simulate a transaction and decode the resulting move calls * @param tx - The transaction to simulate * @param sender - Optional sender address for simulation context * @returns Promise resolving to array of decoded move calls */ async simulatePtb(tx: Transaction, sender?: string): Promise<MoveCall[]> { const ptbCallsResult = await simulateTransaction(this.client, tx, sender) return this.#decodeMoveCalls(ptbCallsResult[0]) } /** * Simulate a LayerZero receive transaction and decode the move calls * Handles versioned receive data and decodes based on version * @param tx - The transaction to simulate * @param sender - Optional sender address for simulation context * @returns Promise resolving to array of decoded move calls * @throws Error if unsupported version is encountered */ async simulateLzReceivePtb(tx: Transaction, sender?: string): Promise<MoveCall[]> { const ptbCallsResult = await simulateTransaction(this.client, tx, sender) const buffer = Buffer.from(bcs.vector(bcs.u8()).parse(ptbCallsResult[0].value)) const version = buffer.readInt16BE() if (version === OAppInfoVersion.VERSION_1) { return this.#decodeMoveCallsFromOAppInfoV1(new Uint8Array(buffer.subarray(2))) } throw new Error(`Unknown version: ${version}`) } /** * Simulate a LayerZero compose transaction and decode the move calls * Handles versioned compose data and decodes based on version * @param tx - The transaction to simulate * @param sender - Optional sender address for simulation context * @returns Promise resolving to array of decoded move calls * @throws Error if unsupported version is encountered */ async simulateLzComposePtb(tx: Transaction, sender?: string): Promise<MoveCall[]> { const ptbCallsResult = await simulateTransaction(this.client, tx, sender) const buffer = Buffer.from(bcs.vector(bcs.u8()).parse(ptbCallsResult[0].value)) const version = buffer.readInt16BE() if (version === LzComposeVersion.VERSION_1) { return this.#decodeMoveCallsBytes(new Uint8Array(buffer.subarray(2))) } throw new Error(`Unknown version: ${version}`) } /** * Builds PTB with move-calls simulated from the transaction * * This method processes an array of move calls, handling both regular calls and builder calls * (which require simulation to expand into actual move calls). It ensures all object arguments * are properly validated for PTB compatibility. * * @param tx - The transaction to append move calls to * @param moveCalls - Array of move calls to process and build * @param resolutionIDs - Cache mapping call IDs to their transaction results for argument resolution (defaults to empty Map) * @param sender - Optional sender address for simulation context (defaults to undefined) * @param maxSimulationTimes - Maximum number of simulations allowed for builder calls (defaults to DEFAULT_SIMULATION_TIMES) * @param nestedResult - Array storing results from previous calls for NestedResult argument resolution (internal use, defaults to empty array) * @param baseOffset - Base offset for calculating nested result indices (internal use, defaults to 0) * * @returns Promise<[number, MoveCall[]]> - [moveCallCount, finalMoveCalls] tuple * * @throws Error if simulation limit is exceeded, nested results are unavailable, or objects are not PTB-compatible */ async buildPtb( tx: Transaction, moveCalls: MoveCall[], resolutionIDs = new Map<string, TransactionResult>(), // ID -> TransactionResult sender: string | undefined = undefined, maxSimulationTimes = DEFAULT_SIMULATION_TIMES, // -- below are internal use only -- nestedResult: TransactionResult[] = [], baseOffset = 0 ): Promise<[number, MoveCall[]]> { const finalMoveCalls: MoveCall[] = [] // This array collects all move calls for validation const moveCallCount = await this.#buildMoveCalls( tx, moveCalls, resolutionIDs, sender, maxSimulationTimes, finalMoveCalls, nestedResult, baseOffset ) return [moveCallCount, finalMoveCalls] } /** * Internal method to recursively build and process move calls * Handles both regular and builder calls with simulation and argument resolution * @param tx - The transaction to add move calls to * @param moveCalls - Array of move calls to process * @param resolutionIDs - Map for resolving call ID arguments * @param sender - Optional sender address for simulation * @param remainingSimulation - Remaining simulation attempts allowed * @param finalMoveCalls - Array collecting all final move calls * @param nestedResult - Array of transaction results for nested argument resolution * @param baseOffset - Base offset for calculating nested result indices * @returns Promise resolving to the number of move calls processed * @private */ async #buildMoveCalls( tx: Transaction, moveCalls: MoveCall[], resolutionIDs: Map<string, TransactionResult>, // ID -> TransactionResult sender: string | undefined = undefined, // -- below are internal use only -- remainingSimulation: number, finalMoveCalls: MoveCall[], nestedResult: TransactionResult[] = [], baseOffset = 0 ): Promise<number> { if (!moveCalls.length) return 0 let builderMoveCallCount = 0 // current builder move_calls count const placeholderInfos: BuilderPlaceholderInfo[] = [] for (let currentMoveCallIndex = 0; currentMoveCallIndex < moveCalls.length; currentMoveCallIndex++) { const moveCall = moveCalls[currentMoveCallIndex] // Create a copy to avoid mutating the original const processedMoveCall = { ...moveCall, arguments: moveCall.arguments.map((arg) => { // Adjust the index by the base offset and the placeholder count if ('NestedResult' in arg) { const [callIndex, resultIndex] = arg.NestedResult const newCallIndex = this.#calculateOffset(callIndex, baseOffset, placeholderInfos) const result: Argument = { NestedResult: [newCallIndex, resultIndex] } return result } return arg }), } if (!moveCall.is_builder_call) { finalMoveCalls.push(processedMoveCall) this.#appendMoveCall(tx, processedMoveCall, resolutionIDs, nestedResult, true) } else { builderMoveCallCount++ if (remainingSimulation <= 0) { throw new Error('remainingSimulation is not enough') } remainingSimulation-- // The simluate tx is simluated to get the actual move calls of the contract to be replaced in the next layer const simulateTx = Transaction.from(tx) this.#appendMoveCall(simulateTx, processedMoveCall, resolutionIDs, nestedResult, false) const newMoveCalls = await this.simulatePtb(simulateTx, sender) // Replace the placeholder move calls with the actual move calls const placeholderMoveCallCount = await this.#buildMoveCalls( tx, newMoveCalls, resolutionIDs, sender, remainingSimulation, finalMoveCalls, nestedResult, this.#calculateOffset(currentMoveCallIndex, baseOffset, placeholderInfos) ) placeholderInfos.push({ index: currentMoveCallIndex, moveCallCount: placeholderMoveCallCount }) } } const placeholderMoveCallCount = placeholderInfos.reduce((acc, item) => acc + item.moveCallCount, 0) return moveCalls.length - builderMoveCallCount + placeholderMoveCallCount } /** * Append a move call to the transaction with argument resolution * Handles different argument types and records results for future reference * @param tx - The transaction to add the move call to * @param moveCall - The move call to append * @param resolutionIDs - Map for resolving call ID arguments * @param nestedResult - Array to store transaction results * @param directCall - Whether this is a direct call (affects result recording) * @private */ #appendMoveCall( tx: Transaction, moveCall: MoveCall, resolutionIDs: Map<string, TransactionResult>, // ID -> TransactionResult nestedResult: TransactionResult[], directCall: boolean ): void { const moveCallParam = { target: `${moveCall.function.package}::${moveCall.function.module_name}::${moveCall.function.name}`, arguments: moveCall.arguments.map((arg) => { if ('Object' in arg) { return tx.object(arg.Object) } else if ('Pure' in arg) { return tx.pure(arg.Pure) } else if ('NestedResult' in arg) { const [callIndex, resultIndex] = arg.NestedResult const moveCallResult = nestedResult[callIndex] // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition,@typescript-eslint/strict-boolean-expressions if (!moveCallResult || typeof moveCallResult[resultIndex] === 'undefined') { throw new Error(`NestedResult resultIndex ${resultIndex} not available in call ${callIndex}`) } return moveCallResult[resultIndex] } else if ('ID' in arg) { // Replace the call parameters with the result from the call cache const result = resolutionIDs.get(Buffer.from(arg.ID).toString('hex')) if (result === undefined) { throw new Error( `Call substitution not found for "${Buffer.from(arg.ID).toString('hex')}" in cache` ) } return result } throw new Error(`Unknown argument variant: ${JSON.stringify(arg)}`) }), typeArguments: moveCall.type_arguments, } if (directCall) { const result = tx.moveCall(moveCallParam) // The nestedResult index is actually equal to the move_call's nested result index in the move_call_builder nestedResult.push(result) // Record output calls for downstream parameter replacement if there are output calls if (moveCall.result_ids.length > 0) { for (let i = 0; i < moveCall.result_ids.length; i++) { const outputID = moveCall.result_ids[i] // Validate that the result has enough outputs if (i >= result.length) { throw new Error( `Move call result doesn't have output at index ${i} for call "${Buffer.from(outputID).toString('hex')}"` ) } const resultID = Buffer.from(outputID).toString('hex') if (resolutionIDs.has(resultID)) { throw new Error(`Move call result already exists for call "${resultID}"`) } resolutionIDs.set(resultID, result[i] as unknown as TransactionResult) } } } else { tx.moveCall(moveCallParam) } } /** * Decode move calls from a simulation result * @param viewResult - The simulation result containing move calls * @returns Array of decoded move calls * @throws Error if the result type doesn't match expected format * @private */ #decodeMoveCalls(viewResult: SimulateResult): MoveCall[] { const { value, type } = viewResult const vectorCallType = `vector<${normalizeSuiPackageId(this.packageId, true, true)}::${MOVE_CALL_MODULE_NAME}::MoveCall>`.toLowerCase() if (type.toLowerCase() !== vectorCallType) { throw new Error(`not match the type: ${type.toLowerCase()} - expected ${vectorCallType}`) } return this.#decodeMoveCallsBytes(value) } #decodeMoveCallsFromOAppInfoV1(bytes: Uint8Array): MoveCall[] { const oappInfo = OAppInfoV1Bcs.parse(bytes) return this.#decodeMoveCallsBytes(new Uint8Array(oappInfo.lz_receive_info).subarray(2)) // remove the version prefix } /** * Decode move calls from raw bytes * @param bytes - The raw bytes containing encoded move calls * @returns Array of decoded move calls * @private */ #decodeMoveCallsBytes(bytes: Uint8Array): MoveCall[] { const moveCalls = VectorMoveCallBCS.parse(bytes) return moveCalls.map((moveCall) => ({ function: { package: moveCall.function.package, module_name: moveCall.function.module_name, name: moveCall.function.name, }, arguments: moveCall.arguments.map((arg) => { if (arg.ID !== undefined) { return { ID: arg.ID } } else if (arg.Object !== undefined) { return { Object: arg.Object } } else if (arg.Pure !== undefined) { return { Pure: new Uint8Array(arg.Pure) } } else { // Must be NestedResult since we've handled other cases return { NestedResult: [arg.NestedResult.call_index, arg.NestedResult.result_index] as [number, number], } } }), type_arguments: moveCall.type_arguments, result_ids: moveCall.result_ids.map((id) => Buffer.from(id)), is_builder_call: moveCall.is_builder_call, })) as MoveCall[] } /** * Calculate the offset for nested result indices accounting for builder expansions * @param index - The original index to calculate offset for * @param baseOffset - The base offset to add * @param placeholderInfos - Information about placeholder expansions * @returns The calculated offset index * @private */ #calculateOffset(index: number, baseOffset: number, placeholderInfos: BuilderPlaceholderInfo[]): number { let placeholderCount = 0 let placeholderMoveCallCount = 0 for (const placeholder of placeholderInfos) { // builder move_call will not return any nested result, so only check index > placeholder.index if (index > placeholder.index) { placeholderMoveCallCount += placeholder.moveCallCount placeholderCount++ } } return index - placeholderCount + placeholderMoveCallCount + baseOffset } }