From 72760c3b05d0074e3e9f2051a967b1b547b98d4c Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Wed, 15 Jan 2025 04:43:26 +0530 Subject: [PATCH] feat: batching for reads and smart-account writes (#773) * Refactor: Add readMulticall route for batch contract reads * bump thirdweb + serialise bigint * Address review comments * fixed import * feat: add batch operations support for transactions and enhance wallet details retrieval * SDK changes * Address review comments --- sdk/src/services/BackendWalletService.ts | 74 ++++++++ sdk/src/services/ContractService.ts | 44 +++++ sdk/src/services/TransactionService.ts | 3 + src/scripts/generate-sdk.ts | 2 +- .../send-transaction-batch-atomic.ts | 143 +++++++++++++++ src/server/routes/contract/read/read-batch.ts | 170 ++++++++++++++++++ src/server/routes/index.ts | 4 + src/server/routes/webhooks/test.ts | 1 + src/server/schemas/transaction/index.ts | 23 +++ .../transaction/raw-transaction-parms.ts | 9 + src/shared/db/wallets/get-wallet-details.ts | 24 ++- .../utils/transaction/insert-transaction.ts | 24 ++- src/shared/utils/transaction/types.ts | 9 + src/worker/tasks/send-transaction-worker.ts | 35 ++-- yarn.lock | 16 +- 15 files changed, 553 insertions(+), 28 deletions(-) create mode 100644 src/server/routes/backend-wallet/send-transaction-batch-atomic.ts create mode 100644 src/server/routes/contract/read/read-batch.ts create mode 100644 src/server/schemas/transaction/raw-transaction-parms.ts diff --git a/sdk/src/services/BackendWalletService.ts b/sdk/src/services/BackendWalletService.ts index 0001fb3aa..7c25b097f 100644 --- a/sdk/src/services/BackendWalletService.ts +++ b/sdk/src/services/BackendWalletService.ts @@ -629,6 +629,78 @@ export class BackendWalletService { }); } + /** + * Send a batch of raw transactions atomically + * Send a batch of raw transactions in a single UserOp. Transactions will be sent in-order and atomically. Can only be used with smart wallets. + * @param chain A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + * @param xBackendWalletAddress Backend wallet address + * @param requestBody + * @param simulateTx Simulates the transaction before adding it to the queue, returning an error if it fails simulation. Note: This step is less performant and recommended only for debugging purposes. + * @param xIdempotencyKey Transactions submitted with the same idempotency key will be de-duplicated. Only the last 100000 transactions are compared. + * @param xAccountAddress Smart account address + * @param xAccountFactoryAddress Smart account factory address. If omitted, Engine will try to resolve it from the contract. + * @param xAccountSalt Smart account salt as string or hex. This is used to predict the smart account address. Useful when creating multiple accounts with the same admin and only needed when deploying the account as part of a userop. + * @returns any Default Response + * @throws ApiError + */ + public sendTransactionBatchAtomic( + chain: string, + xBackendWalletAddress: string, + requestBody: { + transactions: Array<{ + /** + * A contract or wallet address + */ + toAddress?: string; + /** + * A valid hex string + */ + data: string; + /** + * An amount in wei (no decimals). Example: "50000000000" + */ + value: string; + }>; + }, + simulateTx: boolean = false, + xIdempotencyKey?: string, + xAccountAddress?: string, + xAccountFactoryAddress?: string, + xAccountSalt?: string, + ): CancelablePromise<{ + result: { + /** + * Queue ID + */ + queueId: string; + }; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/backend-wallet/{chain}/send-transaction-batch-atomic', + path: { + 'chain': chain, + }, + headers: { + 'x-backend-wallet-address': xBackendWalletAddress, + 'x-idempotency-key': xIdempotencyKey, + 'x-account-address': xAccountAddress, + 'x-account-factory-address': xAccountFactoryAddress, + 'x-account-salt': xAccountSalt, + }, + query: { + 'simulateTx': simulateTx, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + /** * Sign a transaction * Sign a transaction @@ -833,6 +905,7 @@ export class BackendWalletService { onchainStatus: ('success' | 'reverted' | null); effectiveGasPrice: (string | null); cumulativeGasUsed: (string | null); + batchOperations: null; }>; }; }> { @@ -928,6 +1001,7 @@ export class BackendWalletService { onchainStatus: ('success' | 'reverted' | null); effectiveGasPrice: (string | null); cumulativeGasUsed: (string | null); + batchOperations: null; } | string); }>; }> { diff --git a/sdk/src/services/ContractService.ts b/sdk/src/services/ContractService.ts index 6930052bc..caef13274 100644 --- a/sdk/src/services/ContractService.ts +++ b/sdk/src/services/ContractService.ts @@ -46,6 +46,50 @@ export class ContractService { }); } + /** + * Batch read from multiple contracts + * Execute multiple contract read operations in a single call using Multicall + * @param chain + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public readBatch( + chain: string, + requestBody: { + calls: Array<{ + contractAddress: string; + functionName: string; + functionAbi?: string; + args?: Array; + }>; + /** + * Address of the multicall contract to use. If omitted, multicall3 contract will be used (0xcA11bde05977b3631167028862bE2a173976CA11). + */ + multicallAddress?: string; + }, + ): CancelablePromise<{ + results: Array<{ + success: boolean; + result: any; + }>; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/contract/{chain}/read-batch', + path: { + 'chain': chain, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + /** * Write to contract * Call a write function on a contract. diff --git a/sdk/src/services/TransactionService.ts b/sdk/src/services/TransactionService.ts index a10a731d2..76f9ef016 100644 --- a/sdk/src/services/TransactionService.ts +++ b/sdk/src/services/TransactionService.ts @@ -78,6 +78,7 @@ export class TransactionService { onchainStatus: ('success' | 'reverted' | null); effectiveGasPrice: (string | null); cumulativeGasUsed: (string | null); + batchOperations: null; }>; totalCount: number; }; @@ -162,6 +163,7 @@ export class TransactionService { onchainStatus: ('success' | 'reverted' | null); effectiveGasPrice: (string | null); cumulativeGasUsed: (string | null); + batchOperations: null; }; }> { return this.httpRequest.request({ @@ -245,6 +247,7 @@ export class TransactionService { onchainStatus: ('success' | 'reverted' | null); effectiveGasPrice: (string | null); cumulativeGasUsed: (string | null); + batchOperations: null; }>; totalCount: number; }; diff --git a/src/scripts/generate-sdk.ts b/src/scripts/generate-sdk.ts index f39c377f8..759dd8f00 100644 --- a/src/scripts/generate-sdk.ts +++ b/src/scripts/generate-sdk.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { kill } from "node:process"; const ENGINE_OPENAPI_URL = "https://demo.web3api.thirdweb.com/json"; -// const ENGINE_OPENAPI_URL = "http://localhost:3005/json"; +// const ENGINE_OPENAPI_URL = "http://127.0.0.1:3005/json"; const REPLACE_LOG_FILE = "sdk/replacement_log.txt"; type BasicOpenAPISpec = { diff --git a/src/server/routes/backend-wallet/send-transaction-batch-atomic.ts b/src/server/routes/backend-wallet/send-transaction-batch-atomic.ts new file mode 100644 index 000000000..80506b6e8 --- /dev/null +++ b/src/server/routes/backend-wallet/send-transaction-batch-atomic.ts @@ -0,0 +1,143 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import type { Address, Hex } from "thirdweb"; +import { insertTransaction } from "../../../shared/utils/transaction/insert-transaction"; +import { + requestQuerystringSchema, + standardResponseSchema, + transactionWritesResponseSchema, +} from "../../schemas/shared-api-schemas"; +import { + maybeAddress, + walletChainParamSchema, + walletWithAAHeaderSchema, +} from "../../schemas/wallet"; +import { getChainIdFromChain } from "../../utils/chain"; +import { + getWalletDetails, + isSmartBackendWallet, + type ParsedWalletDetails, + WalletDetailsError, +} from "../../../shared/db/wallets/get-wallet-details"; +import { createCustomError } from "../../middleware/error"; +import { RawTransactionParamsSchema } from "../../schemas/transaction/raw-transaction-parms"; + +const requestBodySchema = Type.Object({ + transactions: Type.Array(RawTransactionParamsSchema, { + minItems: 1, + }), +}); + +export async function sendTransactionBatchAtomicRoute(fastify: FastifyInstance) { + fastify.route<{ + Params: Static; + Body: Static; + Reply: Static; + Querystring: Static; + }>({ + method: "POST", + url: "/backend-wallet/:chain/send-transaction-batch-atomic", + schema: { + summary: "Send a batch of raw transactions atomically", + description: + "Send a batch of raw transactions in a single UserOp. Transactions will be sent in-order and atomically. Can only be used with smart wallets.", + tags: ["Backend Wallet"], + operationId: "sendTransactionBatchAtomic", + params: walletChainParamSchema, + body: requestBodySchema, + headers: Type.Omit(walletWithAAHeaderSchema, ["x-transaction-mode"]), + querystring: requestQuerystringSchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: transactionWritesResponseSchema, + }, + }, + handler: async (request, reply) => { + const { chain } = request.params; + const { + "x-backend-wallet-address": fromAddress, + "x-idempotency-key": idempotencyKey, + "x-account-address": accountAddress, + "x-account-factory-address": accountFactoryAddress, + "x-account-salt": accountSalt, + } = request.headers as Static; + const chainId = await getChainIdFromChain(chain); + const shouldSimulate = request.query.simulateTx ?? false; + const transactionRequests = request.body.transactions; + + const hasSmartHeaders = !!accountAddress; + + // check that we either use SBW, or send using EOA with smart wallet headers + if (!hasSmartHeaders) { + let backendWallet: ParsedWalletDetails | undefined; + + try { + backendWallet = await getWalletDetails({ + address: fromAddress, + }); + } catch (e: unknown) { + if (e instanceof WalletDetailsError) { + throw createCustomError( + `Failed to get wallet details for backend wallet ${fromAddress}. ${e.message}`, + StatusCodes.BAD_REQUEST, + "WALLET_DETAILS_ERROR", + ); + } + } + + if (!backendWallet) { + throw createCustomError( + "Failed to get wallet details for backend wallet. See: https://portal.thirdweb.com/engine/troubleshooting", + StatusCodes.INTERNAL_SERVER_ERROR, + "WALLET_DETAILS_ERROR", + ); + } + + if (!isSmartBackendWallet(backendWallet)) { + throw createCustomError( + "Backend wallet is not a smart wallet, and x-account-address is not provided. Either use a smart backend wallet or provide x-account-address. This endpoint can only be used with smart wallets.", + StatusCodes.BAD_REQUEST, + "BACKEND_WALLET_NOT_SMART", + ); + } + } + + if (transactionRequests.length === 0) { + throw createCustomError( + "No transactions provided", + StatusCodes.BAD_REQUEST, + "NO_TRANSACTIONS_PROVIDED", + ); + } + + const queueId = await insertTransaction({ + insertedTransaction: { + transactionMode: undefined, + isUserOp: false, + chainId, + from: fromAddress as Address, + accountAddress: maybeAddress(accountAddress, "x-account-address"), + accountFactoryAddress: maybeAddress( + accountFactoryAddress, + "x-account-factory-address", + ), + accountSalt: accountSalt, + batchOperations: transactionRequests.map((transactionRequest) => ({ + to: transactionRequest.toAddress as Address | undefined, + data: transactionRequest.data as Hex, + value: BigInt(transactionRequest.value), + })), + }, + shouldSimulate, + idempotencyKey, + }); + + reply.status(StatusCodes.OK).send({ + result: { + queueId, + }, + }); + }, + }); +} diff --git a/src/server/routes/contract/read/read-batch.ts b/src/server/routes/contract/read/read-batch.ts new file mode 100644 index 000000000..0a026bacd --- /dev/null +++ b/src/server/routes/contract/read/read-batch.ts @@ -0,0 +1,170 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import SuperJSON from "superjson"; +import { + encode, + getContract, + prepareContractCall, + readContract, + resolveMethod, +} from "thirdweb"; +import { prepareMethod } from "thirdweb/contract"; +import { decodeAbiParameters } from "viem/utils"; +import type { AbiFunction } from "viem"; +import { createCustomError } from "../../../middleware/error"; +import { getChainIdFromChain } from "../../../utils/chain"; +import { standardResponseSchema } from "../../../schemas/shared-api-schemas"; +import { getChain } from "../../../../shared/utils/chain"; +import { thirdwebClient } from "../../../../shared/utils/sdk"; +import { prettifyError } from "../../../../shared/utils/error"; + +const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11"; + +const MULTICALL3_AGGREGATE_ABI = + "function aggregate3((address target, bool allowFailure, bytes callData)[] calls) external payable returns ((bool success, bytes returnData)[])"; + +const readCallRequestItemSchema = Type.Object({ + contractAddress: Type.String(), + functionName: Type.String(), + functionAbi: Type.Optional(Type.String()), + args: Type.Optional(Type.Array(Type.Any())), +}); + +const readMulticallRequestSchema = Type.Object({ + calls: Type.Array(readCallRequestItemSchema), + multicallAddress: Type.Optional( + Type.String({ + description: `Address of the multicall contract to use. If omitted, multicall3 contract will be used (${MULTICALL3_ADDRESS}).`, + }), + ), +}); + +const responseSchema = Type.Object({ + results: Type.Array( + Type.Object({ + success: Type.Boolean(), + result: Type.Any(), + }), + ), +}); + +const paramsSchema = Type.Object({ + chain: Type.String(), +}); + +type RouteGeneric = { + Params: { chain: string }; + Body: Static; + Reply: Static; +}; + +export async function readBatchRoute(fastify: FastifyInstance) { + fastify.route({ + method: "POST", + url: "/contract/:chain/read-batch", + schema: { + summary: "Batch read from multiple contracts", + description: + "Execute multiple contract read operations in a single call using Multicall", + tags: ["Contract"], + operationId: "readBatch", + params: paramsSchema, + body: readMulticallRequestSchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { chain: chainSlug } = request.params; + const { calls, multicallAddress = MULTICALL3_ADDRESS } = request.body; + + const chainId = await getChainIdFromChain(chainSlug); + const chain = await getChain(chainId); + + try { + // Encode each read call + const encodedCalls = await Promise.all( + calls.map(async (call) => { + const contract = getContract({ + client: thirdwebClient, + chain, + address: call.contractAddress, + }); + + const method = + (call.functionAbi as unknown as AbiFunction) ?? + (await resolveMethod(call.functionName)(contract)); + + const transaction = prepareContractCall({ + contract, + method, + params: call.args || [], + }); + + const calldata = await encode(transaction); + if (!calldata) { + throw new Error("Failed to encode call data"); + } + + return { + target: call.contractAddress, + abiFunction: method, + allowFailure: true, + callData: calldata, + }; + }), + ); + + // Get Multicall3 contract + const multicall = await getContract({ + chain, + address: multicallAddress, + client: thirdwebClient, + }); + + // Execute batch read + const results = await readContract({ + contract: multicall, + method: MULTICALL3_AGGREGATE_ABI, + params: [encodedCalls], + }); + + // Process results + const processedResults = results.map((result: unknown, i) => { + const { success, returnData } = result as { + success: boolean; + returnData: unknown; + }; + + const [_sig, _inputs, outputs] = prepareMethod( + encodedCalls[i].abiFunction, + ); + + const decoded = decodeAbiParameters( + outputs, + returnData as `0x${string}`, + ); + + return { + success, + result: success ? decoded : null, + }; + }); + + reply.status(StatusCodes.OK).send({ + results: SuperJSON.serialize(processedResults).json as Static< + typeof responseSchema + >["results"], + }); + } catch (e) { + throw createCustomError( + prettifyError(e), + StatusCodes.BAD_REQUEST, + "BAD_REQUEST", + ); + } + }, + }); +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index b02317213..1606debcc 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -111,6 +111,8 @@ import { getWebhooksEventTypes } from "./webhooks/events"; import { getAllWebhooksData } from "./webhooks/get-all"; import { revokeWebhook } from "./webhooks/revoke"; import { testWebhookRoute } from "./webhooks/test"; +import { readBatchRoute } from "./contract/read/read-batch"; +import { sendTransactionBatchAtomicRoute } from "./backend-wallet/send-transaction-batch-atomic"; export async function withRoutes(fastify: FastifyInstance) { // Backend Wallets @@ -124,6 +126,7 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(withdraw); await fastify.register(sendTransaction); await fastify.register(sendTransactionBatch); + await fastify.register(sendTransactionBatchAtomicRoute); await fastify.register(signTransaction); await fastify.register(signMessageRoute); await fastify.register(signTypedData); @@ -192,6 +195,7 @@ export async function withRoutes(fastify: FastifyInstance) { // Generic await fastify.register(readContract); + await fastify.register(readBatchRoute); await fastify.register(writeToContract); // Contract Events diff --git a/src/server/routes/webhooks/test.ts b/src/server/routes/webhooks/test.ts index d61861f0f..868f21ebc 100644 --- a/src/server/routes/webhooks/test.ts +++ b/src/server/routes/webhooks/test.ts @@ -93,6 +93,7 @@ export async function testWebhookRoute(fastify: FastifyInstance) { paymasterAndData: null, userOpHash: null, accountSalt: null, + batchOperations: null, // Off-chain details functionName: "transfer", diff --git a/src/server/schemas/transaction/index.ts b/src/server/schemas/transaction/index.ts index c2ddb8825..ff82934fa 100644 --- a/src/server/schemas/transaction/index.ts +++ b/src/server/schemas/transaction/index.ts @@ -210,6 +210,16 @@ export const TransactionSchema = Type.Object({ }), Type.Null(), ]), + batchOperations: Type.Union([ + Type.Array( + Type.Object({ + to: Type.Union([Type.String(), Type.Null()]), + data: Type.Union([Type.String(), Type.Null()]), + value: Type.String(), + }), + ), + Type.Null(), + ]), }); export const toTransactionSchema = ( @@ -255,6 +265,17 @@ export const toTransactionSchema = ( return null; }; + const resolveBatchOperations = (): Static< + typeof TransactionSchema + >["batchOperations"] => { + if (!transaction.batchOperations) return null; + return transaction.batchOperations.map((op) => ({ + to: op.to ?? null, + data: op.data ?? null, + value: op.value.toString(), + })); + }; + const resolveGas = (): string | null => { if (transaction.status === "sent") { return transaction.gas.toString(); @@ -351,6 +372,8 @@ export const toTransactionSchema = ( userOpHash: "userOpHash" in transaction ? (transaction.userOpHash as Hex) : null, + batchOperations: resolveBatchOperations(), + // Deprecated retryGasValues: null, retryMaxFeePerGas: null, diff --git a/src/server/schemas/transaction/raw-transaction-parms.ts b/src/server/schemas/transaction/raw-transaction-parms.ts new file mode 100644 index 000000000..fbf64d81a --- /dev/null +++ b/src/server/schemas/transaction/raw-transaction-parms.ts @@ -0,0 +1,9 @@ +import { Type } from "@sinclair/typebox"; +import { AddressSchema, HexSchema } from "../address"; +import { WeiAmountStringSchema } from "../number"; + +export const RawTransactionParamsSchema = Type.Object({ + toAddress: Type.Optional(AddressSchema), + data: HexSchema, + value: WeiAmountStringSchema, +}); diff --git a/src/shared/db/wallets/get-wallet-details.ts b/src/shared/db/wallets/get-wallet-details.ts index eaa475143..c6612d628 100644 --- a/src/shared/db/wallets/get-wallet-details.ts +++ b/src/shared/db/wallets/get-wallet-details.ts @@ -1,3 +1,4 @@ +import LRUMap from "mnemonist/lru-map"; import { getAddress } from "thirdweb"; import { z } from "zod"; import type { PrismaTransaction } from "../../schemas/prisma"; @@ -130,6 +131,7 @@ export type SmartBackendWalletType = (typeof SmartBackendWalletTypes)[number]; export type BackendWalletType = (typeof BackendWalletTypes)[number]; export type ParsedWalletDetails = z.infer; +export const walletDetailsCache = new LRUMap(2048); /** * Return the wallet details for the given address. * @@ -143,20 +145,26 @@ export type ParsedWalletDetails = z.infer; */ export const getWalletDetails = async ({ pgtx, - address, + address: _walletAddress, }: GetWalletDetailsParams) => { + const walletAddress = _walletAddress.toLowerCase(); + const cachedDetails = walletDetailsCache.get(walletAddress); + if (cachedDetails) { + return cachedDetails; + } + const prisma = getPrismaWithPostgresTx(pgtx); const config = await getConfig(); const walletDetails = await prisma.walletDetails.findUnique({ where: { - address: address.toLowerCase(), + address: walletAddress.toLowerCase(), }, }); if (!walletDetails) { throw new WalletDetailsError( - `No wallet details found for address ${address}`, + `No wallet details found for address ${walletAddress}`, ); } @@ -167,7 +175,7 @@ export const getWalletDetails = async ({ ) { if (!walletDetails.awsKmsArn) { throw new WalletDetailsError( - `AWS KMS ARN is missing for the wallet with address ${address}`, + `AWS KMS ARN is missing for the wallet with address ${walletAddress}`, ); } @@ -188,7 +196,7 @@ export const getWalletDetails = async ({ ) { if (!walletDetails.gcpKmsResourcePath) { throw new WalletDetailsError( - `GCP KMS resource path is missing for the wallet with address ${address}`, + `GCP KMS resource path is missing for the wallet with address ${walletAddress}`, ); } @@ -209,14 +217,16 @@ export const getWalletDetails = async ({ // zod schema can validate all necessary fields are populated after decryption try { - return walletDetailsSchema.parse(walletDetails, { + const result = walletDetailsSchema.parse(walletDetails, { errorMap: (issue) => { const fieldName = issue.path.join("."); return { - message: `${fieldName} is necessary for wallet ${address} of type ${walletDetails.type}, but not found in wallet details or configuration`, + message: `${fieldName} is necessary for wallet ${walletAddress} of type ${walletDetails.type}, but not found in wallet details or configuration`, }; }, }); + walletDetailsCache.set(walletAddress, result); + return result; } catch (e) { if (e instanceof z.ZodError) { throw new WalletDetailsError( diff --git a/src/shared/utils/transaction/insert-transaction.ts b/src/shared/utils/transaction/insert-transaction.ts index 625088a30..25fd99967 100644 --- a/src/shared/utils/transaction/insert-transaction.ts +++ b/src/shared/utils/transaction/insert-transaction.ts @@ -4,6 +4,7 @@ import { TransactionDB } from "../../../shared/db/transactions/db"; import { getWalletDetails, isSmartBackendWallet, + WalletDetailsError, type ParsedWalletDetails, } from "../../../shared/db/wallets/get-wallet-details"; import { doesChainSupportService } from "../../lib/chain/chain-capabilities"; @@ -105,8 +106,12 @@ export const insertTransaction = async ( entrypointAddress: walletDetails.entrypointAddress ?? undefined, }; } - } catch { - // if wallet details are not found, this is a smart backend wallet using a v4 endpoint + } catch (e) { + if (e instanceof WalletDetailsError) { + // do nothing. The this is a smart backend wallet using a v4 endpoint + } + // if other type of error, rethrow + throw e; } if (!walletDetails && queuedTransaction.accountAddress) { @@ -139,13 +144,16 @@ export const insertTransaction = async ( walletDetails.accountFactoryAddress ?? undefined, }; } - } catch { + } catch (e: unknown) { // if wallet details are not found for this either, this backend wallet does not exist at all - throw createCustomError( - "Account not found", - StatusCodes.BAD_REQUEST, - "ACCOUNT_NOT_FOUND", - ); + if (e instanceof WalletDetailsError) { + throw createCustomError( + "Account not found", + StatusCodes.BAD_REQUEST, + "ACCOUNT_NOT_FOUND", + ); + } + throw e; } } diff --git a/src/shared/utils/transaction/types.ts b/src/shared/utils/transaction/types.ts index b57a5e5a5..6b28b88d3 100644 --- a/src/shared/utils/transaction/types.ts +++ b/src/shared/utils/transaction/types.ts @@ -13,6 +13,14 @@ export type AnyTransaction = | CancelledTransaction | ErroredTransaction; +export type BatchOperation = { + to?: Address; + value: bigint; + data?: Hex; + functionName?: string; + functionArgs?: unknown[]; +}; + // InsertedTransaction is the raw input from the caller. export type InsertedTransaction = { isUserOp: boolean; @@ -49,6 +57,7 @@ export type InsertedTransaction = { accountSalt?: string; accountFactoryAddress?: Address; entrypointAddress?: Address; + batchOperations?: BatchOperation[]; target?: Address; sender?: Address; }; diff --git a/src/worker/tasks/send-transaction-worker.ts b/src/worker/tasks/send-transaction-worker.ts index d109cb24d..e00825db4 100644 --- a/src/worker/tasks/send-transaction-worker.ts +++ b/src/worker/tasks/send-transaction-worker.ts @@ -143,7 +143,14 @@ const _sendUserOp = async ( assert(accountAddress, "Invalid userOp parameters: accountAddress"); const toAddress = to ?? target; - assert(toAddress, "Invalid transaction parameters: to"); + + if (queuedTransaction.batchOperations) { + queuedTransaction.batchOperations.map((op) => { + assert(op.to, "Invalid transaction parameters: to"); + }); + } else { + assert(toAddress, "Invalid transaction parameters: to"); + } // this can either be a regular backend wallet userop or a smart backend wallet userop let adminAccount: Account | undefined; @@ -199,17 +206,25 @@ const _sendUserOp = async ( } } + const transactions = queuedTransaction.batchOperations + ? queuedTransaction.batchOperations.map((op) => ({ + ...op, + chain, + client: thirdwebClient, + })) + : [ + { + client: thirdwebClient, + chain, + ...queuedTransaction, + ...overrides, + to: getChecksumAddress(toAddress), + }, + ]; + signedUserOp = (await createAndSignUserOp({ client: thirdwebClient, - transactions: [ - { - client: thirdwebClient, - chain, - ...queuedTransaction, - ...overrides, - to: getChecksumAddress(toAddress), - }, - ], + transactions, adminAccount, smartWalletOptions: { chain, diff --git a/yarn.lock b/yarn.lock index f1dda428b..79a69d912 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2372,7 +2372,7 @@ dependencies: "@noble/hashes" "1.4.0" -"@noble/curves@1.4.2", "@noble/curves@~1.4.0": +"@noble/curves@1.4.2": version "1.4.2" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.2.tgz#40309198c76ed71bc6dbf7ba24e81ceb4d0d1fe9" integrity sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw== @@ -2386,6 +2386,13 @@ dependencies: "@noble/hashes" "1.6.0" +"@noble/curves@~1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.6.0.tgz#be5296ebcd5a1730fccea4786d420f87abfeb40b" + integrity sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ== + dependencies: + "@noble/hashes" "1.5.0" + "@noble/hashes@1.3.2": version "1.3.2" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" @@ -2396,6 +2403,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== +"@noble/hashes@1.5.0", "@noble/hashes@~1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" + integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== + "@noble/hashes@1.6.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.6.0.tgz#d4bfb516ad6e7b5111c216a5cc7075f4cf19e6c5" @@ -3262,7 +3274,7 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.2.1.tgz#dd0b2a533063ca612c17aa9ad26424a2ff5aa865" integrity sha512-DGmGtC8Tt63J5GfHgfl5CuAXh96VF/LD8K9Hr/Gv0J2lAoRGlPOMpqMpMbCTOoOJMZCk2Xt+DskdDyn6dEFdzQ== -"@scure/base@~1.1.0", "@scure/base@~1.1.2", "@scure/base@~1.1.6": +"@scure/base@~1.1.0", "@scure/base@~1.1.2", "@scure/base@~1.1.6", "@scure/base@~1.1.7": version "1.1.9" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.9.tgz#e5e142fbbfe251091f9c5f1dd4c834ac04c3dbd1" integrity sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==