diff --git a/src/db/wallets/getWalletDetails.ts b/src/db/wallets/getWalletDetails.ts index fcc8db3f..48176b24 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 ac810a79..8da04887 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/routes/backend-wallet/simulateTransaction.ts b/src/server/routes/backend-wallet/simulateTransaction.ts index e30e5d66..c72a960e 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/server/utils/wallets/getLocalWallet.ts b/src/server/utils/wallets/getLocalWallet.ts index 0f445642..a2495b5a 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 0c8373f7..fc71aa19 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 870561b2..5d77fd40 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 6b078594..c7589435 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 fe79018f..a3cd8bee 100644 --- a/src/utils/transaction/insertTransaction.ts +++ b/src/utils/transaction/insertTransaction.ts @@ -5,6 +5,7 @@ import { getWalletDetails, isSmartBackendWallet, type ParsedWalletDetails, + type SmartBackendWalletDetails, } from "../../db/wallets/getWalletDetails"; import { doesChainSupportService } from "../../lib/chain/chain-capabilities"; import { createCustomError } from "../../server/middleware/error"; @@ -15,41 +16,134 @@ import { reportUsage } from "../usage"; import { doSimulateTransaction } from "./simulateQueuedTransaction"; import type { InsertedTransaction, QueuedTransaction } from "./types"; +/** + * Transaction Processing Cases & SDK Compatibility Layer + * + * 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 + * + * 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 wallet specific addresses (entrypoint, factory) + * + * 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 + * + * 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 { insertedTransaction: InsertedTransaction; idempotencyKey?: string; shouldSimulate?: boolean; } +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", + ); + } +}; + +const transformV5SmartBackendWallet = async ( + transaction: QueuedTransaction, + walletDetails: SmartBackendWalletDetails, +): Promise => { + await validateSmartBackendWalletChainSupport(transaction.chainId); + + if (transaction.accountAddress) { + throw createCustomError( + "Smart backend wallets do not support interacting with other smart accounts", + StatusCodes.BAD_REQUEST, + "INVALID_SMART_BACKEND_WALLET_INTERACTION", + ); + } + + return { + ...transaction, + isUserOp: true, + signerAddress: walletDetails.accountSignerAddress, + from: walletDetails.accountSignerAddress, + accountAddress: transaction.from, + target: transaction.to, + accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, + entrypointAddress: walletDetails.entrypointAddress ?? undefined, + walletType: walletDetails.type, + }; +}; + +const transformV4SmartBackendWallet = async ( + transaction: QueuedTransaction, + walletDetails: SmartBackendWalletDetails, +): Promise => { + await validateSmartBackendWalletChainSupport(transaction.chainId); + + return { + ...transaction, + entrypointAddress: walletDetails.entrypointAddress ?? undefined, + accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, + walletType: walletDetails.type, + }; +}; + /** * Enqueue a transaction to be submitted onchain. - * - * @param args - * @returns queueId */ export const insertTransaction = async ( args: InsertTransactionData, ): Promise => { const { insertedTransaction, idempotencyKey, shouldSimulate = false } = args; - // 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; - } + // Handle idempotency + const queueId: string = idempotencyKey ?? randomUUID(); + if (idempotencyKey && (await TransactionDB.exists(queueId))) { + return 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), @@ -58,98 +152,70 @@ export const insertTransaction = async ( 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; try { walletDetails = await getWalletDetails({ - address: queuedTransaction.from, + walletAddress: queuedTransaction.from, }); + walletFoundByFrom = true; + } catch {} - // 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 + // Case 1 & 2: Wallet found by 'from' + if (walletFoundByFrom && walletDetails) { if (isSmartBackendWallet(walletDetails)) { - if (queuedTransaction.accountAddress) { - throw createCustomError( - "Smart backend wallets do not support interacting 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", - ); - } - - queuedTransaction = { - ...queuedTransaction, - isUserOp: true, - signerAddress: walletDetails.accountSignerAddress, - from: walletDetails.accountSignerAddress, - accountAddress: queuedTransaction.from, - target: queuedTransaction.to, - accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, - entrypointAddress: walletDetails.entrypointAddress ?? undefined, - }; + // 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", + ); } - } 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, + walletAddress: 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", ); } + + // 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 the transaction. + // Simulate if requested if (shouldSimulate) { const error = await doSimulateTransaction(queuedTransaction); if (error) { @@ -161,13 +227,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: { diff --git a/src/utils/transaction/simulateQueuedTransaction.ts b/src/utils/transaction/simulateQueuedTransaction.ts index 5b94db96..ebdea0a8 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/utils/transaction/types.ts b/src/utils/transaction/types.ts index c4591ce8..ce71e4e6 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 002c437c..ebe04c41 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/openApi"; 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, }); } diff --git a/src/worker/tasks/migratePostgresTransactionsWorker.ts b/src/worker/tasks/migratePostgresTransactionsWorker.ts index 7547d9b7..9cb7c7ee 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(","),