From a3012683f69fa5091eda393c2560c62bc04058a4 Mon Sep 17 00:00:00 2001 From: runtianz Date: Thu, 7 Nov 2024 11:32:09 -0800 Subject: [PATCH] Implement a new api for building script payload (#565) * import aptos-intent * Fix some lints * Fix comments * Fix deps, re-export CallArgument * fixup! Fix deps, re-export CallArgument * Fix comments * fix lint * fixup! fix lint * Update package --- CHANGELOG.md | 1 + package.json | 1 + pnpm-lock.yaml | 7 ++ src/api/transactionSubmission/build.ts | 83 ++++++++++++++++++- src/transactions/index.ts | 1 + src/transactions/script-composer/index.ts | 76 +++++++++++++++++ .../transactionBuilder/remoteAbi.ts | 56 ++++++++++++- src/transactions/types.ts | 11 +++ src/types/index.ts | 1 + .../transaction/transactionSubmission.test.ts | 62 ++++++++++++++ 10 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/transactions/script-composer/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 28cbd1ca4..04caa056a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the Aptos TypeScript SDK will be captured in this file. T - Includes the address in the `AbstractKeylessAccount` serialization to prevent information loss for key rotated accounts. - [`Breaking`] Deprecate `serializeOptionStr` and `deserializeOptionStr` in favor of `serializeOption` and `deserializeOption`. - [`Breaking`] Renames `KeylessConfiguration.verficationKey` to `verificationKey` +- Add a new `scriptComposer` api in transactionSubmission api to allower SDK callers to invoke multiple Move functions inside a same transaction and compose the calls dynamically. # 1.31.0 (2024-10-24) diff --git a/package.json b/package.json index 04f982a1b..25fcb42e9 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@noble/hashes": "^1.4.0", "@scure/bip32": "^1.4.0", "@scure/bip39": "^1.3.0", + "@wgb5445/aptos-intent-npm": "^0.1.9", "eventemitter3": "^5.0.1", "form-data": "^4.0.0", "js-base64": "^3.7.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16c7a18d8..94ab8ad97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: '@scure/bip39': specifier: ^1.3.0 version: 1.4.0 + '@wgb5445/aptos-intent-npm': + specifier: ^0.1.9 + version: 0.1.9 eventemitter3: specifier: ^5.0.1 version: 5.0.1 @@ -3018,6 +3021,10 @@ packages: '@xtuc/long': 4.2.2 dev: true + /@wgb5445/aptos-intent-npm@0.1.9: + resolution: {integrity: sha512-Mv+RjURwKtiV8YjW7XmysbnBAR1aSRDCmtC0TmGPxSFsUVyWCE1whtmO7zyxm16rLfEAhW9wkvfmkZU6K3Wf0w==} + dev: false + /@whatwg-node/fetch@0.9.21: resolution: {integrity: sha512-Wt0jPb+04JjobK0pAAN7mEHxVHcGA9HoP3OyCsZtyAecNQeADXCZ1MihFwVwjsgaRYuGVmNlsCmLxlG6mor8Gw==} engines: {node: '>=18.0.0'} diff --git a/src/api/transactionSubmission/build.ts b/src/api/transactionSubmission/build.ts index e56fbbce0..469a690bf 100644 --- a/src/api/transactionSubmission/build.ts +++ b/src/api/transactionSubmission/build.ts @@ -3,10 +3,17 @@ import { AccountAddressInput } from "../../core"; import { generateTransaction } from "../../internal/transactionSubmission"; -import { InputGenerateTransactionPayloadData, InputGenerateTransactionOptions } from "../../transactions"; +import { + InputGenerateTransactionPayloadData, + InputGenerateTransactionOptions, + AptosScriptComposer, + TransactionPayloadScript, + generateRawTransaction, +} from "../../transactions"; import { MultiAgentTransaction } from "../../transactions/instances/multiAgentTransaction"; import { SimpleTransaction } from "../../transactions/instances/simpleTransaction"; import { AptosConfig } from "../aptosConfig"; +import { Deserializer } from "../../bcs"; /** * A class to handle all `Build` transaction operations. @@ -93,6 +100,80 @@ export class Build { return generateTransaction({ aptosConfig: this.config, ...args }); } + /** + * Build a transaction from a series of Move calls. + * + * This function allows you to create a transaction with a list of Move calls. + * + * Right now we only tested this logic with single signer and we will add support + * for mutli agent transactions if needed. + * + * @param args.sender - The sender account address. + * @param args.builder - The closure to construct the list of calls. + * @param args.options - Optional transaction configurations. + * @param args.withFeePayer - Whether there is a fee payer for the transaction. + * + * @returns SimpleTransaction + * + * @example + * ```typescript + * import { Aptos, AptosConfig, Network } from "@aptos-labs/ts-sdk"; + * + * const config = new AptosConfig({ network: Network.TESTNET }); + * const aptos = new Aptos(config); + * + * async function runExample() { + * // Build a transaction from a chained series of Move calls. + * const transaction = await aptos.transaction.script_composer({ + * sender: "0x1", // replace with a real sender account address + * builder: builder: async (builder) => { + * const coin = await builder.addBatchedCalls({ + * function: "0x1::coin::withdraw", + * functionArguments: [CallArgument.new_signer(0), 1], + * typeArguments: ["0x1::aptos_coin::AptosCoin"], + * }); + * + * // Pass the returned value from the first function call to the second call + * const fungibleAsset = await builder.addBatchedCalls({ + * function: "0x1::coin::coin_to_fungible_asset", + * functionArguments: [coin[0]], + * typeArguments: ["0x1::aptos_coin::AptosCoin"], + * }); + * + * await builder.addBatchedCalls({ + * function: "0x1::primary_fungible_store::deposit", + * functionArguments: [singleSignerED25519SenderAccount.accountAddress, fungibleAsset[0]], + * typeArguments: [], + * }); + * return builder; + * }, + * options: { + * gasUnitPrice: 100, // specify your own gas unit price if needed + * maxGasAmount: 1000, // specify your own max gas amount if needed + * }, + * }); + * + * console.log(transaction); + * } + * runExample().catch(console.error); + * ``` + */ + async scriptComposer(args: { + sender: AccountAddressInput; + builder: (builder: AptosScriptComposer) => Promise; + options?: InputGenerateTransactionOptions; + withFeePayer?: boolean; + }): Promise { + const builder = await args.builder(new AptosScriptComposer(this.config)); + const bytes = builder.build(); + const rawTxn = await generateRawTransaction({ + aptosConfig: this.config, + payload: TransactionPayloadScript.load(new Deserializer(bytes)), + ...args, + }); + return new SimpleTransaction(rawTxn); + } + /** * Build a multi-agent transaction that allows multiple signers to authorize a transaction. * diff --git a/src/transactions/index.ts b/src/transactions/index.ts index b8da34334..0d9bfa892 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -7,3 +7,4 @@ export * from "./transactionBuilder"; export * from "./typeTag"; export * from "./typeTag/parser"; export * from "./types"; +export * from "./script-composer"; diff --git a/src/transactions/script-composer/index.ts b/src/transactions/script-composer/index.ts new file mode 100644 index 000000000..e0835df06 --- /dev/null +++ b/src/transactions/script-composer/index.ts @@ -0,0 +1,76 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +import { TransactionComposer, initSync, create_wasm } from "@wgb5445/aptos-intent-npm"; +import { AptosApiType } from "../../utils"; +import { AptosConfig } from "../../api/aptosConfig"; +import { InputBatchedFunctionData } from "../types"; +import { fetchMoveFunctionAbi, getFunctionParts, standardizeTypeTags } from "../transactionBuilder"; +import { CallArgument } from "../../types"; +import { convertCallArgument } from "../transactionBuilder/remoteAbi"; + +(async () => { + initSync(await create_wasm()); +})(); + +// A wrapper class around TransactionComposer, which is a WASM library compiled +// from aptos-core/aptos-move/script-composer. +// +// This class allows the SDK caller to build a transaction that invokes multiple Move functions +// and allow for arguments to be passed around. +export class AptosScriptComposer { + private builder: TransactionComposer; + + private config: AptosConfig; + + constructor(aptosConfig: AptosConfig) { + this.builder = TransactionComposer.single_signer(); + this.config = aptosConfig; + } + + // Add a move function invocation to the TransactionComposer. + // + // Similar to how to create an entry function, the difference is that input arguments could + // either be a `CallArgument` which represents an abstract value returned from a previous Move call + // or the regular entry function arguments. + // + // The function would also return a list of `CallArgument` that can be passed on to future calls. + async addBatchedCalls(input: InputBatchedFunctionData): Promise { + const { moduleAddress, moduleName, functionName } = getFunctionParts(input.function); + const nodeUrl = this.config.getRequestUrl(AptosApiType.FULLNODE); + + // Load the calling module into the builder. + await this.builder.load_module(nodeUrl, `${moduleAddress}::${moduleName}`); + + // Load the calling type arguments into the loader. + if (input.typeArguments !== undefined) { + for (const typeTag of input.typeArguments) { + // eslint-disable-next-line no-await-in-loop + await this.builder.load_type_tag(nodeUrl, typeTag.toString()); + } + } + const typeArguments = standardizeTypeTags(input.typeArguments); + const functionAbi = await fetchMoveFunctionAbi(moduleAddress, moduleName, functionName, this.config); + // Check the type argument count against the ABI + if (typeArguments.length !== functionAbi.typeParameters.length) { + throw new Error( + `Type argument count mismatch, expected ${functionAbi.typeParameters.length}, received ${typeArguments.length}`, + ); + } + + const functionArguments: CallArgument[] = input.functionArguments.map((arg, i) => + convertCallArgument(arg, functionName, functionAbi, i, typeArguments), + ); + + return this.builder.add_batched_call( + `${moduleAddress}::${moduleName}`, + functionName, + typeArguments.map((arg) => arg.toString()), + functionArguments, + ); + } + + build(): Uint8Array { + return this.builder.generate_batched_calls(true); + } +} diff --git a/src/transactions/transactionBuilder/remoteAbi.ts b/src/transactions/transactionBuilder/remoteAbi.ts index 92707f66d..f5a2cda7b 100644 --- a/src/transactions/transactionBuilder/remoteAbi.ts +++ b/src/transactions/transactionBuilder/remoteAbi.ts @@ -45,7 +45,7 @@ import { throwTypeMismatch, convertNumber, } from "./helpers"; -import { MoveFunction } from "../../types"; +import { CallArgument, MoveFunction } from "../../types"; const TEXT_ENCODER = new TextEncoder(); @@ -92,6 +92,34 @@ export async function fetchFunctionAbi( return undefined; } +/** + * Fetches a function ABI from the on-chain module ABI. It doesn't validate whether it's a view or entry function. + * @param moduleAddress + * @param moduleName + * @param functionName + * @param aptosConfig + */ +export async function fetchMoveFunctionAbi( + moduleAddress: string, + moduleName: string, + functionName: string, + aptosConfig: AptosConfig, +): Promise { + const functionAbi = await fetchFunctionAbi(moduleAddress, moduleName, functionName, aptosConfig); + if (!functionAbi) { + throw new Error(`Could not find function ABI for '${moduleAddress}::${moduleName}::${functionName}'`); + } + const params: TypeTag[] = []; + for (let i = 0; i < functionAbi.params.length; i += 1) { + params.push(parseTypeTag(functionAbi.params[i], { allowGenerics: true })); + } + + return { + typeParameters: functionAbi.generic_type_params, + parameters: params, + }; +} + /** * Fetches the ABI for an entry function from the specified module address. * This function validates if the ABI corresponds to an entry function and retrieves its parameters. @@ -183,6 +211,32 @@ export async function fetchViewFunctionAbi( }; } +/** + * Converts a entry function argument into CallArgument, if necessary. + * This function checks the provided argument against the expected parameter type and converts it accordingly. + * + * @param functionName - The name of the function for which the argument is being converted. + * @param functionAbi - The ABI (Application Binary Interface) of the function, which defines its parameters. + * @param argument - The argument to be converted, which can be of various types. If the argument is already + * CallArgument returned from TransactionComposer it would be returned immediately. + * @param position - The index of the argument in the function's parameter list. + * @param genericTypeParams - An array of type tags for any generic type parameters. + */ +export function convertCallArgument( + argument: CallArgument | EntryFunctionArgumentTypes | SimpleEntryFunctionArgumentTypes, + functionName: string, + functionAbi: FunctionABI, + position: number, + genericTypeParams: Array, +): CallArgument { + if (argument instanceof CallArgument) { + return argument; + } + return CallArgument.new_bytes( + convertArgument(functionName, functionAbi, argument, position, genericTypeParams).bcsToBytes(), + ); +} + /** * Converts a non-BCS encoded argument into BCS encoded, if necessary. * This function checks the provided argument against the expected parameter type and converts it accordingly. diff --git a/src/transactions/types.ts b/src/transactions/types.ts index 5fdd85b41..1b41e1f45 100644 --- a/src/transactions/types.ts +++ b/src/transactions/types.ts @@ -1,6 +1,7 @@ // Copyright © Aptos Foundation // SPDX-License-Identifier: Apache-2.0 +import { CallArgument } from "@wgb5445/aptos-intent-npm"; import { AptosConfig } from "../api/aptosConfig"; import { MoveOption, MoveString, MoveVector } from "../bcs/serializable/moveStructs"; import { Bool, U128, U16, U256, U32, U64, U8 } from "../bcs/serializable/movePrimitives"; @@ -164,6 +165,16 @@ export type InputMultiSigDataWithABI = { * Combines input function data with Aptos configuration for remote ABI interactions. */ export type InputEntryFunctionDataWithRemoteABI = InputEntryFunctionData & { aptosConfig: AptosConfig }; + +/** + * The data needed to generate a batched function payload + */ +export type InputBatchedFunctionData = { + function: MoveFunctionId; + typeArguments?: Array; + functionArguments: Array; +}; + /** * The data needed to generate a Multi Sig payload */ diff --git a/src/types/index.ts b/src/types/index.ts index bec57ae9f..69f97cb61 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,3 @@ export * from "./indexer"; export * from "./types"; +export { CallArgument } from "@wgb5445/aptos-intent-npm"; diff --git a/tests/e2e/transaction/transactionSubmission.test.ts b/tests/e2e/transaction/transactionSubmission.test.ts index cbea950af..89b00f3e2 100644 --- a/tests/e2e/transaction/transactionSubmission.test.ts +++ b/tests/e2e/transaction/transactionSubmission.test.ts @@ -12,6 +12,11 @@ import { TransactionPayloadEntryFunction, Bool, MoveString, + AptosScriptComposer, + TransactionPayloadScript, + generateRawTransaction, + SimpleTransaction, + CallArgument, } from "../../../src"; import { MAX_U64_BIG_INT } from "../../../src/bcs/consts"; import { longTestTimeout } from "../../unit/helper"; @@ -61,6 +66,63 @@ describe("transaction submission", () => { expect(response.signature?.type).toBe("single_sender"); }); + test("with batch payload", async () => { + const builder = new AptosScriptComposer(aptos.config); + await builder.addBatchedCalls({ + function: `${contractPublisherAccount.accountAddress}::transfer::transfer`, + functionArguments: [CallArgument.new_signer(0), 1, receiverAccounts[0].accountAddress], + }); + const bytes = builder.build(); + const transaction = await generateRawTransaction({ + aptosConfig: aptos.config, + sender: singleSignerED25519SenderAccount.accountAddress, + payload: TransactionPayloadScript.load(new Deserializer(bytes)), + }); + const response = await aptos.signAndSubmitTransaction({ + signer: singleSignerED25519SenderAccount, + transaction: new SimpleTransaction(transaction), + }); + + await aptos.waitForTransaction({ + transactionHash: response.hash, + }); + + expect(response.signature?.type).toBe("single_sender"); + }); + test("with batch withdraw payload", async () => { + const transaction = await aptos.transaction.build.scriptComposer({ + sender: singleSignerED25519SenderAccount.accountAddress, + builder: async (builder) => { + const coin = await builder.addBatchedCalls({ + function: "0x1::coin::withdraw", + functionArguments: [CallArgument.new_signer(0), 1], + typeArguments: ["0x1::aptos_coin::AptosCoin"], + }); + + const fungibleAsset = await builder.addBatchedCalls({ + function: "0x1::coin::coin_to_fungible_asset", + functionArguments: [coin[0]], + typeArguments: ["0x1::aptos_coin::AptosCoin"], + }); + + await builder.addBatchedCalls({ + function: "0x1::primary_fungible_store::deposit", + functionArguments: [singleSignerED25519SenderAccount.accountAddress, fungibleAsset[0]], + }); + return builder; + }, + }); + const response = await aptos.signAndSubmitTransaction({ + signer: singleSignerED25519SenderAccount, + transaction, + }); + + await aptos.waitForTransaction({ + transactionHash: response.hash, + }); + + expect(response.signature?.type).toBe("single_sender"); + }); test("with entry function payload", async () => { const transaction = await aptos.transaction.build.simple({ sender: singleSignerED25519SenderAccount.accountAddress,