From c8d3c13ce546e4909e55228e788468a6e06710e2 Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Mon, 18 Nov 2024 12:40:59 +0800 Subject: [PATCH 1/3] chore: track walletType usage --- src/db/wallets/getWalletDetails.ts | 30 +++-- .../routes/backend-wallet/signMessage.ts | 2 +- src/server/utils/wallets/getLocalWallet.ts | 8 +- src/utils/account.ts | 4 +- src/utils/cache/clearCache.ts | 4 + src/utils/cache/getWallet.ts | 2 +- src/utils/transaction/insertTransaction.ts | 122 ++++++++---------- src/utils/transaction/types.ts | 2 + src/utils/usage.ts | 5 +- 9 files changed, 93 insertions(+), 86 deletions(-) diff --git a/src/db/wallets/getWalletDetails.ts b/src/db/wallets/getWalletDetails.ts index 92356b05e..1a4de3a36 100644 --- a/src/db/wallets/getWalletDetails.ts +++ b/src/db/wallets/getWalletDetails.ts @@ -1,3 +1,4 @@ +import LRUMap from "mnemonist/lru-map"; import { getAddress } from "thirdweb"; import { z } from "zod"; import type { PrismaTransaction } from "../../schema/prisma"; @@ -8,7 +9,7 @@ import { getPrismaWithPostgresTx } from "../client"; interface GetWalletDetailsParams { pgtx?: PrismaTransaction; - address: string; + walletAddress: string; } export class WalletDetailsError extends Error { @@ -130,6 +131,8 @@ 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 +146,28 @@ export type ParsedWalletDetails = z.infer; */ export const getWalletDetails = async ({ pgtx, - address, + walletAddress: _walletAddress, }: GetWalletDetailsParams) => { + // Wallet details are stored in lowercase. + 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, }, }); if (!walletDetails) { throw new WalletDetailsError( - `No wallet details found for address ${address}`, + `No wallet details found for address ${walletAddress}`, ); } @@ -167,7 +178,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 +199,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 +220,17 @@ 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/server/routes/backend-wallet/signMessage.ts b/src/server/routes/backend-wallet/signMessage.ts index ac810a795..8da048878 100644 --- a/src/server/routes/backend-wallet/signMessage.ts +++ b/src/server/routes/backend-wallet/signMessage.ts @@ -56,7 +56,7 @@ export async function signMessageRoute(fastify: FastifyInstance) { } const walletDetails = await getWalletDetails({ - address: walletAddress, + walletAddress, }); if (isSmartBackendWallet(walletDetails) && !chainId) { diff --git a/src/server/utils/wallets/getLocalWallet.ts b/src/server/utils/wallets/getLocalWallet.ts index 0f4456423..a2495b5aa 100644 --- a/src/server/utils/wallets/getLocalWallet.ts +++ b/src/server/utils/wallets/getLocalWallet.ts @@ -49,7 +49,9 @@ export const getLocalWallet = async ({ }); // If that works, save the wallet using the encryption password for the future - const walletDetails = await getWalletDetails({ address: walletAddress }); + const walletDetails = await getWalletDetails({ + walletAddress, + }); logger({ service: "worker", @@ -73,7 +75,9 @@ export const getLocalWallet = async ({ export const getLocalWalletAccount = async ( walletAddress: Address, ): Promise => { - const walletDetails = await getWalletDetails({ address: walletAddress }); + const walletDetails = await getWalletDetails({ + walletAddress, + }); if (walletDetails.type !== "local") { throw new Error(`Local Wallet not found for address ${walletAddress}`); diff --git a/src/utils/account.ts b/src/utils/account.ts index 0c8373f7d..fc71aa19e 100644 --- a/src/utils/account.ts +++ b/src/utils/account.ts @@ -39,7 +39,7 @@ export const getAccount = async (args: { } const walletDetails = await getWalletDetails({ - address: from, + walletAddress: from, }); const { account } = await walletDetailsToAccount({ walletDetails, chain }); @@ -180,7 +180,7 @@ export const getSmartBackendWalletAdminAccount = async ({ } const walletDetails = await getWalletDetails({ - address: accountAddress, + walletAddress: accountAddress, }); if (!isSmartBackendWallet(walletDetails)) { diff --git a/src/utils/cache/clearCache.ts b/src/utils/cache/clearCache.ts index 870561b26..5d77fd409 100644 --- a/src/utils/cache/clearCache.ts +++ b/src/utils/cache/clearCache.ts @@ -1,7 +1,9 @@ +import { walletDetailsCache } from "../../db/wallets/getWalletDetails"; import type { env } from "../env"; import { accessTokenCache } from "./accessToken"; import { invalidateConfig } from "./getConfig"; import { sdkCache } from "./getSdk"; +import { smartWalletsCache } from "./getSmartWalletV5"; import { walletsCache } from "./getWallet"; import { webhookCache } from "./getWebhook"; import { keypairCache } from "./keypair"; @@ -15,4 +17,6 @@ export const clearCache = async ( walletsCache.clear(); accessTokenCache.clear(); keypairCache.clear(); + smartWalletsCache.clear(); + walletDetailsCache.clear(); }; diff --git a/src/utils/cache/getWallet.ts b/src/utils/cache/getWallet.ts index 6b078594b..c75894357 100644 --- a/src/utils/cache/getWallet.ts +++ b/src/utils/cache/getWallet.ts @@ -44,7 +44,7 @@ export const getWallet = async ({ try { walletDetails = await getWalletDetails({ pgtx, - address: walletAddress, + walletAddress, }); } catch (e) { if (e instanceof WalletDetailsError) { diff --git a/src/utils/transaction/insertTransaction.ts b/src/utils/transaction/insertTransaction.ts index fe79018fb..28fd9b406 100644 --- a/src/utils/transaction/insertTransaction.ts +++ b/src/utils/transaction/insertTransaction.ts @@ -2,9 +2,9 @@ import { StatusCodes } from "http-status-codes"; import { randomUUID } from "node:crypto"; import { TransactionDB } from "../../db/transactions/db"; import { + ParsedWalletDetails, getWalletDetails, isSmartBackendWallet, - type ParsedWalletDetails, } from "../../db/wallets/getWalletDetails"; import { doesChainSupportService } from "../../lib/chain/chain-capabilities"; import { createCustomError } from "../../server/middleware/error"; @@ -43,6 +43,32 @@ export const insertTransaction = async ( } } + // Get wallet details. For EOA and SBW (v5 endpoints), `from` should return a valid backend wallet. + // For SBW (v4 endpoints), `accountAddress` should return a valid backend wallet. + // Else the provided details are incorrect (user error). + let walletDetails: ParsedWalletDetails | undefined; + let isSmartBackendWalletV4 = false; + try { + walletDetails = await getWalletDetails({ + walletAddress: insertedTransaction.from, + }); + } catch {} + if (!walletDetails && insertedTransaction.accountAddress) { + try { + walletDetails = await getWalletDetails({ + walletAddress: insertedTransaction.accountAddress, + }); + isSmartBackendWalletV4 = true; + } catch {} + } + if (!walletDetails) { + throw createCustomError( + "Account not found", + StatusCodes.BAD_REQUEST, + "ACCOUNT_NOT_FOUND", + ); + } + let queuedTransaction: QueuedTransaction = { ...insertedTransaction, status: "queued", @@ -50,6 +76,7 @@ export const insertTransaction = async ( queuedAt: new Date(), resendCount: 0, + walletType: walletDetails.type, from: getChecksumAddress(insertedTransaction.from), to: getChecksumAddress(insertedTransaction.to), signerAddress: getChecksumAddress(insertedTransaction.signerAddress), @@ -60,37 +87,34 @@ export const insertTransaction = async ( value: insertedTransaction.value ?? 0n, }; - let walletDetails: ParsedWalletDetails | undefined; + // Handle smart backend wallets details. + if (isSmartBackendWallet(walletDetails)) { + if ( + !(await doesChainSupportService( + queuedTransaction.chainId, + "account-abstraction", + )) + ) { + throw createCustomError( + `Smart backend wallets do not support chain ${queuedTransaction.chainId}.`, + StatusCodes.BAD_REQUEST, + "INVALID_SMART_BACKEND_WALLET_TRANSACTION", + ); + } - try { - walletDetails = await getWalletDetails({ - address: queuedTransaction.from, - }); + queuedTransaction = { + ...queuedTransaction, + accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, + entrypointAddress: walletDetails.entrypointAddress ?? undefined, + }; - // when using the v5 SDK with smart backend wallets, the following values are not set correctly: - // isUserOp is set to false - // account address is blank or the user provided value (this should be the SBW account address) - // from is set to the SBW account address (this should be the SBW signer address) - // these values need to be corrected so the worker can process the transaction - if (isSmartBackendWallet(walletDetails)) { + if (!isSmartBackendWalletV4) { if (queuedTransaction.accountAddress) { + // Disallow smart backend wallets from sending userOps. throw createCustomError( - "Smart backend wallets do not support interacting with other smart accounts", + "Smart backend wallets do not support sending transactions with other smart accounts", StatusCodes.BAD_REQUEST, - "INVALID_SMART_BACKEND_WALLET_INTERACTION", - ); - } - - if ( - !(await doesChainSupportService( - queuedTransaction.chainId, - "account-abstraction", - )) - ) { - throw createCustomError( - "Chain does not support smart backend wallets", - StatusCodes.BAD_REQUEST, - "SBW_CHAIN_NOT_SUPPORTED", + "INVALID_SMART_BACKEND_WALLET_TRANSACTION", ); } @@ -101,52 +125,8 @@ export const insertTransaction = async ( from: walletDetails.accountSignerAddress, accountAddress: queuedTransaction.from, target: queuedTransaction.to, - accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, - entrypointAddress: walletDetails.entrypointAddress ?? undefined, }; } - } catch { - // if wallet details are not found, this is a smart backend wallet using a v4 endpoint - } - - if (!walletDetails && queuedTransaction.accountAddress) { - try { - walletDetails = await getWalletDetails({ - address: queuedTransaction.accountAddress, - }); - - // when using v4 SDK with smart backend wallets, the following values are not set correctly: - // entrypointAddress is not set - // accountFactoryAddress is not set - if (walletDetails && isSmartBackendWallet(walletDetails)) { - if ( - !(await doesChainSupportService( - queuedTransaction.chainId, - "account-abstraction", - )) - ) { - throw createCustomError( - "Chain does not support smart backend wallets", - StatusCodes.BAD_REQUEST, - "SBW_CHAIN_NOT_SUPPORTED", - ); - } - - queuedTransaction = { - ...queuedTransaction, - entrypointAddress: walletDetails.entrypointAddress ?? undefined, - accountFactoryAddress: - walletDetails.accountFactoryAddress ?? undefined, - }; - } - } catch { - // 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", - ); - } } // Simulate the transaction. diff --git a/src/utils/transaction/types.ts b/src/utils/transaction/types.ts index c4591ce8f..ce71e4e68 100644 --- a/src/utils/transaction/types.ts +++ b/src/utils/transaction/types.ts @@ -1,5 +1,6 @@ import type { Address, Hex, toSerializableTransaction } from "thirdweb"; import type { TransactionType } from "viem"; +import { BackendWalletType } from "../../db/wallets/getWalletDetails"; // TODO: Replace with thirdweb SDK exported type when available. export type PopulatedTransaction = Awaited< @@ -52,6 +53,7 @@ export type InsertedTransaction = { export type QueuedTransaction = InsertedTransaction & { status: "queued"; + walletType: BackendWalletType; resendCount: number; queueId: string; queuedAt: Date; diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 5d218b787..1f5683ae1 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -2,6 +2,7 @@ import { Static } from "@sinclair/typebox"; import { UsageEvent } from "@thirdweb-dev/service-utils/cf-worker"; import { FastifyInstance } from "fastify"; import { Address, Hex } from "thirdweb"; +import { BackendWalletType } from "../db/wallets/getWalletDetails"; import { ADMIN_QUEUES_BASEPATH } from "../server/middleware/adminRoutes"; import { OPENAPI_ROUTES } from "../server/middleware/open-api"; import { contractParamSchema } from "../server/schemas/sharedApiSchemas"; @@ -22,6 +23,7 @@ export interface ReportUsageParams { | "api_request"; input: { chainId?: number; + walletType?: BackendWalletType; from?: Address; to?: Address; value?: bigint; @@ -113,6 +115,7 @@ export const reportUsage = (usageEvents: ReportUsageParams[]) => { action, clientId: thirdwebClientId, chainId: input.chainId, + walletType: input.walletType, walletAddress: input.from, contractAddress: input.to, transactionValue: input.value?.toString(), @@ -136,7 +139,7 @@ export const reportUsage = (usageEvents: ReportUsageParams[]) => { logger({ service: "worker", level: "error", - message: `Error:`, + message: "Error reporting usage event:", error: e, }); } From 392d655388b688a2f26286768341d5e8a751f1f2 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Sat, 30 Nov 2024 04:52:25 +0530 Subject: [PATCH 2/3] refactor insertTransaction --- src/utils/transaction/insertTransaction.ts | 287 ++++++++++++++------- 1 file changed, 197 insertions(+), 90 deletions(-) diff --git a/src/utils/transaction/insertTransaction.ts b/src/utils/transaction/insertTransaction.ts index 28fd9b406..da29bc8ad 100644 --- a/src/utils/transaction/insertTransaction.ts +++ b/src/utils/transaction/insertTransaction.ts @@ -2,9 +2,10 @@ import { StatusCodes } from "http-status-codes"; import { randomUUID } from "node:crypto"; import { TransactionDB } from "../../db/transactions/db"; import { - ParsedWalletDetails, getWalletDetails, isSmartBackendWallet, + type ParsedWalletDetails, + type SmartBackendWalletDetails, } from "../../db/wallets/getWalletDetails"; import { doesChainSupportService } from "../../lib/chain/chain-capabilities"; import { createCustomError } from "../../server/middleware/error"; @@ -15,121 +16,225 @@ import { reportUsage } from "../usage"; import { doSimulateTransaction } from "./simulateQueuedTransaction"; import type { InsertedTransaction, QueuedTransaction } from "./types"; +/** + * Transaction Processing Cases & SDK Compatibility Layer + * + * This code handles transaction processing across two SDK versions (v4 and v5) and two wallet types + * (smart backend wallets and regular wallets). Each case needs different handling: + * + * Case 1: V5 SDK with Smart Backend Wallet + * - 'from' address is the smart backend wallet address + * - accountAddress must NOT be set (SDK shouldn't allow interacting with other accounts) + * - Requires transformation: + * * from -> becomes signer address (from wallet.accountSignerAddress) + * * original from -> becomes account address + * * to -> becomes target + * * set isUserOp true + * * add accountFactoryAddress and entrypoint from wallet details + * + * Case 2: V4 SDK with Smart Backend Wallet + * - accountAddress is set to the smart backend wallet address + * - 'from' address not in wallet DB + * - Requires transformation: + * * add entrypoint and accountFactory addresses from wallet details + * + * Case 3: V5 SDK with Regular Wallet + * - 'from' address is a regular wallet + * - No transformation needed, just add wallet type + * - May optionally have accountAddress for sending via a smart account + * + * Case 4: V4 SDK with Regular Wallet + * - Similar to Case 3 + * - Only difference is how we detect wallet (via accountAddress) + */ + interface InsertTransactionData { insertedTransaction: InsertedTransaction; idempotencyKey?: string; shouldSimulate?: boolean; } +interface TransactionContext { + processedTransaction: QueuedTransaction; +} + +type SdkVersion = "v4" | "v5"; + +interface ResolvedWalletDetails { + sdkVersion: SdkVersion; + walletDetails: ParsedWalletDetails; +} + +const validateSmartBackendWalletChainSupport = async (chainId: number) => { + if (!(await doesChainSupportService(chainId, "account-abstraction"))) { + throw createCustomError( + "Chain does not support smart backend wallets", + StatusCodes.BAD_REQUEST, + "SBW_CHAIN_NOT_SUPPORTED", + ); + } +}; + /** - * Enqueue a transaction to be submitted onchain. - * - * @param args - * @returns queueId + * Transform transaction for Case 1 (V5 Smart Backend Wallet) + * Type guard ensures walletDetails has required smart wallet properties */ -export const insertTransaction = async ( - args: InsertTransactionData, -): Promise => { - const { insertedTransaction, idempotencyKey, shouldSimulate = false } = args; +const transformV5SmartBackendWallet = async ( + transaction: QueuedTransaction, + walletDetails: SmartBackendWalletDetails, // Note: narrowed type +): Promise => { + await validateSmartBackendWalletChainSupport(transaction.chainId); - // The queueId uniquely represents an enqueued transaction. - // It's also used as the idempotency key (default = no idempotence). - let queueId: string = randomUUID(); - if (idempotencyKey) { - queueId = idempotencyKey; - if (await TransactionDB.exists(queueId)) { - // No-op. Return the existing queueId. - return queueId; - } + if (transaction.accountAddress) { + throw createCustomError( + "Smart backend wallets do not support interacting with other smart accounts", + StatusCodes.BAD_REQUEST, + "INVALID_SMART_BACKEND_WALLET_INTERACTION", + ); } - // Get wallet details. For EOA and SBW (v5 endpoints), `from` should return a valid backend wallet. - // For SBW (v4 endpoints), `accountAddress` should return a valid backend wallet. - // Else the provided details are incorrect (user error). - let walletDetails: ParsedWalletDetails | undefined; - let isSmartBackendWalletV4 = false; + return { + ...transaction, + isUserOp: true, + signerAddress: walletDetails.accountSignerAddress, + from: walletDetails.accountSignerAddress, + accountAddress: transaction.from, // Original 'from' becomes the account + target: transaction.to, + accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, + entrypointAddress: walletDetails.entrypointAddress ?? undefined, + walletType: walletDetails.type, + }; +}; + +/** + * Transform transaction for Case 2 (V4 Smart Backend Wallet) + * Type guard ensures walletDetails has required smart wallet properties + */ +const transformV4SmartBackendWallet = async ( + transaction: QueuedTransaction, + walletDetails: SmartBackendWalletDetails, // Note: narrowed type +): Promise => { + await validateSmartBackendWalletChainSupport(transaction.chainId); + + return { + ...transaction, + entrypointAddress: walletDetails.entrypointAddress ?? undefined, + accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, + walletType: walletDetails.type, + }; +}; + +/** + * Try to resolve wallet details, determining if we're in V4 or V5 case + * For V5: wallet details should be found from 'from' address (Cases 1 & 3) + * For V4: wallet details are found from accountAddress (Cases 2 & 4) + */ +const resolveWalletDetails = async ( + transaction: QueuedTransaction, +): Promise => { + // Try V5 path first (Cases 1 & 3) try { - walletDetails = await getWalletDetails({ - walletAddress: insertedTransaction.from, + const walletDetails = await getWalletDetails({ + walletAddress: transaction.from, }); - } catch {} - if (!walletDetails && insertedTransaction.accountAddress) { - try { - walletDetails = await getWalletDetails({ - walletAddress: insertedTransaction.accountAddress, - }); - isSmartBackendWalletV4 = true; - } catch {} + return { sdkVersion: "v5", walletDetails }; + } catch {} // Silently handle V5 failure + + // If primary address fails and no accountAddress, we can't proceed + if (!transaction.accountAddress) { + throw createCustomError( + "Account not found", + StatusCodes.BAD_REQUEST, + "ACCOUNT_NOT_FOUND", + ); } - if (!walletDetails) { + + // Try V4 path (Cases 2 & 4) + try { + const walletDetails = await getWalletDetails({ + walletAddress: transaction.accountAddress, + }); + return { sdkVersion: "v4", walletDetails }; + } catch { throw createCustomError( "Account not found", StatusCodes.BAD_REQUEST, "ACCOUNT_NOT_FOUND", ); } +}; - let queuedTransaction: QueuedTransaction = { - ...insertedTransaction, - status: "queued", - queueId, - queuedAt: new Date(), - resendCount: 0, +/** + * Handle both transformation cases and add wallet type for non-transformed cases + * Uses type guard to ensure smart wallet properties are available when needed + */ +const detectAndTransformTransaction = async ( + transaction: QueuedTransaction, +): Promise => { + const { sdkVersion, walletDetails } = await resolveWalletDetails(transaction); - walletType: walletDetails.type, - from: getChecksumAddress(insertedTransaction.from), - to: getChecksumAddress(insertedTransaction.to), - signerAddress: getChecksumAddress(insertedTransaction.signerAddress), - accountAddress: getChecksumAddress(insertedTransaction.accountAddress), - accountSalt: insertedTransaction.accountSalt, - target: getChecksumAddress(insertedTransaction.target), - sender: getChecksumAddress(insertedTransaction.sender), - value: insertedTransaction.value ?? 0n, - }; + // isSmartBackendWallet is a type guard that narrows walletDetails + if (!isSmartBackendWallet(walletDetails)) { + // Cases 3 & 4: Regular wallet cases just need wallet type + return { + processedTransaction: { + ...transaction, + walletType: walletDetails.type, + }, + }; + } - // Handle smart backend wallets details. - if (isSmartBackendWallet(walletDetails)) { - if ( - !(await doesChainSupportService( - queuedTransaction.chainId, - "account-abstraction", - )) - ) { - throw createCustomError( - `Smart backend wallets do not support chain ${queuedTransaction.chainId}.`, - StatusCodes.BAD_REQUEST, - "INVALID_SMART_BACKEND_WALLET_TRANSACTION", - ); - } + // walletDetails is now narrowed to SmartBackendWalletDetails + const processedTransaction = await (sdkVersion === "v5" + ? transformV5SmartBackendWallet(transaction, walletDetails) + : transformV4SmartBackendWallet(transaction, walletDetails)); - queuedTransaction = { - ...queuedTransaction, - accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, - entrypointAddress: walletDetails.entrypointAddress ?? undefined, - }; + return { processedTransaction }; +}; - if (!isSmartBackendWalletV4) { - if (queuedTransaction.accountAddress) { - // Disallow smart backend wallets from sending userOps. - throw createCustomError( - "Smart backend wallets do not support sending transactions with other smart accounts", - StatusCodes.BAD_REQUEST, - "INVALID_SMART_BACKEND_WALLET_TRANSACTION", - ); - } - - queuedTransaction = { - ...queuedTransaction, - isUserOp: true, - signerAddress: walletDetails.accountSignerAddress, - from: walletDetails.accountSignerAddress, - accountAddress: queuedTransaction.from, - target: queuedTransaction.to, - }; - } +const normalizeAddresses = ( + transaction: InsertedTransaction, +): QueuedTransaction => ({ + ...transaction, + status: "queued", + queueId: "", // Will be set later + queuedAt: new Date(), + resendCount: 0, + from: getChecksumAddress(transaction.from), + to: getChecksumAddress(transaction.to), + signerAddress: getChecksumAddress(transaction.signerAddress), + accountAddress: getChecksumAddress(transaction.accountAddress), + accountSalt: transaction.accountSalt, + target: getChecksumAddress(transaction.target), + sender: getChecksumAddress(transaction.sender), + value: transaction.value ?? 0n, + walletType: "local", // Will be set later +}); + +/** + * Enqueue a transaction to be submitted onchain. + */ +export const insertTransaction = async ( + args: InsertTransactionData, +): Promise => { + const { insertedTransaction, idempotencyKey, shouldSimulate = false } = args; + + // Handle idempotency + const queueId: string = idempotencyKey ?? randomUUID(); + if (idempotencyKey && (await TransactionDB.exists(queueId))) { + return queueId; } - // Simulate the transaction. + // Normalize addresses and create initial transaction + let queuedTransaction = normalizeAddresses(insertedTransaction); + queuedTransaction.queueId = queueId; + + // Detect case and transform transaction accordingly + const { processedTransaction } = + await detectAndTransformTransaction(queuedTransaction); + queuedTransaction = processedTransaction; + + // Simulate if requested if (shouldSimulate) { const error = await doSimulateTransaction(queuedTransaction); if (error) { @@ -141,13 +246,15 @@ export const insertTransaction = async ( } } + // Queue transaction await TransactionDB.set(queuedTransaction); await SendTransactionQueue.add({ queueId: queuedTransaction.queueId, resendCount: 0, }); - reportUsage([{ action: "queue_tx", input: queuedTransaction }]); + // Report metrics + reportUsage([{ action: "queue_tx", input: queuedTransaction }]); recordMetrics({ event: "transaction_queued", params: { From 7a740a56b3fef714b49d39b8496e0ab1dbccaaac Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 6 Dec 2024 00:03:29 +0530 Subject: [PATCH 3/3] refactor: update transaction handling to use InsertedTransaction type and add walletType field --- .../backend-wallet/simulateTransaction.ts | 12 +- src/utils/transaction/insertTransaction.ts | 251 ++++++++---------- .../transaction/simulateQueuedTransaction.ts | 5 +- .../migratePostgresTransactionsWorker.ts | 4 +- 4 files changed, 125 insertions(+), 147 deletions(-) diff --git a/src/server/routes/backend-wallet/simulateTransaction.ts b/src/server/routes/backend-wallet/simulateTransaction.ts index e30e5d662..c72a960ec 100644 --- a/src/server/routes/backend-wallet/simulateTransaction.ts +++ b/src/server/routes/backend-wallet/simulateTransaction.ts @@ -1,10 +1,9 @@ import { Type, type Static } from "@sinclair/typebox"; import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; -import { randomUUID } from "node:crypto"; import type { Address, Hex } from "thirdweb"; import { doSimulateTransaction } from "../../../utils/transaction/simulateQueuedTransaction"; -import type { QueuedTransaction } from "../../../utils/transaction/types"; +import type { InsertedTransaction } from "../../../utils/transaction/types"; import { createCustomError } from "../../middleware/error"; import { AddressSchema } from "../../schemas/address"; import { @@ -86,12 +85,7 @@ export async function simulateTransaction(fastify: FastifyInstance) { const chainId = await getChainIdFromChain(chain); - let queuedTransaction: QueuedTransaction = { - status: "queued", - queueId: randomUUID(), - queuedAt: new Date(), - resendCount: 0, - + const insertedTransaction: InsertedTransaction = { chainId, from: walletAddress as Address, to: toAddress as Address, @@ -112,7 +106,7 @@ export async function simulateTransaction(fastify: FastifyInstance) { }), }; - const simulateError = await doSimulateTransaction(queuedTransaction); + const simulateError = await doSimulateTransaction(insertedTransaction); if (simulateError) { throw createCustomError( simulateError, diff --git a/src/utils/transaction/insertTransaction.ts b/src/utils/transaction/insertTransaction.ts index da29bc8ad..a3cd8bee6 100644 --- a/src/utils/transaction/insertTransaction.ts +++ b/src/utils/transaction/insertTransaction.ts @@ -19,33 +19,51 @@ import type { InsertedTransaction, QueuedTransaction } from "./types"; /** * Transaction Processing Cases & SDK Compatibility Layer * - * This code handles transaction processing across two SDK versions (v4 and v5) and two wallet types - * (smart backend wallets and regular wallets). Each case needs different handling: + * Transaction Detection Logic: + * 1. First, try to find wallet by 'from' address: + * - If found and is smart backend wallet -> Must be V5 SDK, needs transformation + * - If found and is regular wallet -> Could be V4 or V5, no transformation needed + * - If not found -> Check for V4 smart backend wallet case + * 2. If 'from' not found and has accountAddress: + * - If found and is smart backend wallet -> V4 SDK case + * - Otherwise -> Server Error (invalid wallet configuration) + * 3. If 'from' not found and no accountAddress -> Error * - * Case 1: V5 SDK with Smart Backend Wallet - * - 'from' address is the smart backend wallet address - * - accountAddress must NOT be set (SDK shouldn't allow interacting with other accounts) - * - Requires transformation: + * Cases by Detection Path: + * + * Found by 'from' address, is Smart Backend Wallet: + * Case 1: V5 Smart Backend Wallet + * - 'from' is smart backend wallet address + * - accountAddress must NOT be set + * - Needs transformation: * * from -> becomes signer address (from wallet.accountSignerAddress) * * original from -> becomes account address * * to -> becomes target * * set isUserOp true - * * add accountFactoryAddress and entrypoint from wallet details - * - * Case 2: V4 SDK with Smart Backend Wallet - * - accountAddress is set to the smart backend wallet address - * - 'from' address not in wallet DB - * - Requires transformation: - * * add entrypoint and accountFactory addresses from wallet details + * * add wallet specific addresses (entrypoint, factory) * - * Case 3: V5 SDK with Regular Wallet - * - 'from' address is a regular wallet + * Found by 'from' address, is Regular Wallet: + * Case 2: Regular Wallet (V4 or V5) + * - 'from' exists as regular wallet + * - may optionally have accountAddress for AA * - No transformation needed, just add wallet type - * - May optionally have accountAddress for sending via a smart account * - * Case 4: V4 SDK with Regular Wallet - * - Similar to Case 3 - * - Only difference is how we detect wallet (via accountAddress) + * Found by accountAddress after 'from' not found: + * Case 3: V4 Smart Backend Wallet + * - 'from' not found in DB + * - accountAddress must exist as smart backend wallet + * - Needs transformation: + * * add wallet specific addresses (entrypoint, factory) + * + * Critical Requirements: + * 1. Smart backend wallets must be validated for chain support + * 2. V5 smart backend wallets must not have accountAddress set + * 3. Every transaction needs a wallet type + * 4. Addresses must be normalized to checksum format + * 5. Properties like accountSignerAddress, accountFactoryAddress, and entrypoint + * are only available on SmartBackendWalletDetails type + * 6. Only smart backend wallets can be found via accountAddress lookup when 'from' + * is not found - finding anything else indicates invalid wallet configuration */ interface InsertTransactionData { @@ -54,17 +72,6 @@ interface InsertTransactionData { shouldSimulate?: boolean; } -interface TransactionContext { - processedTransaction: QueuedTransaction; -} - -type SdkVersion = "v4" | "v5"; - -interface ResolvedWalletDetails { - sdkVersion: SdkVersion; - walletDetails: ParsedWalletDetails; -} - const validateSmartBackendWalletChainSupport = async (chainId: number) => { if (!(await doesChainSupportService(chainId, "account-abstraction"))) { throw createCustomError( @@ -75,13 +82,9 @@ const validateSmartBackendWalletChainSupport = async (chainId: number) => { } }; -/** - * Transform transaction for Case 1 (V5 Smart Backend Wallet) - * Type guard ensures walletDetails has required smart wallet properties - */ const transformV5SmartBackendWallet = async ( transaction: QueuedTransaction, - walletDetails: SmartBackendWalletDetails, // Note: narrowed type + walletDetails: SmartBackendWalletDetails, ): Promise => { await validateSmartBackendWalletChainSupport(transaction.chainId); @@ -98,7 +101,7 @@ const transformV5SmartBackendWallet = async ( isUserOp: true, signerAddress: walletDetails.accountSignerAddress, from: walletDetails.accountSignerAddress, - accountAddress: transaction.from, // Original 'from' becomes the account + accountAddress: transaction.from, target: transaction.to, accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, entrypointAddress: walletDetails.entrypointAddress ?? undefined, @@ -106,13 +109,9 @@ const transformV5SmartBackendWallet = async ( }; }; -/** - * Transform transaction for Case 2 (V4 Smart Backend Wallet) - * Type guard ensures walletDetails has required smart wallet properties - */ const transformV4SmartBackendWallet = async ( transaction: QueuedTransaction, - walletDetails: SmartBackendWalletDetails, // Note: narrowed type + walletDetails: SmartBackendWalletDetails, ): Promise => { await validateSmartBackendWalletChainSupport(transaction.chainId); @@ -124,93 +123,6 @@ const transformV4SmartBackendWallet = async ( }; }; -/** - * Try to resolve wallet details, determining if we're in V4 or V5 case - * For V5: wallet details should be found from 'from' address (Cases 1 & 3) - * For V4: wallet details are found from accountAddress (Cases 2 & 4) - */ -const resolveWalletDetails = async ( - transaction: QueuedTransaction, -): Promise => { - // Try V5 path first (Cases 1 & 3) - try { - const walletDetails = await getWalletDetails({ - walletAddress: transaction.from, - }); - return { sdkVersion: "v5", walletDetails }; - } catch {} // Silently handle V5 failure - - // If primary address fails and no accountAddress, we can't proceed - if (!transaction.accountAddress) { - throw createCustomError( - "Account not found", - StatusCodes.BAD_REQUEST, - "ACCOUNT_NOT_FOUND", - ); - } - - // Try V4 path (Cases 2 & 4) - try { - const walletDetails = await getWalletDetails({ - walletAddress: transaction.accountAddress, - }); - return { sdkVersion: "v4", walletDetails }; - } catch { - throw createCustomError( - "Account not found", - StatusCodes.BAD_REQUEST, - "ACCOUNT_NOT_FOUND", - ); - } -}; - -/** - * Handle both transformation cases and add wallet type for non-transformed cases - * Uses type guard to ensure smart wallet properties are available when needed - */ -const detectAndTransformTransaction = async ( - transaction: QueuedTransaction, -): Promise => { - const { sdkVersion, walletDetails } = await resolveWalletDetails(transaction); - - // isSmartBackendWallet is a type guard that narrows walletDetails - if (!isSmartBackendWallet(walletDetails)) { - // Cases 3 & 4: Regular wallet cases just need wallet type - return { - processedTransaction: { - ...transaction, - walletType: walletDetails.type, - }, - }; - } - - // walletDetails is now narrowed to SmartBackendWalletDetails - const processedTransaction = await (sdkVersion === "v5" - ? transformV5SmartBackendWallet(transaction, walletDetails) - : transformV4SmartBackendWallet(transaction, walletDetails)); - - return { processedTransaction }; -}; - -const normalizeAddresses = ( - transaction: InsertedTransaction, -): QueuedTransaction => ({ - ...transaction, - status: "queued", - queueId: "", // Will be set later - queuedAt: new Date(), - resendCount: 0, - from: getChecksumAddress(transaction.from), - to: getChecksumAddress(transaction.to), - signerAddress: getChecksumAddress(transaction.signerAddress), - accountAddress: getChecksumAddress(transaction.accountAddress), - accountSalt: transaction.accountSalt, - target: getChecksumAddress(transaction.target), - sender: getChecksumAddress(transaction.sender), - value: transaction.value ?? 0n, - walletType: "local", // Will be set later -}); - /** * Enqueue a transaction to be submitted onchain. */ @@ -225,14 +137,83 @@ export const insertTransaction = async ( return queueId; } - // Normalize addresses and create initial transaction - let queuedTransaction = normalizeAddresses(insertedTransaction); - queuedTransaction.queueId = queueId; + // Normalize addresses + let queuedTransaction: QueuedTransaction = { + ...insertedTransaction, + status: "queued", + queueId, + queuedAt: new Date(), + resendCount: 0, + from: getChecksumAddress(insertedTransaction.from), + to: getChecksumAddress(insertedTransaction.to), + signerAddress: getChecksumAddress(insertedTransaction.signerAddress), + accountAddress: getChecksumAddress(insertedTransaction.accountAddress), + accountSalt: insertedTransaction.accountSalt, + target: getChecksumAddress(insertedTransaction.target), + sender: getChecksumAddress(insertedTransaction.sender), + value: insertedTransaction.value ?? 0n, + walletType: "local", // we override this later + }; + + // First attempt: try to find wallet by 'from' address + let walletDetails: ParsedWalletDetails | undefined; + let walletFoundByFrom = false; - // Detect case and transform transaction accordingly - const { processedTransaction } = - await detectAndTransformTransaction(queuedTransaction); - queuedTransaction = processedTransaction; + try { + walletDetails = await getWalletDetails({ + walletAddress: queuedTransaction.from, + }); + walletFoundByFrom = true; + } catch {} + + // Case 1 & 2: Wallet found by 'from' + if (walletFoundByFrom && walletDetails) { + if (isSmartBackendWallet(walletDetails)) { + // Case 1: V5 Smart Backend Wallet + queuedTransaction = await transformV5SmartBackendWallet( + queuedTransaction, + walletDetails, + ); + } else { + // Case 2: Regular wallet (V4 or V5) - just add wallet type + queuedTransaction.walletType = walletDetails.type; + } + } else { + // From this point on, we're in Case 3 territory - check for V4 smart backend wallet + if (!queuedTransaction.accountAddress) { + throw createCustomError( + "Account not found", + StatusCodes.BAD_REQUEST, + "ACCOUNT_NOT_FOUND", + ); + } + + try { + walletDetails = await getWalletDetails({ + walletAddress: queuedTransaction.accountAddress, + }); + } catch { + throw createCustomError( + "Account not found", + StatusCodes.BAD_REQUEST, + "ACCOUNT_NOT_FOUND", + ); + } + + // Case 3: Must be a V4 smart backend wallet + if (!isSmartBackendWallet(walletDetails)) { + throw createCustomError( + "Invalid wallet configuration in database - non-smart-backend wallet found via accountAddress", + StatusCodes.INTERNAL_SERVER_ERROR, + "INVALID_WALLET_CONFIGURATION", + ); + } + + queuedTransaction = await transformV4SmartBackendWallet( + queuedTransaction, + walletDetails, + ); + } // Simulate if requested if (shouldSimulate) { diff --git a/src/utils/transaction/simulateQueuedTransaction.ts b/src/utils/transaction/simulateQueuedTransaction.ts index 5b94db96f..ebdea0a88 100644 --- a/src/utils/transaction/simulateQueuedTransaction.ts +++ b/src/utils/transaction/simulateQueuedTransaction.ts @@ -9,7 +9,7 @@ import { getAccount } from "../account"; import { getSmartWalletV5 } from "../cache/getSmartWalletV5"; import { getChain } from "../chain"; import { thirdwebClient } from "../sdk"; -import type { AnyTransaction } from "./types"; +import type { InsertedTransaction } from "./types"; /** * Simulate the queued transaction. @@ -17,7 +17,7 @@ import type { AnyTransaction } from "./types"; * @returns string - The simulation error, or null if no error. */ export const doSimulateTransaction = async ( - transaction: AnyTransaction, + transaction: InsertedTransaction, ): Promise => { const { chainId, @@ -70,6 +70,7 @@ export const doSimulateTransaction = async ( account, }); return null; + // biome-ignore lint/suspicious/noExplicitAny: any error } catch (e: any) { // Error should be of type TransactionError in the thirdweb SDK. return `${e.name}: ${e.message}`; diff --git a/src/worker/tasks/migratePostgresTransactionsWorker.ts b/src/worker/tasks/migratePostgresTransactionsWorker.ts index 7547d9b75..9cb7c7ee6 100644 --- a/src/worker/tasks/migratePostgresTransactionsWorker.ts +++ b/src/worker/tasks/migratePostgresTransactionsWorker.ts @@ -198,11 +198,13 @@ const toQueuedTransaction = (row: Transactions): QueuedTransaction => { resendCount: 0, isUserOp: !!row.accountAddress, - chainId: parseInt(row.chainId), + chainId: Number.parseInt(row.chainId), from: normalizeAddress(row.fromAddress), to: normalizeAddress(row.toAddress), value: row.value ? BigInt(row.value) : 0n, + walletType: "local", // NOTE: fetching this is async, don't fetch it here because it's not needed. + data: row.data as Hex, functionName: row.functionName ?? undefined, functionArgs: row.functionArgs?.split(","),