diff --git a/src/prisma/migrations/20241211085444_add_backend_wallet_lite_acccess_table/migration.sql b/src/prisma/migrations/20241211085444_add_backend_wallet_lite_acccess_table/migration.sql new file mode 100644 index 000000000..c90591655 --- /dev/null +++ b/src/prisma/migrations/20241211085444_add_backend_wallet_lite_acccess_table/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "backend_wallet_lite_access" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "dashboardUserAddress" TEXT NOT NULL, + "accountAddress" TEXT, + "signerAddress" TEXT, + "encryptedJson" TEXT, + "salt" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "backend_wallet_lite_access_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "backend_wallet_lite_access_teamId_idx" ON "backend_wallet_lite_access"("teamId"); + +-- AddForeignKey +ALTER TABLE "backend_wallet_lite_access" ADD CONSTRAINT "backend_wallet_lite_access_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "wallet_details"("address") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 2e04b2d8b..8e415b2fc 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -99,9 +99,29 @@ model WalletDetails { accountFactoryAddress String? @map("accountFactoryAddress") /// optional even for smart wallet, if not available default factory will be used entrypointAddress String? @map("entrypointAddress") /// optional even for smart wallet, if not available SDK will use default entrypoint + BackendWalletLiteAccess BackendWalletLiteAccess[] + @@map("wallet_details") } +model BackendWalletLiteAccess { + id String @id @default(uuid()) + teamId String + dashboardUserAddress String + accountAddress String? + signerAddress String? + encryptedJson String? + salt String + + createdAt DateTime @default(now()) + deletedAt DateTime? + + account WalletDetails? @relation(fields: [accountAddress], references: [address]) + + @@index([teamId]) + @@map("backend_wallet_lite_access") +} + model WalletNonce { address String @map("address") chainId String @map("chainId") diff --git a/src/server/middleware/logs.ts b/src/server/middleware/logs.ts index b0c494343..59ec9329f 100644 --- a/src/server/middleware/logs.ts +++ b/src/server/middleware/logs.ts @@ -1,71 +1,90 @@ -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyRequest } from "fastify"; import { stringify } from "thirdweb/utils"; import { logger } from "../../shared/utils/logger"; import { ADMIN_QUEUES_BASEPATH } from "./admin-routes"; import { OPENAPI_ROUTES } from "./open-api"; -const SKIP_LOG_PATHS = new Set([ - "", +const IGNORE_LOG_PATHS = new Set([ "/", "/favicon.ico", "/system/health", "/static", ...OPENAPI_ROUTES, - // Skip these routes case of importing sensitive details. +]); + +const SENSITIVE_LOG_PATHS = new Set([ "/backend-wallet/import", "/configuration/wallets", + "/backend-wallet/lite", ]); +function shouldLog(request: FastifyRequest) { + if (!request.routeOptions.url) { + return false; + } + if (request.method === "OPTIONS") { + return false; + } + if ( + request.method === "POST" && + SENSITIVE_LOG_PATHS.has(request.routeOptions.url) + ) { + return false; + } + if (IGNORE_LOG_PATHS.has(request.routeOptions.url)) { + return false; + } + if (request.routeOptions.url.startsWith(ADMIN_QUEUES_BASEPATH)) { + return false; + } + return true; +} + export function withRequestLogs(server: FastifyInstance) { server.addHook("onSend", async (request, reply, payload) => { - if ( - request.method === "OPTIONS" || - !request.routeOptions.url || - SKIP_LOG_PATHS.has(request.routeOptions.url) || - request.routeOptions.url.startsWith(ADMIN_QUEUES_BASEPATH) - ) { - return payload; - } - - const { method, routeOptions, headers, params, query, body } = request; - const { statusCode, elapsedTime } = reply; - const isError = statusCode >= 400; + if (shouldLog(request)) { + const { method, routeOptions, headers, params, query, body } = request; + const { statusCode, elapsedTime } = reply; + const isError = statusCode >= 400; - const extractedHeaders = { - "x-backend-wallet-address": headers["x-backend-wallet-address"], - "x-idempotency-key": headers["x-idempotency-key"], - "x-account-address": headers["x-account-address"], - "x-account-factory-address": headers["x-account-factory-address"], - "x-account-salt": headers["x-account-salt"], - }; + const extractedHeaders = { + "x-backend-wallet-address": headers["x-backend-wallet-address"], + "x-idempotency-key": headers["x-idempotency-key"], + "x-account-address": headers["x-account-address"], + "x-account-factory-address": headers["x-account-factory-address"], + "x-account-salt": headers["x-account-salt"], + }; - const paramsStr = - params && Object.keys(params).length - ? `params=${stringify(params)}` - : undefined; - const queryStr = - query && Object.keys(query).length - ? `querystring=${stringify(query)}` - : undefined; - const bodyStr = - body && Object.keys(body).length ? `body=${stringify(body)}` : undefined; - const payloadStr = isError ? `payload=${payload}` : undefined; + const paramsStr = + params && Object.keys(params).length + ? `params=${stringify(params)}` + : undefined; + const queryStr = + query && Object.keys(query).length + ? `querystring=${stringify(query)}` + : undefined; + const bodyStr = + body && Object.keys(body).length + ? `body=${stringify(body)}` + : undefined; + const payloadStr = isError ? `payload=${payload}` : undefined; - logger({ - service: "server", - level: isError ? "error" : "info", - message: [ - `[Request complete - ${statusCode}]`, - `method=${method}`, - `path=${routeOptions.url}`, - `headers=${stringify(extractedHeaders)}`, - paramsStr, - queryStr, - bodyStr, - `duration=${elapsedTime.toFixed(1)}ms`, - payloadStr, - ].join(" "), - }); + logger({ + service: "server", + level: isError ? "error" : "info", + message: [ + `[Request complete - ${statusCode}]`, + `method=${method}`, + `path=${routeOptions.url}`, + `headers=${stringify(extractedHeaders)}`, + paramsStr, + queryStr, + bodyStr, + `duration=${elapsedTime.toFixed(1)}ms`, + payloadStr, + ].join(" "), + }); + } return payload; }); diff --git a/src/server/routes/backend-wallet/lite/create.ts b/src/server/routes/backend-wallet/lite/create.ts new file mode 100644 index 000000000..37a39c70b --- /dev/null +++ b/src/server/routes/backend-wallet/lite/create.ts @@ -0,0 +1,123 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { checksumAddress } from "thirdweb/utils"; +import { getBackendWalletLiteAccess } from "../../../../shared/db/wallets/get-backend-wallet-lite-access"; +import { AddressSchema } from "../../../schemas/address"; +import { standardResponseSchema } from "../../../schemas/shared-api-schemas"; +import { createCustomError } from "../../../middleware/error"; +import { + DEFAULT_ACCOUNT_FACTORY_V0_7, + ENTRYPOINT_ADDRESS_v0_7, +} from "thirdweb/wallets/smart"; +import { createSmartLocalWalletDetails } from "../../../utils/wallets/create-smart-wallet"; +import { updateBackendWalletLiteAccess } from "../../../../shared/db/wallets/update-backend-wallet-lite-access"; + +const requestSchema = Type.Object({ + teamId: Type.String({ + description: "Wallets are listed for this team.", + }), +}); + +const requestBodySchema = Type.Object({ + salt: Type.String(), + litePassword: Type.String(), +}); + +const responseSchema = Type.Object({ + result: Type.Object({ + walletAddress: Type.Union([AddressSchema, Type.Null()], { + description: "The Engine Lite wallet address, if created.", + }), + salt: Type.String({ + description: "The salt used to encrypt the Engine Lite wallet address..", + }), + }), +}); + +responseSchema.example = { + result: { + walletAddress: "0x....", + salt: "2caaddce3d66ed4bee1a6ba9a29c98eb6d375635f62941655702bdff74939023", + }, +}; + +export const createBackendWalletLiteRoute = async ( + fastify: FastifyInstance, +) => { + fastify.withTypeProvider().route<{ + Params: Static; + Body: Static; + Reply: Static; + }>({ + method: "POST", + url: "/backend-wallet/lite/:teamId", + schema: { + summary: "Create backend wallet (Lite)", + description: "Create a backend wallet used for Engine Lite.", + tags: ["Backend Wallet"], + operationId: "createBackendWalletsLite", + params: requestSchema, + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + hide: true, + }, + handler: async (req, reply) => { + const dashboardUserAddress = checksumAddress(req.user.address); + if (!dashboardUserAddress) { + throw createCustomError( + "This endpoint must be called from the thirdweb dashboard.", + StatusCodes.FORBIDDEN, + "DASHBOARD_AUTH_REQUIRED", + ); + } + + const { teamId } = req.params; + const { salt, litePassword } = req.body; + + const liteAccess = await getBackendWalletLiteAccess({ teamId }); + if ( + !liteAccess || + liteAccess.teamId !== teamId || + liteAccess.dashboardUserAddress !== dashboardUserAddress || + liteAccess.salt !== salt + ) { + throw createCustomError( + "The salt does not match the authenticated user. Try requesting a backend wallet again.", + StatusCodes.BAD_REQUEST, + "INVALID_LITE_WALLET_SALT", + ); + } + + // Generate a signer wallet and store the smart:local wallet, encrypted with `litePassword`. + const walletDetails = await createSmartLocalWalletDetails({ + label: `${teamId} (${new Date()})`, + accountFactoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7, + entrypointAddress: ENTRYPOINT_ADDRESS_v0_7, + encryptionPassword: litePassword, + }); + if (!walletDetails.accountSignerAddress || !walletDetails.encryptedJson) { + throw new Error( + "Created smart:local wallet is missing required fields.", + ); + } + + await updateBackendWalletLiteAccess({ + id: liteAccess.id, + accountAddress: walletDetails.address, + signerAddress: walletDetails.accountSignerAddress, + encryptedJson: walletDetails.encryptedJson, + }); + + reply.status(StatusCodes.OK).send({ + result: { + walletAddress: walletDetails.address, + salt, + }, + }); + }, + }); +}; diff --git a/src/server/routes/backend-wallet/lite/get.ts b/src/server/routes/backend-wallet/lite/get.ts new file mode 100644 index 000000000..e24f7881e --- /dev/null +++ b/src/server/routes/backend-wallet/lite/get.ts @@ -0,0 +1,94 @@ +import { randomBytes } from "node:crypto"; +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { checksumAddress } from "thirdweb/utils"; +import { createBackendWalletLiteAccess } from "../../../../shared/db/wallets/create-backend-wallet-lite-access"; +import { getBackendWalletLiteAccess } from "../../../../shared/db/wallets/get-backend-wallet-lite-access"; +import { AddressSchema } from "../../../schemas/address"; +import { standardResponseSchema } from "../../../schemas/shared-api-schemas"; +import { createCustomError } from "../../../middleware/error"; + +const requestSchema = Type.Object({ + teamId: Type.String({ + description: "Wallets are listed for this team.", + }), +}); + +const responseSchema = Type.Object({ + result: Type.Object({ + walletAddress: Type.Union([AddressSchema, Type.Null()], { + description: "The Engine Lite wallet address, if created.", + }), + salt: Type.String({ + description: "The salt used to encrypt the Engine Lite wallet address..", + }), + }), +}); + +responseSchema.example = { + result: { + walletAddress: "0x....", + salt: "2caaddce3d66ed4bee1a6ba9a29c98eb6d375635f62941655702bdff74939023", + }, +}; + +export const listBackendWalletsLiteRoute = async (fastify: FastifyInstance) => { + fastify.withTypeProvider().route<{ + Params: Static; + Reply: Static; + }>({ + method: "GET", + url: "/backend-wallet/lite/:teamId", + schema: { + summary: "List backend wallets (Lite)", + description: "List backend wallets used for Engine Lite.", + tags: ["Backend Wallet"], + operationId: "listBackendWalletsLite", + params: requestSchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + hide: true, + }, + handler: async (req, reply) => { + const dashboardUserAddress = checksumAddress(req.user.address); + if (!dashboardUserAddress) { + throw createCustomError( + "This endpoint must be called from the thirdweb dashboard.", + StatusCodes.FORBIDDEN, + "DASHBOARD_AUTH_REQUIRED", + ); + } + + const { teamId } = req.params; + const liteAccess = await getBackendWalletLiteAccess({ teamId }); + + // If a wallet exists, return it. + if (liteAccess?.accountAddress) { + return reply.status(StatusCodes.OK).send({ + result: { + walletAddress: liteAccess.accountAddress, + salt: liteAccess.salt, + }, + }); + } + + // Else generate a salt and have the developer sign it. + const salt = randomBytes(32).toString("hex"); + await createBackendWalletLiteAccess({ + teamId, + dashboardUserAddress, + salt, + }); + + reply.status(StatusCodes.OK).send({ + result: { + walletAddress: null, + salt, + }, + }); + }, + }); +}; diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index b02317213..7116a9cc6 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 { createBackendWalletLiteRoute } from "./backend-wallet/lite/create"; +import { listBackendWalletsLiteRoute } from "./backend-wallet/lite/get"; export async function withRoutes(fastify: FastifyInstance) { // Backend Wallets @@ -134,6 +136,10 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(getBackendWalletNonce); await fastify.register(simulateTransaction); + // Engine Lite + await fastify.register(createBackendWalletLiteRoute); + await fastify.register(listBackendWalletsLiteRoute); + // Configuration await fastify.register(getWalletsConfiguration); await fastify.register(updateWalletsConfiguration); diff --git a/src/server/utils/wallets/create-local-wallet.ts b/src/server/utils/wallets/create-local-wallet.ts index 2a1d3d7bd..f31354436 100644 --- a/src/server/utils/wallets/create-local-wallet.ts +++ b/src/server/utils/wallets/create-local-wallet.ts @@ -13,7 +13,7 @@ interface CreateLocalWallet { * Create a local wallet with a random private key * Does not store the wallet in the database */ -export const generateLocalWallet = async () => { +export const generateLocalWallet = async (encryptionPassword: string) => { const pk = generatePrivateKey(); const account = privateKeyToAccount({ client: thirdwebClient, @@ -25,13 +25,13 @@ export const generateLocalWallet = async () => { address: account.address, privateKey: pk, }, - env.ENCRYPTION_PASSWORD, + encryptionPassword, ); return { account, - // these exact values are stored for backwards compatibility - // only the encryptedJson is used for loading the wallet + // Only the encryptedJson `data` is used for loading the wallet. + // The other fields are for backward compatibility. encryptedJson: JSON.stringify({ data: encryptedJsonData, address: account.address, @@ -47,7 +47,9 @@ export const generateLocalWallet = async () => { export const createLocalWalletDetails = async ({ label, }: CreateLocalWallet): Promise => { - const { account, encryptedJson } = await generateLocalWallet(); + const { account, encryptedJson } = await generateLocalWallet( + env.ENCRYPTION_PASSWORD, + ); await createWalletDetails({ type: "local", diff --git a/src/server/utils/wallets/create-smart-wallet.ts b/src/server/utils/wallets/create-smart-wallet.ts index 555f6e70f..3270eaea7 100644 --- a/src/server/utils/wallets/create-smart-wallet.ts +++ b/src/server/utils/wallets/create-smart-wallet.ts @@ -15,6 +15,7 @@ import { import { generateLocalWallet } from "./create-local-wallet"; import { getAwsKmsAccount } from "./get-aws-kms-account"; import { getGcpKmsAccount } from "./get-gcp-kms-account"; +import { env } from "../../../shared/utils/env"; /** * Get a smart wallet address for a given admin account @@ -47,7 +48,7 @@ export const getConnectedSmartWallet = async ({ }); }; -export type CreateSmartAwsWalletParams = CreateAwsKmsWalletParams & { +type CreateSmartAwsWalletParams = CreateAwsKmsWalletParams & { accountFactoryAddress?: Address; entrypointAddress?: Address; }; @@ -95,7 +96,7 @@ export const createSmartAwsWalletDetails = async ({ }); }; -export type CreateSmartGcpWalletParams = CreateGcpKmsWalletParams & { +type CreateSmartGcpWalletParams = CreateGcpKmsWalletParams & { accountFactoryAddress?: Address; entrypointAddress?: Address; }; @@ -140,18 +141,21 @@ export const createSmartGcpWalletDetails = async ({ }); }; -export type CreateSmartLocalWalletParams = { +type CreateSmartLocalWalletParams = { label?: string; accountFactoryAddress?: Address; entrypointAddress?: Address; + encryptionPassword?: string; }; export const createSmartLocalWalletDetails = async ({ label, accountFactoryAddress, entrypointAddress, + encryptionPassword = env.ENCRYPTION_PASSWORD, }: CreateSmartLocalWalletParams) => { - const { account, encryptedJson } = await generateLocalWallet(); + const { account, encryptedJson } = + await generateLocalWallet(encryptionPassword); const wallet = await getConnectedSmartWallet({ adminAccount: account, diff --git a/src/shared/db/wallets/create-backend-wallet-lite-access.ts b/src/shared/db/wallets/create-backend-wallet-lite-access.ts new file mode 100644 index 000000000..cbbb7410d --- /dev/null +++ b/src/shared/db/wallets/create-backend-wallet-lite-access.ts @@ -0,0 +1,17 @@ +import type { Address } from "thirdweb"; +import { prisma } from "../client"; + +export async function createBackendWalletLiteAccess(args: { + teamId: string; + dashboardUserAddress: Address; + salt: string; +}) { + const { teamId, dashboardUserAddress, salt } = args; + return prisma.backendWalletLiteAccess.create({ + data: { + teamId, + dashboardUserAddress, + salt, + }, + }); +} diff --git a/src/shared/db/wallets/get-backend-wallet-lite-access.ts b/src/shared/db/wallets/get-backend-wallet-lite-access.ts new file mode 100644 index 000000000..db2d0c472 --- /dev/null +++ b/src/shared/db/wallets/get-backend-wallet-lite-access.ts @@ -0,0 +1,11 @@ +import { prisma } from "../client"; + +export async function getBackendWalletLiteAccess(args: { + teamId: string; +}) { + const { teamId } = args; + return prisma.backendWalletLiteAccess.findFirst({ + where: { teamId }, + include: { account: true }, + }); +} diff --git a/src/shared/db/wallets/update-backend-wallet-lite-access.ts b/src/shared/db/wallets/update-backend-wallet-lite-access.ts new file mode 100644 index 000000000..cdc5c20f9 --- /dev/null +++ b/src/shared/db/wallets/update-backend-wallet-lite-access.ts @@ -0,0 +1,18 @@ +import { prisma } from "../client"; + +export async function updateBackendWalletLiteAccess(args: { + id: string; + accountAddress: string; + signerAddress: string; + encryptedJson: string; +}) { + const { id, accountAddress, signerAddress, encryptedJson } = args; + return prisma.backendWalletLiteAccess.update({ + where: { id }, + data: { + accountAddress, + signerAddress, + encryptedJson, + }, + }); +}