From 38ecff278cf3d3d533e445e71882e55ad66dd47b Mon Sep 17 00:00:00 2001 From: Yuki Abe <12268087+yukia3e@users.noreply.github.com> Date: Sat, 18 Jan 2025 16:02:52 +0900 Subject: [PATCH 01/10] fix: Separate properties and attributes in NFTInput (#829) --- src/server/routes/contract/extensions/erc1155/write/mint-to.ts | 3 ++- src/server/routes/contract/extensions/erc721/write/mint-to.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server/routes/contract/extensions/erc1155/write/mint-to.ts b/src/server/routes/contract/extensions/erc1155/write/mint-to.ts index 63ca44a7..6508c87f 100644 --- a/src/server/routes/contract/extensions/erc1155/write/mint-to.ts +++ b/src/server/routes/contract/extensions/erc1155/write/mint-to.ts @@ -106,7 +106,8 @@ export async function erc1155mintTo(fastify: FastifyInstance) { animation_url: metadata.animation_url ?? undefined, external_url: metadata.external_url ?? undefined, background_color: metadata.background_color ?? undefined, - properties: metadata.properties || metadata.attributes, + properties: metadata.properties ?? undefined, + attributes: metadata.attributes ?? undefined, }; const transaction = mintTo({ contract, diff --git a/src/server/routes/contract/extensions/erc721/write/mint-to.ts b/src/server/routes/contract/extensions/erc721/write/mint-to.ts index 7e7d0c14..0385d0bc 100644 --- a/src/server/routes/contract/extensions/erc721/write/mint-to.ts +++ b/src/server/routes/contract/extensions/erc721/write/mint-to.ts @@ -101,7 +101,8 @@ export async function erc721mintTo(fastify: FastifyInstance) { animation_url: metadata.animation_url ?? undefined, external_url: metadata.external_url ?? undefined, background_color: metadata.background_color ?? undefined, - properties: metadata.properties || metadata.attributes, + properties: metadata.properties ?? undefined, + attributes: metadata.attributes ?? undefined, }; const transaction = mintTo({ contract, From b468f5dcbbb85b44bd9b6535a7db2486aa518e22 Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Sat, 25 Jan 2025 11:45:12 +0800 Subject: [PATCH 02/10] fix: /reset-nonces endpoint allows deleting nonce state without resyncing (#836) * chore: Allow deleting stored nonces without resyncing yet * rename * also delete nonce-history --- .../routes/backend-wallet/reset-nonces.ts | 31 +++++++++++++------ src/shared/db/wallets/wallet-nonce.ts | 4 ++- src/worker/tasks/nonce-health-check-worker.ts | 9 ++++-- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/server/routes/backend-wallet/reset-nonces.ts b/src/server/routes/backend-wallet/reset-nonces.ts index f94be5b6..74f2d237 100644 --- a/src/server/routes/backend-wallet/reset-nonces.ts +++ b/src/server/routes/backend-wallet/reset-nonces.ts @@ -21,6 +21,11 @@ const requestBodySchema = Type.Object({ description: "The backend wallet address to reset nonces for. Omit to reset all backend wallets.", }), + syncOnchainNonces: Type.Boolean({ + description: + "Resync nonces to match the onchain transaction count for your backend wallets. (Default: true)", + default: true, + }), }); const responseSchema = Type.Object({ @@ -61,7 +66,11 @@ export const resetBackendWalletNoncesRoute = async ( }, }, handler: async (req, reply) => { - const { chainId, walletAddress: _walletAddress } = req.body; + const { + chainId, + walletAddress: _walletAddress, + syncOnchainNonces, + } = req.body; // If chain+wallet are provided, only process that wallet. // Otherwise process all used wallets that has nonce state. @@ -70,19 +79,21 @@ export const resetBackendWalletNoncesRoute = async ( ? [{ chainId, walletAddress: getAddress(_walletAddress) }] : await getUsedBackendWallets(); - const RESYNC_BATCH_SIZE = 50; - for (let i = 0; i < backendWallets.length; i += RESYNC_BATCH_SIZE) { - const batch = backendWallets.slice(i, i + RESYNC_BATCH_SIZE); + const BATCH_SIZE = 50; + for (let i = 0; i < backendWallets.length; i += BATCH_SIZE) { + const batch = backendWallets.slice(i, i + BATCH_SIZE); // Delete nonce state for these backend wallets. await deleteNoncesForBackendWallets(backendWallets); - // Resync nonces for these backend wallets. - await Promise.allSettled( - batch.map(({ chainId, walletAddress }) => - syncLatestNonceFromOnchain(chainId, walletAddress), - ), - ); + if (syncOnchainNonces) { + // Resync nonces for these backend wallets. + await Promise.allSettled( + batch.map(({ chainId, walletAddress }) => + syncLatestNonceFromOnchain(chainId, walletAddress), + ), + ); + } } reply.status(StatusCodes.OK).send({ diff --git a/src/shared/db/wallets/wallet-nonce.ts b/src/shared/db/wallets/wallet-nonce.ts index c730533c..c7c0ea14 100644 --- a/src/shared/db/wallets/wallet-nonce.ts +++ b/src/shared/db/wallets/wallet-nonce.ts @@ -10,6 +10,7 @@ import { normalizeAddress } from "../../utils/primitive-types"; import { redis } from "../../utils/redis/redis"; import { thirdwebClient } from "../../utils/sdk"; import { updateNonceMap } from "./nonce-map"; +import { nonceHistoryKey } from "../../../worker/tasks/nonce-health-check-worker"; /** * Get all used backend wallets. @@ -45,7 +46,7 @@ export const getUsedBackendWallets = async ( /** * The "last used nonce" stores the last nonce submitted onchain. - * Example: "25" + * Example: 25 -> nonce 25 is onchain, nonce 26 is unused or inflight. */ export const lastUsedNonceKey = (chainId: number, walletAddress: Address) => `nonce:${chainId}:${normalizeAddress(walletAddress)}`; @@ -260,6 +261,7 @@ export async function deleteNoncesForBackendWallets( lastUsedNonceKey(chainId, walletAddress), recycledNoncesKey(chainId, walletAddress), sentNoncesKey(chainId, walletAddress), + nonceHistoryKey(chainId, walletAddress), ]); await redis.del(keys); } diff --git a/src/worker/tasks/nonce-health-check-worker.ts b/src/worker/tasks/nonce-health-check-worker.ts index 839fb794..b75adf6e 100644 --- a/src/worker/tasks/nonce-health-check-worker.ts +++ b/src/worker/tasks/nonce-health-check-worker.ts @@ -116,7 +116,10 @@ async function getCurrentNonceState( }; } -function nonceHistoryKey(walletAddress: Address, chainId: number) { +/** + * Stores a list of onchain vs sent nonces to check if the nonce is stuck over time. + */ +export function nonceHistoryKey(chainId: number, walletAddress: Address) { return `nonce-history:${chainId}:${getAddress(walletAddress)}`; } @@ -128,7 +131,7 @@ async function getHistoricalNonceStates( chainId: number, periods: number, ): Promise { - const key = nonceHistoryKey(walletAddress, chainId); + const key = nonceHistoryKey(chainId, walletAddress); const historicalStates = await redis.lrange(key, 0, periods - 1); return historicalStates.map((state) => JSON.parse(state)); } @@ -136,7 +139,7 @@ async function getHistoricalNonceStates( // Update nonce history async function updateNonceHistory(walletAddress: Address, chainId: number) { const currentState = await getCurrentNonceState(walletAddress, chainId); - const key = nonceHistoryKey(walletAddress, chainId); + const key = nonceHistoryKey(chainId, walletAddress); await redis .multi() From be8b6448f9fa3fd58c4984f36c37ff415934fd3a Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Sat, 25 Jan 2025 11:47:24 +0800 Subject: [PATCH 03/10] feat: support mTLS certificate upload (#835) * feat: Add mTLS and custom HMAC support to webhoks * remove resetNonce * use env vars for client id/secret * update tests * debug steps * remove async * remove unneeded changes --- .env.test | 14 +-- package.json | 1 + .../migration.sql | 3 + src/prisma/schema.prisma | 3 + src/server/middleware/auth.ts | 10 +- src/server/routes/configuration/auth/get.ts | 10 +- .../routes/configuration/auth/update.ts | 55 ++++++++- src/server/routes/webhooks/create.ts | 2 +- .../db/configuration/get-configuration.ts | 6 + .../db/configuration/update-configuration.ts | 2 +- src/shared/db/webhooks/create-webhook.ts | 2 +- src/shared/schemas/config.ts | 4 + src/shared/utils/crypto.ts | 14 +-- src/shared/utils/custom-auth-header.ts | 63 +++++++++++ src/shared/utils/env.ts | 23 ++-- src/shared/utils/webhook.ts | 106 +++++++++++++----- tests/unit/webhook.test.ts | 57 ++++++++++ yarn.lock | 21 ++-- 18 files changed, 310 insertions(+), 86 deletions(-) create mode 100644 src/prisma/migrations/20241031010103_add_mtls_configuration/migration.sql create mode 100644 src/shared/utils/custom-auth-header.ts create mode 100644 tests/unit/webhook.test.ts diff --git a/.env.test b/.env.test index d2acedfb..5ab9791d 100644 --- a/.env.test +++ b/.env.test @@ -7,11 +7,11 @@ ENABLE_HTTPS="true" REDIS_URL="redis://127.0.0.1:6379/0" THIRDWEB_API_SECRET_KEY="my-thirdweb-secret-key" -TEST_AWS_KMS_KEY_ID="" -TEST_AWS_KMS_ACCESS_KEY_ID="" -TEST_AWS_KMS_SECRET_ACCESS_KEY="" -TEST_AWS_KMS_REGION="" +TEST_AWS_KMS_KEY_ID="UNIMPLEMENTED" +TEST_AWS_KMS_ACCESS_KEY_ID="UNIMPLEMENTED" +TEST_AWS_KMS_SECRET_ACCESS_KEY="UNIMPLEMENTED" +TEST_AWS_KMS_REGION="UNIMPLEMENTED" -TEST_GCP_KMS_RESOURCE_PATH="" -TEST_GCP_KMS_EMAIL="" -TEST_GCP_KMS_PK="" \ No newline at end of file +TEST_GCP_KMS_RESOURCE_PATH="UNIMPLEMENTED" +TEST_GCP_KMS_EMAIL="UNIMPLEMENTED" +TEST_GCP_KMS_PK="UNIMPLEMENTED" \ No newline at end of file diff --git a/package.json b/package.json index 69359687..5be6c5aa 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "prom-client": "^15.1.3", "superjson": "^2.2.1", "thirdweb": "^5.83.0", + "undici": "^6.20.1", "uuid": "^9.0.1", "viem": "^2.21.54", "winston": "^3.14.1", diff --git a/src/prisma/migrations/20241031010103_add_mtls_configuration/migration.sql b/src/prisma/migrations/20241031010103_add_mtls_configuration/migration.sql new file mode 100644 index 00000000..9e7a54aa --- /dev/null +++ b/src/prisma/migrations/20241031010103_add_mtls_configuration/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "configuration" ADD COLUMN "mtlsCertificateEncrypted" TEXT, +ADD COLUMN "mtlsPrivateKeyEncrypted" TEXT; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 2e04b2d8..44608303 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -50,6 +50,9 @@ model Configuration { accessControlAllowOrigin String @default("https://thirdweb.com,https://embed.ipfscdn.io") @map("accessControlAllowOrigin") ipAllowlist String[] @default([]) @map("ipAllowlist") clearCacheCronSchedule String @default("*/30 * * * * *") @map("clearCacheCronSchedule") + // mTLS support + mtlsCertificateEncrypted String? + mtlsPrivateKeyEncrypted String? @@map("configuration") } diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index f29804e7..fb21508d 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -5,10 +5,10 @@ import { type ThirdwebAuthUser, } from "@thirdweb-dev/auth/fastify"; import { AsyncWallet } from "@thirdweb-dev/wallets/evm/wallets/async"; -import { createHash } from "node:crypto"; import type { FastifyInstance } from "fastify"; import type { FastifyRequest } from "fastify/types/request"; import jsonwebtoken, { type JwtPayload } from "jsonwebtoken"; +import { createHash } from "node:crypto"; import { validate as uuidValidate } from "uuid"; import { getPermissions } from "../../shared/db/permissions/get-permissions"; import { createToken } from "../../shared/db/tokens/create-token"; @@ -123,7 +123,8 @@ export async function withAuth(server: FastifyInstance) { } // Allow this request to proceed. return; - }if (error) { + } + if (error) { message = error; } } catch (err: unknown) { @@ -172,10 +173,11 @@ export const onRequest = async ({ const authWallet = await getAuthWallet(); if (publicKey === (await authWallet.getAddress())) { return await handleAccessToken(jwt, req, getUser); - }if (publicKey === THIRDWEB_DASHBOARD_ISSUER) { + } + if (publicKey === THIRDWEB_DASHBOARD_ISSUER) { return await handleDashboardAuth(jwt); } - return await handleKeypairAuth({ jwt, req, publicKey }); + return await handleKeypairAuth({ jwt, req, publicKey }); } // Get the public key hash from the `kid` header. diff --git a/src/server/routes/configuration/auth/get.ts b/src/server/routes/configuration/auth/get.ts index 0b97d971..115964db 100644 --- a/src/server/routes/configuration/auth/get.ts +++ b/src/server/routes/configuration/auth/get.ts @@ -6,7 +6,9 @@ import { standardResponseSchema } from "../../../schemas/shared-api-schemas"; export const responseBodySchema = Type.Object({ result: Type.Object({ - domain: Type.String(), + authDomain: Type.String(), + mtlsCertificate: Type.Union([Type.String(), Type.Null()]), + // Do not return mtlsPrivateKey. }), }); @@ -27,10 +29,12 @@ export async function getAuthConfiguration(fastify: FastifyInstance) { }, }, handler: async (_req, res) => { - const config = await getConfig(); + const { authDomain, mtlsCertificate } = await getConfig(); + res.status(StatusCodes.OK).send({ result: { - domain: config.authDomain, + authDomain, + mtlsCertificate, }, }); }, diff --git a/src/server/routes/configuration/auth/update.ts b/src/server/routes/configuration/auth/update.ts index 50d88a49..3a124367 100644 --- a/src/server/routes/configuration/auth/update.ts +++ b/src/server/routes/configuration/auth/update.ts @@ -5,10 +5,21 @@ import { updateConfiguration } from "../../../../shared/db/configuration/update- import { getConfig } from "../../../../shared/utils/cache/get-config"; import { standardResponseSchema } from "../../../schemas/shared-api-schemas"; import { responseBodySchema } from "./get"; +import { createCustomError } from "../../../middleware/error"; +import { encrypt } from "../../../../shared/utils/crypto"; -export const requestBodySchema = Type.Object({ - domain: Type.String(), -}); +export const requestBodySchema = Type.Partial( + Type.Object({ + authDomain: Type.String(), + mtlsCertificate: Type.String({ + description: + "Engine certificate used for outbound mTLS requests. Must provide the full certificate chain.", + }), + mtlsPrivateKey: Type.String({ + description: "Engine private key used for outbound mTLS requests.", + }), + }), +); export async function updateAuthConfiguration(fastify: FastifyInstance) { fastify.route<{ @@ -29,15 +40,49 @@ export async function updateAuthConfiguration(fastify: FastifyInstance) { }, }, handler: async (req, res) => { + const { authDomain, mtlsCertificate, mtlsPrivateKey } = req.body; + + if (mtlsCertificate) { + if ( + !mtlsCertificate.includes("-----BEGIN CERTIFICATE-----\n") || + !mtlsCertificate.includes("\n-----END CERTIFICATE-----") + ) { + throw createCustomError( + "Invalid mtlsCertificate.", + StatusCodes.BAD_REQUEST, + "INVALID_MTLS_CERTIFICATE", + ); + } + } + if (mtlsPrivateKey) { + if ( + !mtlsPrivateKey.startsWith("-----BEGIN PRIVATE KEY-----\n") || + !mtlsPrivateKey.endsWith("\n-----END PRIVATE KEY-----") + ) { + throw createCustomError( + "Invalid mtlsPrivateKey.", + StatusCodes.BAD_REQUEST, + "INVALID_MTLS_PRIVATE_KEY", + ); + } + } + await updateConfiguration({ - authDomain: req.body.domain, + authDomain, + mtlsCertificateEncrypted: mtlsCertificate + ? encrypt(mtlsCertificate) + : undefined, + mtlsPrivateKeyEncrypted: mtlsPrivateKey + ? encrypt(mtlsPrivateKey) + : undefined, }); const config = await getConfig(false); res.status(StatusCodes.OK).send({ result: { - domain: config.authDomain, + authDomain: config.authDomain, + mtlsCertificate: config.mtlsCertificate, }, }); }, diff --git a/src/server/routes/webhooks/create.ts b/src/server/routes/webhooks/create.ts index 3d18768f..a45e66a0 100644 --- a/src/server/routes/webhooks/create.ts +++ b/src/server/routes/webhooks/create.ts @@ -45,7 +45,7 @@ export async function createWebhookRoute(fastify: FastifyInstance) { method: "POST", url: "/webhooks/create", schema: { - summary: "Create a webhook", + summary: "Create webhook", description: "Create a webhook to call when a specific Engine event occurs.", tags: ["Webhooks"], diff --git a/src/shared/db/configuration/get-configuration.ts b/src/shared/db/configuration/get-configuration.ts index ee46e3a3..71776b87 100644 --- a/src/shared/db/configuration/get-configuration.ts +++ b/src/shared/db/configuration/get-configuration.ts @@ -172,6 +172,12 @@ const toParsedConfig = async (config: Configuration): Promise => { gcp: gcpWalletConfiguration, legacyWalletType_removeInNextBreakingChange, }, + mtlsCertificate: config.mtlsCertificateEncrypted + ? decrypt(config.mtlsCertificateEncrypted, env.ENCRYPTION_PASSWORD) + : null, + mtlsPrivateKey: config.mtlsPrivateKeyEncrypted + ? decrypt(config.mtlsPrivateKeyEncrypted, env.ENCRYPTION_PASSWORD) + : null, }; }; diff --git a/src/shared/db/configuration/update-configuration.ts b/src/shared/db/configuration/update-configuration.ts index f6d14fd4..4e153cde 100644 --- a/src/shared/db/configuration/update-configuration.ts +++ b/src/shared/db/configuration/update-configuration.ts @@ -3,7 +3,7 @@ import { encrypt } from "../../utils/crypto"; import { prisma } from "../client"; export const updateConfiguration = async ( - data: Prisma.ConfigurationUpdateArgs["data"], + data: Prisma.ConfigurationUpdateInput, ) => { return prisma.configuration.update({ where: { diff --git a/src/shared/db/webhooks/create-webhook.ts b/src/shared/db/webhooks/create-webhook.ts index 0b3ebb15..ee07d770 100644 --- a/src/shared/db/webhooks/create-webhook.ts +++ b/src/shared/db/webhooks/create-webhook.ts @@ -1,5 +1,5 @@ import type { Webhooks } from "@prisma/client"; -import { createHash, randomBytes } from "crypto"; +import { createHash, randomBytes } from "node:crypto"; import type { WebhooksEventTypes } from "../../schemas/webhooks"; import { prisma } from "../client"; diff --git a/src/shared/schemas/config.ts b/src/shared/schemas/config.ts index fc3ce2d6..6205c381 100644 --- a/src/shared/schemas/config.ts +++ b/src/shared/schemas/config.ts @@ -33,6 +33,8 @@ export interface ParsedConfig | "gcpApplicationCredentialEmail" | "gcpApplicationCredentialPrivateKey" | "contractSubscriptionsRetryDelaySeconds" + | "mtlsCertificateEncrypted" + | "mtlsPrivateKeyEncrypted" > { walletConfiguration: { aws: AwsWalletConfiguration | null; @@ -41,4 +43,6 @@ export interface ParsedConfig }; contractSubscriptionsRequeryDelaySeconds: string; chainOverridesParsed: Chain[]; + mtlsCertificate: string | null; + mtlsPrivateKey: string | null; } diff --git a/src/shared/utils/crypto.ts b/src/shared/utils/crypto.ts index 4086d4a9..a6e8c4e8 100644 --- a/src/shared/utils/crypto.ts +++ b/src/shared/utils/crypto.ts @@ -1,20 +1,20 @@ -import crypto from "crypto"; import CryptoJS from "crypto-js"; +import crypto from "node:crypto"; import { env } from "./env"; -export const encrypt = (data: string): string => { +export function encrypt(data: string): string { return CryptoJS.AES.encrypt(data, env.ENCRYPTION_PASSWORD).toString(); -}; +} -export const decrypt = (data: string, password: string) => { +export function decrypt(data: string, password: string) { return CryptoJS.AES.decrypt(data, password).toString(CryptoJS.enc.Utf8); -}; +} -export const isWellFormedPublicKey = (key: string) => { +export function isWellFormedPublicKey(key: string) { try { crypto.createPublicKey(key); return true; } catch (_e) { return false; } -}; +} diff --git a/src/shared/utils/custom-auth-header.ts b/src/shared/utils/custom-auth-header.ts new file mode 100644 index 00000000..ac5fd54f --- /dev/null +++ b/src/shared/utils/custom-auth-header.ts @@ -0,0 +1,63 @@ +import { createHmac } from "node:crypto"; + +/** + * Generates an HMAC-256 secret to set in the "Authorization" header. + * + * @param webhookUrl - The URL to call. + * @param body - The request body. + * @param timestamp - The request timestamp. + * @param nonce - A unique string for this request. Should not be re-used. + * @param clientId - Your application's client id. + * @param clientSecret - Your application's client secret. + * @returns + */ +export const generateSecretHmac256 = (args: { + webhookUrl: string; + body: Record; + timestamp: Date; + nonce: string; + clientId: string; + clientSecret: string; +}): string => { + const { webhookUrl, body, timestamp, nonce, clientId, clientSecret } = args; + + // Create the body hash by hashing the payload. + const bodyHash = createHmac("sha256", clientSecret) + .update(JSON.stringify(body), "utf8") + .digest("base64"); + + // Create the signature hash by hashing the signature. + const ts = timestamp.getTime(); // timestamp expected in milliseconds + const httpMethod = "POST"; + const url = new URL(webhookUrl); + const resourcePath = url.pathname; + const host = url.hostname; + const port = url.port + ? Number.parseInt(url.port) + : url.protocol === "https:" + ? 443 + : 80; + + const signature = [ + ts, + nonce, + httpMethod, + resourcePath, + host, + port, + bodyHash, + "", // to insert a newline at the end + ].join("\n"); + + const signatureHash = createHmac("sha256", clientSecret) + .update(signature, "utf8") + .digest("base64"); + + return [ + `MAC id="${clientId}"`, + `ts="${ts}"`, + `nonce="${nonce}"`, + `bodyhash="${bodyHash}"`, + `mac="${signatureHash}"`, + ].join(","); +}; diff --git a/src/shared/utils/env.ts b/src/shared/utils/env.ts index 3fa84dcc..c048630d 100644 --- a/src/shared/utils/env.ts +++ b/src/shared/utils/env.ts @@ -6,18 +6,6 @@ import { z } from "zod"; const path = process.env.NODE_ENV === "test" ? ".env.test" : ".env"; dotenv.config({ path }); -export const JsonSchema = z.string().refine( - (value) => { - try { - JSON.parse(value); - return true; - } catch { - return false; - } - }, - { message: "Invalid JSON string" }, -); - const boolEnvSchema = (defaultBool: boolean) => z .string() @@ -68,7 +56,6 @@ export const env = createEnv({ .default("https://c.thirdweb.com/event"), SDK_BATCH_TIME_LIMIT: z.coerce.number().default(0), SDK_BATCH_SIZE_LIMIT: z.coerce.number().default(100), - ENABLE_KEYPAIR_AUTH: boolEnvSchema(false), REDIS_URL: z.string(), SEND_TRANSACTION_QUEUE_CONCURRENCY: z.coerce.number().default(200), CONFIRM_TRANSACTION_QUEUE_CONCURRENCY: z.coerce.number().default(200), @@ -99,6 +86,11 @@ export const env = createEnv({ // Sets the number of recent nonces to map to queue IDs. NONCE_MAP_COUNT: z.coerce.number().default(10_000), + ENABLE_KEYPAIR_AUTH: boolEnvSchema(false), + ENABLE_CUSTOM_HMAC_AUTH: boolEnvSchema(false), + CUSTOM_HMAC_AUTH_CLIENT_ID: z.string().optional(), + CUSTOM_HMAC_AUTH_CLIENT_SECRET: z.string().optional(), + /** * Experimental env vars. These may be renamed or removed in future non-major releases. */ @@ -130,7 +122,6 @@ export const env = createEnv({ CLIENT_ANALYTICS_URL: process.env.CLIENT_ANALYTICS_URL, SDK_BATCH_TIME_LIMIT: process.env.SDK_BATCH_TIME_LIMIT, SDK_BATCH_SIZE_LIMIT: process.env.SDK_BATCH_SIZE_LIMIT, - ENABLE_KEYPAIR_AUTH: process.env.ENABLE_KEYPAIR_AUTH, REDIS_URL: process.env.REDIS_URL, SEND_TRANSACTION_QUEUE_CONCURRENCY: process.env.SEND_TRANSACTION_QUEUE_CONCURRENCY, @@ -150,6 +141,10 @@ export const env = createEnv({ process.env.EXPERIMENTAL__MAX_GAS_PRICE_WEI, METRICS_PORT: process.env.METRICS_PORT, METRICS_ENABLED: process.env.METRICS_ENABLED, + ENABLE_KEYPAIR_AUTH: process.env.ENABLE_KEYPAIR_AUTH, + ENABLE_CUSTOM_HMAC_AUTH: process.env.ENABLE_CUSTOM_HMAC_AUTH, + CUSTOM_HMAC_AUTH_CLIENT_ID: process.env.CUSTOM_HMAC_AUTH_CLIENT_ID, + CUSTOM_HMAC_AUTH_CLIENT_SECRET: process.env.CUSTOM_HMAC_AUTH_CLIENT_SECRET, }, onValidationError: (error: ZodError) => { console.error( diff --git a/src/shared/utils/webhook.ts b/src/shared/utils/webhook.ts index c31b3e1a..1997502e 100644 --- a/src/shared/utils/webhook.ts +++ b/src/shared/utils/webhook.ts @@ -1,43 +1,70 @@ import type { Webhooks } from "@prisma/client"; -import crypto from "node:crypto"; +import assert from "node:assert"; +import crypto, { randomUUID } from "node:crypto"; +import { Agent, fetch } from "undici"; +import { getConfig } from "./cache/get-config"; +import { env } from "./env"; import { prettifyError } from "./error"; +import { generateSecretHmac256 } from "./custom-auth-header"; -export const generateSignature = ( +function generateSignature( body: Record, - timestamp: string, + timestampSeconds: number, secret: string, -): string => { +): string { const _body = JSON.stringify(body); - const payload = `${timestamp}.${_body}`; + const payload = `${timestampSeconds}.${_body}`; return crypto.createHmac("sha256", secret).update(payload).digest("hex"); -}; +} -export const createWebhookRequestHeaders = async ( - webhook: Webhooks, - body: Record, -): Promise => { - const headers: { - Accept: string; - "Content-Type": string; - Authorization?: string; - "x-engine-signature"?: string; - "x-engine-timestamp"?: string; - } = { - Accept: "application/json", - "Content-Type": "application/json", - }; +function generateAuthorization(args: { + webhook: Webhooks; + timestamp: Date; + body: Record; +}): string { + const { webhook, timestamp, body } = args; - if (webhook.secret) { - const timestamp = Math.floor(Date.now() / 1000).toString(); - const signature = generateSignature(body, timestamp, webhook.secret); + if (env.ENABLE_CUSTOM_HMAC_AUTH) { + assert( + env.CUSTOM_HMAC_AUTH_CLIENT_ID, + 'Missing "CUSTOM_HMAC_AUTH_CLIENT_ID".', + ); + assert( + env.CUSTOM_HMAC_AUTH_CLIENT_SECRET, + 'Missing "CUSTOM_HMAC_AUTH_CLIENT_SECRET"', + ); - headers.Authorization = `Bearer ${webhook.secret}`; - headers["x-engine-signature"] = signature; - headers["x-engine-timestamp"] = timestamp; + return generateSecretHmac256({ + webhookUrl: webhook.url, + body, + timestamp, + nonce: randomUUID(), + clientId: env.CUSTOM_HMAC_AUTH_CLIENT_ID, + clientSecret: env.CUSTOM_HMAC_AUTH_CLIENT_SECRET, + }); } - return headers; -}; + return `Bearer ${webhook.secret}`; +} + +export function generateRequestHeaders(args: { + webhook: Webhooks; + body: Record; + timestamp: Date; +}): HeadersInit { + const { webhook, body, timestamp } = args; + + const timestampSeconds = Math.floor(timestamp.getTime() / 1000); + const signature = generateSignature(body, timestampSeconds, webhook.secret); + const authorization = generateAuthorization({ webhook, timestamp, body }); + return { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: authorization, + "x-engine-signature": signature, + "x-engine-timestamp": timestampSeconds.toString(), + }; +} export interface WebhookResponse { ok: boolean; @@ -50,11 +77,32 @@ export const sendWebhookRequest = async ( body: Record, ): Promise => { try { - const headers = await createWebhookRequestHeaders(webhook, body); + const config = await getConfig(); + + // If mTLS is enabled, provide the certificate with this request. + const dispatcher = + config.mtlsCertificate && config.mtlsPrivateKey + ? new Agent({ + connect: { + cert: config.mtlsCertificate, + key: config.mtlsPrivateKey, + // Validate the server's certificate. + rejectUnauthorized: true, + }, + }) + : undefined; + + const headers = generateRequestHeaders({ + webhook, + body, + timestamp: new Date(), + }); + const resp = await fetch(webhook.url, { method: "POST", headers: headers, body: JSON.stringify(body), + dispatcher, }); return { diff --git a/tests/unit/webhook.test.ts b/tests/unit/webhook.test.ts new file mode 100644 index 00000000..adc0d5dc --- /dev/null +++ b/tests/unit/webhook.test.ts @@ -0,0 +1,57 @@ +import type { Webhooks } from "@prisma/client"; +import { describe, expect, it } from "vitest"; +import { WebhooksEventTypes } from "../../src/shared/schemas/webhooks"; +import { generateRequestHeaders } from "../../src/shared/utils/webhook"; +import { generateSecretHmac256 } from "../../src/shared/utils/customAuthHeader"; + +describe("generateSecretHmac256", () => { + it("should generate a valid MAC header with correct structure and values", () => { + const timestamp = new Date("2024-01-01"); + const nonce = "6b98df0d-5f33-4121-96cb-77a0b9df2bbe"; + + const result = generateSecretHmac256({ + webhookUrl: "https://example.com/webhook", + body: { bodyArgName: "bodyArgValue" }, + timestamp, + nonce, + clientId: "testClientId", + clientSecret: "testClientSecret", + }); + + expect(result).toEqual( + `MAC id="testClientId" ts="1704067200000" nonce="6b98df0d-5f33-4121-96cb-77a0b9df2bbe" bodyhash="4Mknknli8NGCwC28djVf/Qa8vN3wtvfeRGKVha0MgjQ=" mac="Qbe9H5yeVvywoL3l1RFLBDC0YvDOCQnytNSlbTWXzEk="`, + ); + }); +}); + +describe("generateRequestHeaders", () => { + const webhook: Webhooks = { + id: 42, + name: "test webhook", + url: "https://www.example.com/webhook", + secret: "test-secret-string", + eventType: WebhooksEventTypes.SENT_TX, + createdAt: new Date(), + updatedAt: new Date(), + revokedAt: null, + }; + const body = { + name: "Alice", + age: 25, + occupation: ["Founder", "Developer"], + }; + const timestamp = new Date("2024-01-01"); + + it("Generate a consistent webhook header", () => { + const result = generateRequestHeaders({ webhook, body, timestamp }); + + expect(result).toEqual({ + Accept: "application/json", + Authorization: "Bearer test-secret-string", + "Content-Type": "application/json", + "x-engine-signature": + "ca272da65f1145b9cfadab6d55086ee458eccc03a2c5f7f5ea84094d95b219cc", + "x-engine-timestamp": "1704067200", + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 79a69d91..55f064b9 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.2", "@noble/curves@~1.4.0": version "1.4.2" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.2.tgz#40309198c76ed71bc6dbf7ba24e81ceb4d0d1fe9" integrity sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw== @@ -2386,13 +2386,6 @@ 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" @@ -2403,11 +2396,6 @@ 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" @@ -3274,7 +3262,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.7": +"@scure/base@~1.1.0", "@scure/base@~1.1.2", "@scure/base@~1.1.6": version "1.1.9" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.9.tgz#e5e142fbbfe251091f9c5f1dd4c834ac04c3dbd1" integrity sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg== @@ -10550,6 +10538,11 @@ undici-types@~6.20.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici@^6.20.1: + version "6.20.1" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.20.1.tgz#fbb87b1e2b69d963ff2d5410a40ffb4c9e81b621" + integrity sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA== + unenv@^1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/unenv/-/unenv-1.10.0.tgz#c3394a6c6e4cfe68d699f87af456fe3f0db39571" From 110c782bf102c352167ba59af630f21fb4e6b872 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Thu, 30 Jan 2025 01:36:00 +0530 Subject: [PATCH 04/10] remove check for AA support on chain (to support stealth chains) (#838) --- .../utils/transaction/insert-transaction.ts | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/src/shared/utils/transaction/insert-transaction.ts b/src/shared/utils/transaction/insert-transaction.ts index 25fd9996..5e9da7d8 100644 --- a/src/shared/utils/transaction/insert-transaction.ts +++ b/src/shared/utils/transaction/insert-transaction.ts @@ -82,19 +82,6 @@ export const insertTransaction = async ( ); } - 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, @@ -124,19 +111,6 @@ export const insertTransaction = async ( // 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, From a828191ac5cae8ac4ea25784be3fe60733474e83 Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Thu, 30 Jan 2025 17:51:52 -0800 Subject: [PATCH 05/10] fix: handle blockTags for get-all-events (#840) * fix: handle blockTags for get-all-events * fix lint --- .../routes/contract/events/get-all-events.ts | 28 ++++++++++++++----- src/server/schemas/contract/index.ts | 27 +++++++++++------- .../utils/transaction/insert-transaction.ts | 1 - 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/server/routes/contract/events/get-all-events.ts b/src/server/routes/contract/events/get-all-events.ts index f0df96e8..c108cf09 100644 --- a/src/server/routes/contract/events/get-all-events.ts +++ b/src/server/routes/contract/events/get-all-events.ts @@ -13,8 +13,12 @@ import { thirdwebClient } from "../../../../shared/utils/sdk"; import { getChain } from "../../../../shared/utils/chain"; import { getChainIdFromChain } from "../../../utils/chain"; import { getContract, getContractEvents } from "thirdweb"; -import { maybeBigInt } from "../../../../shared/utils/primitive-types"; -import { toContractEventV4Schema } from "../../../schemas/event"; +import { + type ContractEventV5, + toContractEventV4Schema, +} from "../../../schemas/event"; +import { createCustomError } from "../../../middleware/error"; +import { prettifyError } from "../../../../shared/utils/error"; const requestSchema = contractParamSchema; @@ -93,11 +97,21 @@ export async function getAllEvents(fastify: FastifyInstance) { chain: await getChain(chainId), }); - const eventsV5 = await getContractEvents({ - contract: contract, - fromBlock: maybeBigInt(fromBlock?.toString()), - toBlock: maybeBigInt(toBlock?.toString()), - }); + let eventsV5: ContractEventV5[]; + try { + eventsV5 = await getContractEvents({ + contract: contract, + fromBlock: + typeof fromBlock === "number" ? BigInt(fromBlock) : fromBlock, + toBlock: typeof toBlock === "number" ? BigInt(toBlock) : toBlock, + }); + } catch (e) { + throw createCustomError( + `Failed to get events: ${prettifyError(e)}`, + StatusCodes.BAD_REQUEST, + "BAD_REQUEST", + ); + } reply.status(StatusCodes.OK).send({ result: eventsV5.map(toContractEventV4Schema).sort((a, b) => { diff --git a/src/server/schemas/contract/index.ts b/src/server/schemas/contract/index.ts index d6abd42e..dea20362 100644 --- a/src/server/schemas/contract/index.ts +++ b/src/server/schemas/contract/index.ts @@ -79,18 +79,25 @@ export const rolesResponseSchema = Type.Object({ signer: Type.Array(Type.String()), }); +export const blockNumberOrTagSchema = Type.Union([ + Type.Integer({ minimum: 0 }), + Type.Literal("latest"), + Type.Literal("earliest"), + Type.Literal("pending"), + Type.Literal("safe"), + Type.Literal("finalized"), +]); + export const eventsQuerystringSchema = Type.Object( { - fromBlock: Type.Optional( - Type.Union([Type.Integer({ minimum: 0 }), Type.String()], { - default: "0", - }), - ), - toBlock: Type.Optional( - Type.Union([Type.Integer({ minimum: 0 }), Type.String()], { - default: "latest", - }), - ), + fromBlock: Type.Optional({ + ...blockNumberOrTagSchema, + default: "0", + }), + toBlock: Type.Optional({ + ...blockNumberOrTagSchema, + default: "latest", + }), order: Type.Optional( Type.Union([Type.Literal("asc"), Type.Literal("desc")], { default: "desc", diff --git a/src/shared/utils/transaction/insert-transaction.ts b/src/shared/utils/transaction/insert-transaction.ts index 5e9da7d8..7b529b3c 100644 --- a/src/shared/utils/transaction/insert-transaction.ts +++ b/src/shared/utils/transaction/insert-transaction.ts @@ -7,7 +7,6 @@ import { WalletDetailsError, type ParsedWalletDetails, } from "../../../shared/db/wallets/get-wallet-details"; -import { doesChainSupportService } from "../../lib/chain/chain-capabilities"; import { createCustomError } from "../../../server/middleware/error"; import { SendTransactionQueue } from "../../../worker/queues/send-transaction-queue"; import { getChecksumAddress } from "../primitive-types"; From 7c79dba02fb055927f9e0f02316ded8e5024022c Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 7 Feb 2025 20:16:28 +0530 Subject: [PATCH 06/10] Circle Wallets (#841) * configuration capability for circle w3s API key * Wallet Credentials API * tested transactions * better error handling * Addressed review comments * clearer messaging for unsupported wallet * better messaging for v4 incompatible wallets * wallet credential isDefault: if not true then null --- package.json | 2 + .../migration.sql | 29 ++ .../migration.sql | 2 + src/prisma/schema.prisma | 47 ++- src/server/routes/backend-wallet/create.ts | 107 +++++- .../routes/configuration/wallets/update.ts | 13 + src/server/routes/index.ts | 8 + .../routes/wallet-credentials/create.ts | 104 ++++++ .../routes/wallet-credentials/get-all.ts | 70 ++++ src/server/routes/wallet-credentials/get.ts | 85 +++++ src/server/utils/wallets/circle/index.ts | 338 ++++++++++++++++++ .../db/configuration/get-configuration.ts | 39 ++ .../db/configuration/update-configuration.ts | 53 ++- .../create-wallet-credential.ts | 79 ++++ .../get-all-wallet-credentials.ts | 34 ++ .../get-wallet-credential.ts | 81 +++++ src/shared/db/wallets/get-wallet-details.ts | 38 +- src/shared/schemas/config.ts | 6 + src/shared/schemas/wallet.ts | 31 ++ src/shared/utils/account.ts | 55 +++ src/shared/utils/cache/get-contract.ts | 13 +- src/shared/utils/cache/get-wallet.ts | 2 +- yarn.lock | 22 +- 23 files changed, 1215 insertions(+), 43 deletions(-) create mode 100644 src/prisma/migrations/20250204201526_add_wallet_credentials/migration.sql create mode 100644 src/prisma/migrations/20250207135644_nullable_is_default/migration.sql create mode 100644 src/server/routes/wallet-credentials/create.ts create mode 100644 src/server/routes/wallet-credentials/get-all.ts create mode 100644 src/server/routes/wallet-credentials/get.ts create mode 100644 src/server/utils/wallets/circle/index.ts create mode 100644 src/shared/db/wallet-credentials/create-wallet-credential.ts create mode 100644 src/shared/db/wallet-credentials/get-all-wallet-credentials.ts create mode 100644 src/shared/db/wallet-credentials/get-wallet-credential.ts diff --git a/package.json b/package.json index 5be6c5aa..e8b1eb1a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dependencies": { "@aws-sdk/client-kms": "^3.679.0", "@bull-board/fastify": "^5.23.0", + "@circle-fin/developer-controlled-wallets": "^7.0.0", "@cloud-cryptographic-wallet/cloud-kms-signer": "^0.1.2", "@cloud-cryptographic-wallet/signer": "^0.0.5", "@ethersproject/json-wallets": "^5.7.0", @@ -63,6 +64,7 @@ "knex": "^3.1.0", "mnemonist": "^0.39.8", "node-cron": "^3.0.2", + "ox": "^0.6.9", "pg": "^8.11.3", "prisma": "^5.14.0", "prom-client": "^15.1.3", diff --git a/src/prisma/migrations/20250204201526_add_wallet_credentials/migration.sql b/src/prisma/migrations/20250204201526_add_wallet_credentials/migration.sql new file mode 100644 index 00000000..9776a0c9 --- /dev/null +++ b/src/prisma/migrations/20250204201526_add_wallet_credentials/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "configuration" ADD COLUMN "walletProviderConfigs" JSONB NOT NULL DEFAULT '{}'; + +-- AlterTable +ALTER TABLE "wallet_details" ADD COLUMN "credentialId" TEXT, +ADD COLUMN "platformIdentifiers" JSONB; + +-- CreateTable +CREATE TABLE "wallet_credentials" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "label" TEXT NOT NULL, + "data" JSONB NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "wallet_credentials_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "wallet_credentials_type_idx" ON "wallet_credentials"("type"); + +-- CreateIndex +CREATE UNIQUE INDEX "wallet_credentials_type_is_default_key" ON "wallet_credentials"("type", "isDefault"); + +-- AddForeignKey +ALTER TABLE "wallet_details" ADD CONSTRAINT "wallet_details_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "wallet_credentials"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/prisma/migrations/20250207135644_nullable_is_default/migration.sql b/src/prisma/migrations/20250207135644_nullable_is_default/migration.sql new file mode 100644 index 00000000..3712fa67 --- /dev/null +++ b/src/prisma/migrations/20250207135644_nullable_is_default/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "wallet_credentials" ALTER COLUMN "isDefault" DROP NOT NULL; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 44608303..1c4a473c 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -29,6 +29,11 @@ model Configuration { cursorDelaySeconds Int @default(2) @map("cursorDelaySeconds") contractSubscriptionsRetryDelaySeconds String @default("10") @map("contractSubscriptionsRetryDelaySeconds") + // Wallet provider specific configurations, non-credential + walletProviderConfigs Json @default("{}") @map("walletProviderConfigs") /// Eg: { "aws": { "defaultAwsRegion": "us-east-1" }, "gcp": { "defaultGcpKmsLocationId": "us-east1-b" } } + + // Legacy wallet provider credentials + // Use default credentials instead, and store non-credential wallet provider configuration in walletProviderConfig // AWS awsAccessKeyId String? @map("awsAccessKeyId") /// global config, precedence goes to WalletDetails awsSecretAccessKey String? @map("awsSecretAccessKey") /// global config, precedence goes to WalletDetails @@ -79,11 +84,19 @@ model Tokens { } model WalletDetails { - address String @id @map("address") - type String @map("type") - label String? @map("label") + address String @id @map("address") + type String @map("type") + label String? @map("label") + // Local - encryptedJson String? @map("encryptedJson") + encryptedJson String? @map("encryptedJson") + + // New approach: platform identifiers + wallet credentials + platformIdentifiers Json? @map("platformIdentifiers") /// Eg: { "awsKmsArn": "..." } or { "gcpKmsResourcePath": "..." } + credentialId String? @map("credentialId") + credential WalletCredentials? @relation(fields: [credentialId], references: [id]) + + // Legacy AWS KMS fields - use platformIdentifiers + WalletCredentials for new wallets // KMS awsKmsKeyId String? @map("awsKmsKeyId") /// deprecated and unused, todo: remove with next breaking change. Use awsKmsArn awsKmsArn String? @map("awsKmsArn") @@ -97,14 +110,34 @@ model WalletDetails { gcpKmsResourcePath String? @map("gcpKmsResourcePath") @db.Text gcpApplicationCredentialEmail String? @map("gcpApplicationCredentialEmail") /// if not available, default to: Configuration.gcpApplicationCredentialEmail gcpApplicationCredentialPrivateKey String? @map("gcpApplicationCredentialPrivateKey") /// if not available, default to: Configuration.gcpApplicationCredentialPrivateKey + // Smart Backend Wallet - accountSignerAddress String? @map("accountSignerAddress") /// this, and either local, aws or gcp encryptedJson, are required for smart wallet - 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 + accountSignerAddress String? @map("accountSignerAddress") /// this, and either local, aws or gcp encryptedJson, are required for smart wallet + 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 @@map("wallet_details") } +model WalletCredentials { + id String @id @default(uuid()) + type String + label String + data Json + isDefault Boolean? @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + wallets WalletDetails[] + + // A maximum of one default credential per type + @@unique([type, isDefault], name: "unique_default_per_type", map: "wallet_credentials_type_is_default_key") + @@index([type]) + @@map("wallet_credentials") +} + model WalletNonce { address String @map("address") chainId String @map("chainId") diff --git a/src/server/routes/backend-wallet/create.ts b/src/server/routes/backend-wallet/create.ts index 707616b4..107fde4a 100644 --- a/src/server/routes/backend-wallet/create.ts +++ b/src/server/routes/backend-wallet/create.ts @@ -6,7 +6,11 @@ import { DEFAULT_ACCOUNT_FACTORY_V0_7, ENTRYPOINT_ADDRESS_v0_7, } from "thirdweb/wallets/smart"; -import { WalletType } from "../../../shared/schemas/wallet"; +import { + LegacyWalletType, + WalletType, + CircleWalletType, +} from "../../../shared/schemas/wallet"; import { getConfig } from "../../../shared/utils/cache/get-config"; import { createCustomError } from "../../middleware/error"; import { AddressSchema } from "../../schemas/address"; @@ -25,16 +29,33 @@ import { createSmartGcpWalletDetails, createSmartLocalWalletDetails, } from "../../utils/wallets/create-smart-wallet"; +import { + CircleWalletError, + createCircleWalletDetails, +} from "../../utils/wallets/circle"; +import assert from "node:assert"; -const requestBodySchema = Type.Object({ - label: Type.Optional(Type.String()), - type: Type.Optional( - Type.Enum(WalletType, { - description: - "Type of new wallet to create. It is recommended to always provide this value. If not provided, the default wallet type will be used.", - }), - ), -}); +const requestBodySchema = Type.Union([ + // Base schema for non-circle wallet types + Type.Object({ + label: Type.Optional(Type.String()), + type: Type.Optional(Type.Union([Type.Enum(LegacyWalletType)])), + }), + + // Schema for circle and smart:circle wallet types + Type.Object({ + label: Type.Optional(Type.String()), + type: Type.Union([Type.Enum(CircleWalletType)]), + isTestnet: Type.Optional( + Type.Boolean({ + description: + "If your engine is configured with a testnet API Key for Circle, you can only create testnet wallets and send testnet transactions. Enable this field for testnet wallets. NOTE: A production API Key cannot be used for testnet transactions, and a testnet API Key cannot be used for production transactions. See: https://developers.circle.com/w3s/sandbox-vs-production", + }), + ), + credentialId: Type.String(), + walletSetId: Type.Optional(Type.String()), + }), +]); const responseSchema = Type.Object({ result: Type.Object({ @@ -112,6 +133,64 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { throw e; } break; + case CircleWalletType.circle: + { + // we need this if here for typescript to statically type the credentialId and walletSetId + assert(req.body.type === "circle", "Expected circle wallet type"); + const { credentialId, walletSetId, isTestnet } = req.body; + + try { + const wallet = await createCircleWalletDetails({ + label, + isSmart: false, + credentialId, + walletSetId, + isTestnet: isTestnet, + }); + + walletAddress = getAddress(wallet.address); + } catch (e) { + if (e instanceof CircleWalletError) { + throw createCustomError( + e.message, + StatusCodes.BAD_REQUEST, + "CREATE_CIRCLE_WALLET_ERROR", + ); + } + throw e; + } + } + break; + + case CircleWalletType.smartCircle: + { + // we need this if here for typescript to statically type the credentialId and walletSetId + assert(req.body.type === "smart:circle", "Expected circle wallet type"); + const { credentialId, walletSetId, isTestnet } = req.body; + + try { + const wallet = await createCircleWalletDetails({ + label, + isSmart: true, + credentialId, + walletSetId, + isTestnet: isTestnet, + }); + + walletAddress = getAddress(wallet.address); + } catch (e) { + if (e instanceof CircleWalletError) { + throw createCustomError( + e.message, + StatusCodes.BAD_REQUEST, + "CREATE_CIRCLE_WALLET_ERROR", + ); + } + throw e; + } + } + break; + case WalletType.smartAwsKms: try { const smartAwsWallet = await createSmartAwsWalletDetails({ @@ -161,12 +240,18 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { walletAddress = getAddress(smartLocalWallet.address); } break; + default: + throw createCustomError( + "Unkown wallet type", + StatusCodes.BAD_REQUEST, + "CREATE_WALLET_ERROR", + ); } reply.status(StatusCodes.OK).send({ result: { walletAddress, - type: walletType, + type: walletType as WalletType, status: "success", }, }); diff --git a/src/server/routes/configuration/wallets/update.ts b/src/server/routes/configuration/wallets/update.ts index dd4ed2b9..b807a960 100644 --- a/src/server/routes/configuration/wallets/update.ts +++ b/src/server/routes/configuration/wallets/update.ts @@ -21,6 +21,9 @@ const requestBodySchema = Type.Union([ gcpApplicationCredentialEmail: Type.String(), gcpApplicationCredentialPrivateKey: Type.String(), }), + Type.Object({ + circleApiKey: Type.String(), + }), ]); requestBodySchema.examples = [ @@ -107,6 +110,16 @@ export async function updateWalletsConfiguration(fastify: FastifyInstance) { }); } + if ("circleApiKey" in req.body) { + await updateConfiguration({ + walletProviderConfigs: { + circle: { + apiKey: req.body.circleApiKey, + }, + }, + }); + } + const config = await getConfig(false); const { legacyWalletType_removeInNextBreakingChange, aws, gcp } = diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 1606debc..370ab9e6 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -113,6 +113,9 @@ 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"; +import { createWalletCredentialRoute } from "./wallet-credentials/create"; +import { getWalletCredentialRoute } from "./wallet-credentials/get"; +import { getAllWalletCredentialsRoute } from "./wallet-credentials/get-all"; export async function withRoutes(fastify: FastifyInstance) { // Backend Wallets @@ -137,6 +140,11 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(getBackendWalletNonce); await fastify.register(simulateTransaction); + // Credentials + await fastify.register(createWalletCredentialRoute); + await fastify.register(getWalletCredentialRoute); + await fastify.register(getAllWalletCredentialsRoute); + // Configuration await fastify.register(getWalletsConfiguration); await fastify.register(updateWalletsConfiguration); diff --git a/src/server/routes/wallet-credentials/create.ts b/src/server/routes/wallet-credentials/create.ts new file mode 100644 index 00000000..53ec65f3 --- /dev/null +++ b/src/server/routes/wallet-credentials/create.ts @@ -0,0 +1,104 @@ +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { createWalletCredential } from "../../../shared/db/wallet-credentials/create-wallet-credential"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; +import { type Static, Type } from "@sinclair/typebox"; +import { WalletCredentialsError } from "../../../shared/db/wallet-credentials/get-wallet-credential"; +import { createCustomError } from "../../middleware/error"; + +const requestBodySchema = Type.Object({ + label: Type.String(), + type: Type.Literal("circle"), + entitySecret: Type.Optional( + Type.String({ + description: + "32-byte hex string. If not provided, a random one will be generated.", + pattern: "^[0-9a-fA-F]{64}$", + }), + ), + isDefault: Type.Optional( + Type.Boolean({ + description: + "Whether this credential should be set as the default for its type. Only one credential can be default per type.", + default: false, + }), + ), +}); + +const responseSchema = Type.Object({ + result: Type.Object({ + id: Type.String(), + type: Type.String(), + label: Type.String(), + isDefault: Type.Union([Type.Boolean(), Type.Null()]), + createdAt: Type.String(), + updatedAt: Type.String(), + }), +}); + +responseSchema.example = { + result: { + id: "123e4567-e89b-12d3-a456-426614174000", + type: "circle", + label: "My Circle Credential", + isDefault: false, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, +}; + +export const createWalletCredentialRoute = async (fastify: FastifyInstance) => { + fastify.withTypeProvider().route<{ + Body: Static; + Reply: Static; + }>({ + method: "POST", + url: "/wallet-credentials", + schema: { + summary: "Create wallet credentials", + description: "Create a new set of wallet credentials.", + tags: ["Wallet Credentials"], + operationId: "createWalletCredential", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (req, reply) => { + const { label, type, entitySecret, isDefault } = req.body; + + let createdWalletCredential: Awaited< + ReturnType + > | null = null; + + try { + createdWalletCredential = await createWalletCredential({ + type, + label, + entitySecret, + isDefault, + }); + + reply.status(StatusCodes.OK).send({ + result: { + id: createdWalletCredential.id, + type: createdWalletCredential.type, + label: createdWalletCredential.label, + isDefault: createdWalletCredential.isDefault, + createdAt: createdWalletCredential.createdAt.toISOString(), + updatedAt: createdWalletCredential.updatedAt.toISOString(), + }, + }); + } catch (e: unknown) { + if (e instanceof WalletCredentialsError) { + throw createCustomError( + e.message, + StatusCodes.BAD_REQUEST, + "WALLET_CREDENTIAL_ERROR", + ); + } + } + }, + }); +}; diff --git a/src/server/routes/wallet-credentials/get-all.ts b/src/server/routes/wallet-credentials/get-all.ts new file mode 100644 index 00000000..f7a48a65 --- /dev/null +++ b/src/server/routes/wallet-credentials/get-all.ts @@ -0,0 +1,70 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { getAllWalletCredentials } from "../../../shared/db/wallet-credentials/get-all-wallet-credentials"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; +import { PaginationSchema } from "../../schemas/pagination"; + +const QuerySchema = PaginationSchema; + +const responseSchema = Type.Object({ + result: Type.Array( + Type.Object({ + id: Type.String(), + type: Type.String(), + label: Type.Union([Type.String(), Type.Null()]), + isDefault: Type.Union([Type.Boolean(), Type.Null()]), + createdAt: Type.String(), + updatedAt: Type.String(), + }), + ), +}); + +responseSchema.example = { + result: [ + { + id: "123e4567-e89b-12d3-a456-426614174000", + type: "circle", + label: "My Circle Credential", + isDefault: false, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + deletedAt: null, + }, + ], +}; + +export async function getAllWalletCredentialsRoute(fastify: FastifyInstance) { + fastify.route<{ + Querystring: Static; + Reply: Static; + }>({ + method: "GET", + url: "/wallet-credentials", + schema: { + summary: "Get all wallet credentials", + description: "Get all wallet credentials with pagination.", + tags: ["Wallet Credentials"], + operationId: "getAllWalletCredentials", + querystring: QuerySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (req, res) => { + const credentials = await getAllWalletCredentials({ + page: req.query.page, + limit: req.query.limit, + }); + + res.status(StatusCodes.OK).send({ + result: credentials.map((cred) => ({ + ...cred, + createdAt: cred.createdAt.toISOString(), + updatedAt: cred.updatedAt.toISOString(), + })), + }); + }, + }); +} diff --git a/src/server/routes/wallet-credentials/get.ts b/src/server/routes/wallet-credentials/get.ts new file mode 100644 index 00000000..c0141efe --- /dev/null +++ b/src/server/routes/wallet-credentials/get.ts @@ -0,0 +1,85 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { getWalletCredential, WalletCredentialsError } from "../../../shared/db/wallet-credentials/get-wallet-credential"; +import { createCustomError } from "../../middleware/error"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; + +const ParamsSchema = Type.Object({ + id: Type.String({ + description: "The ID of the wallet credential to get.", + }), +}); + +const responseSchema = Type.Object({ + result: Type.Object({ + id: Type.String(), + type: Type.String(), + label: Type.Union([Type.String(), Type.Null()]), + isDefault: Type.Boolean(), + createdAt: Type.String(), + updatedAt: Type.String(), + deletedAt: Type.Union([Type.String(), Type.Null()]), + }), +}); + +responseSchema.example = { + result: { + id: "123e4567-e89b-12d3-a456-426614174000", + type: "circle", + label: "My Circle Credential", + isDefault: false, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + deletedAt: null, + }, +}; + +export async function getWalletCredentialRoute(fastify: FastifyInstance) { + fastify.route<{ + Params: Static; + Reply: Static; + }>({ + method: "GET", + url: "/wallet-credentials/:id", + schema: { + summary: "Get wallet credential", + description: "Get a wallet credential by ID.", + tags: ["Wallet Credentials"], + operationId: "getWalletCredential", + params: ParamsSchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (req, reply) => { + try { + const credential = await getWalletCredential({ + id: req.params.id, + }); + + reply.status(StatusCodes.OK).send({ + result: { + id: credential.id, + type: credential.type, + label: credential.label, + isDefault: credential.isDefault, + createdAt: credential.createdAt.toISOString(), + updatedAt: credential.updatedAt.toISOString(), + deletedAt: credential.deletedAt?.toISOString() || null, + }, + }); + } catch (e) { + if (e instanceof WalletCredentialsError) { + throw createCustomError( + e.message, + StatusCodes.NOT_FOUND, + "WALLET_CREDENTIAL_NOT_FOUND", + ); + } + throw e; + } + }, + }); +} \ No newline at end of file diff --git a/src/server/utils/wallets/circle/index.ts b/src/server/utils/wallets/circle/index.ts new file mode 100644 index 00000000..5f175953 --- /dev/null +++ b/src/server/utils/wallets/circle/index.ts @@ -0,0 +1,338 @@ +import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets"; +import { getConfig } from "../../../../shared/utils/cache/get-config"; +import { getWalletCredential } from "../../../../shared/db/wallet-credentials/get-wallet-credential"; +import { + type Address, + eth_sendRawTransaction, + getRpcClient, + type Hex, + serializeTransaction, + type ThirdwebClient, + toHex, + type toSerializableTransaction, +} from "thirdweb"; +import { getChain } from "../../../../shared/utils/chain"; +import { + parseSignature, + type SignableMessage, + type TypedData, + type TypedDataDefinition, +} from "viem"; +import type { Account } from "thirdweb/wallets"; +import { thirdwebClient } from "../../../../shared/utils/sdk"; +import { prisma } from "../../../../shared/db/client"; +import { getConnectedSmartWallet } from "../create-smart-wallet"; +import { + DEFAULT_ACCOUNT_FACTORY_V0_7, + ENTRYPOINT_ADDRESS_v0_7, +} from "thirdweb/wallets/smart"; +import { stringify } from "thirdweb/utils"; + +export class CircleWalletError extends Error { + constructor(message: string) { + super(message); + this.name = "CircleWalletError"; + } +} + +export async function provisionCircleWallet({ + entitySecret, + apiKey, + walletSetId, + client, + isTestnet, +}: { + entitySecret: string; + apiKey: string; + walletSetId?: string; + client: ThirdwebClient; + isTestnet?: boolean; +}) { + const circleDeveloperSdk = initiateDeveloperControlledWalletsClient({ + apiKey: apiKey, + entitySecret: entitySecret, + }); + + if (!walletSetId) { + const walletSet = await circleDeveloperSdk + .createWalletSet({ + name: `Engine WalletSet ${new Date().toISOString()}`, + }) + .catch((e) => { + throw new CircleWalletError( + `[Circle] Could not create walletset:\n${JSON.stringify( + e?.response?.data, + )}`, + ); + }); + + walletSetId = walletSet.data?.walletSet.id; + if (!walletSetId) + throw new CircleWalletError( + "Did not receive walletSetId, and failed to create one automatically", + ); + } + + const provisionWalletResponse = await circleDeveloperSdk + .createWallets({ + accountType: "EOA", + blockchains: [isTestnet ? "EVM-TESTNET" : "EVM"], + count: 1, + walletSetId: walletSetId, + }) + .catch((e) => { + throw new CircleWalletError( + `[Circle] Could not provision wallet:\n${JSON.stringify( + e?.response?.data, + )}`, + ); + }); + + const provisionedWallet = provisionWalletResponse.data?.wallets?.[0]; + + if (!provisionedWallet) + throw new CircleWalletError("Did not receive provisioned wallet"); + + const circleAccount = await getCircleAccount({ + walletId: provisionedWallet.id, + apiKey: apiKey, + entitySecret: entitySecret, + client, + }); + + return { + walletSetId, + provisionedWallet: provisionedWallet, + account: circleAccount, + }; +} + +type SerializableTransaction = Awaited< + ReturnType +>; + +type SendTransactionOptions = SerializableTransaction & { + chainId: number; +}; + +type SendTransactionResult = { + transactionHash: Hex; +}; + +type CircleAccount = Account; + +export async function getCircleAccount({ + walletId, + apiKey, + entitySecret, + client, +}: { + walletId: string; + apiKey: string; + entitySecret: string; + client: ThirdwebClient; +}) { + const circleDeveloperSdk = initiateDeveloperControlledWalletsClient({ + apiKey, + entitySecret, + }); + + const walletResponse = await circleDeveloperSdk + .getWallet({ id: walletId }) + .catch((e) => { + throw new CircleWalletError( + `[Circle] Could not get wallet with id:${walletId}:\n${JSON.stringify( + e?.response?.data, + )}`, + ); + }); + + if (!walletResponse) { + throw new CircleWalletError( + `Unable to get circle wallet with id:${walletId}`, + ); + } + const wallet = walletResponse.data?.wallet; + const address = wallet?.address as Address; + + async function signTransaction(tx: SerializableTransaction) { + const signature = await circleDeveloperSdk + .signTransaction({ + walletId, + transaction: stringify(tx), + }) + .catch((e) => { + throw new CircleWalletError( + `[Circle] Could not get transaction signature:\n${JSON.stringify( + e?.response?.data, + )}`, + ); + }); + + if (!signature.data?.signature) { + throw new CircleWalletError("Unable to sign transaction"); + } + + return signature.data.signature as Hex; + } + + async function sendTransaction( + tx: SendTransactionOptions, + ): Promise { + const rpcRequest = getRpcClient({ + client: client, + chain: await getChain(tx.chainId), + }); + + const signature = await signTransaction(tx); + const splittedSignature = parseSignature(signature); + + const signedTransaction = serializeTransaction({ + transaction: tx, + signature: splittedSignature, + }); + + const transactionHash = await eth_sendRawTransaction( + rpcRequest, + signedTransaction, + ); + return { transactionHash }; + } + + async function signTypedData< + const typedData extends TypedData | Record, + primaryType extends keyof typedData | "EIP712Domain" = keyof typedData, + >(_typedData: TypedDataDefinition): Promise { + const signatureResponse = await circleDeveloperSdk + .signTypedData({ + data: stringify(_typedData), + walletId, + }) + .catch((e) => { + throw new CircleWalletError( + `[Circle] Could not get signature:\n${JSON.stringify( + e?.response?.data, + )}`, + ); + }); + + if (!signatureResponse.data?.signature) { + throw new CircleWalletError("Could not sign typed data"); + } + + return signatureResponse.data?.signature as Hex; + } + + async function signMessage({ + message, + }: { + message: SignableMessage; + }): Promise { + const isRawMessage = typeof message === "object" && "raw" in message; + let messageToSign = isRawMessage ? message.raw : message; + + if (typeof messageToSign !== "string") { + messageToSign = toHex(messageToSign); + } + + const signatureResponse = await circleDeveloperSdk + .signMessage({ + walletId, + message: messageToSign, + encodedByHex: isRawMessage, + }) + .catch((e) => { + throw new CircleWalletError( + `[Circle] Could not get signature:\n${JSON.stringify( + e?.response?.data, + )}`, + ); + }); + + if (!signatureResponse.data?.signature) + throw new CircleWalletError("Could not get signature"); + return signatureResponse.data?.signature as Hex; + } + + return { + address, + sendTransaction, + signMessage, + signTypedData, + signTransaction, + } as CircleAccount satisfies Account; +} + +export async function createCircleWalletDetails({ + credentialId, + walletSetId, + label, + isSmart, + isTestnet, +}: { + credentialId: string; + walletSetId?: string; + label?: string; + isSmart: boolean; + isTestnet?: boolean; +}) { + const { + walletConfiguration: { circle }, + } = await getConfig(); + + if (!circle) { + throw new CircleWalletError( + "Circle wallet configuration not found. Please check your configuration.", + ); + } + + const credential = await getWalletCredential({ + id: credentialId, + }); + + if (credential.type !== "circle") { + throw new CircleWalletError( + `Invalid Credential: not valid type, expected circle received ${credential.type}`, + ); + } + + const provisionedDetails = await provisionCircleWallet({ + entitySecret: credential.data.entitySecret, + apiKey: circle.apiKey, + client: thirdwebClient, + walletSetId, + isTestnet, + }); + + let address = provisionedDetails.account.address; + + const sbwDetails = { + accountFactoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7, + entrypointAddress: ENTRYPOINT_ADDRESS_v0_7, + accountSignerAddress: address, + } as const; + + if (isSmart) { + const smartAccount = await getConnectedSmartWallet({ + adminAccount: provisionedDetails.account, + ...sbwDetails, + }); + + address = smartAccount.address; + } + + return await prisma.walletDetails.create({ + data: { + address: address.toLowerCase(), + type: isSmart ? "smart:circle" : "circle", + label: label, + credentialId, + platformIdentifiers: { + circleWalletId: provisionedDetails.provisionedWallet.id, + walletSetId: provisionedDetails.walletSetId, + isTestnet: isTestnet ?? false, + }, + ...(isSmart ? sbwDetails : {}), + }, + }); +} diff --git a/src/shared/db/configuration/get-configuration.ts b/src/shared/db/configuration/get-configuration.ts index 71776b87..d765fafb 100644 --- a/src/shared/db/configuration/get-configuration.ts +++ b/src/shared/db/configuration/get-configuration.ts @@ -5,6 +5,7 @@ import { ethers } from "ethers"; import type { Chain } from "thirdweb"; import type { AwsWalletConfiguration, + CircleWalletConfiguration, GcpWalletConfiguration, ParsedConfig, } from "../../schemas/config"; @@ -16,6 +17,15 @@ import { env } from "../../utils/env"; import { logger } from "../../utils/logger"; import { prisma } from "../client"; import { updateConfiguration } from "./update-configuration"; +import * as z from "zod"; + +export const walletProviderConfigsSchema = z.object({ + circle: z + .object({ + apiKey: z.string(), + }) + .optional(), +}); const toParsedConfig = async (config: Configuration): Promise => { // We destructure the config to omit wallet related fields to prevent direct access @@ -29,6 +39,7 @@ const toParsedConfig = async (config: Configuration): Promise => { gcpApplicationCredentialEmail, gcpApplicationCredentialPrivateKey, contractSubscriptionsRetryDelaySeconds, + walletProviderConfigs, ...restConfig } = config; @@ -162,6 +173,33 @@ const toParsedConfig = async (config: Configuration): Promise => { legacyWalletType_removeInNextBreakingChange = WalletType.gcpKms; } + let circleWalletConfiguration: CircleWalletConfiguration | null = null; + + const { + data: parsedWalletProviderConfigs, + success, + error: walletProviderConfigsParseError, + } = walletProviderConfigsSchema.safeParse(walletProviderConfigs?.valueOf()); + + // TODO: fail loudly if walletProviderConfigs is not valid + if (!success) { + logger({ + level: "error", + message: "Invalid wallet provider configs", + service: "server", + error: walletProviderConfigsParseError, + }); + } + + if (parsedWalletProviderConfigs?.circle) { + circleWalletConfiguration = { + apiKey: decrypt( + parsedWalletProviderConfigs.circle.apiKey, + env.ENCRYPTION_PASSWORD, + ), + }; + } + return { ...restConfig, contractSubscriptionsRequeryDelaySeconds: @@ -170,6 +208,7 @@ const toParsedConfig = async (config: Configuration): Promise => { walletConfiguration: { aws: awsWalletConfiguration, gcp: gcpWalletConfiguration, + circle: circleWalletConfiguration, legacyWalletType_removeInNextBreakingChange, }, mtlsCertificate: config.mtlsCertificateEncrypted diff --git a/src/shared/db/configuration/update-configuration.ts b/src/shared/db/configuration/update-configuration.ts index 4e153cde..41e49df0 100644 --- a/src/shared/db/configuration/update-configuration.ts +++ b/src/shared/db/configuration/update-configuration.ts @@ -1,26 +1,53 @@ import type { Prisma } from "@prisma/client"; import { encrypt } from "../../utils/crypto"; import { prisma } from "../client"; +import { walletProviderConfigsSchema } from "./get-configuration"; +import { logger } from "../../utils/logger"; export const updateConfiguration = async ( data: Prisma.ConfigurationUpdateInput, ) => { + if (typeof data.awsSecretAccessKey === "string") { + data.awsSecretAccessKey = encrypt(data.awsSecretAccessKey); + } + + if (typeof data.gcpApplicationCredentialPrivateKey === "string") { + data.gcpApplicationCredentialPrivateKey = encrypt( + data.gcpApplicationCredentialPrivateKey, + ); + } + + // allow undefined (for no updates to field), but do not allow any other values than object + if (typeof data.walletProviderConfigs === "object") { + const { data: parsedWalletProviderConfigs, error } = + walletProviderConfigsSchema.safeParse(data.walletProviderConfigs); + + if (error) { + logger({ + level: "error", + message: "Invalid walletProviderConfigs", + error: error, + service: "server", + }); + // it's okay to throw a raw error here, any HTTP call that uses this should validate the input + throw new Error("Invalid walletProviderConfigs"); + } + + if (parsedWalletProviderConfigs?.circle?.apiKey) { + parsedWalletProviderConfigs.circle.apiKey = encrypt( + parsedWalletProviderConfigs.circle.apiKey, + ); + } + + data.walletProviderConfigs = parsedWalletProviderConfigs; + } else if (typeof data.walletProviderConfigs !== "undefined") { + throw new Error("Invalid walletProviderConfigs"); + } + return prisma.configuration.update({ where: { id: "default", }, - data: { - ...data, - ...(typeof data.awsSecretAccessKey === "string" - ? { awsSecretAccessKey: encrypt(data.awsSecretAccessKey) } - : {}), - ...(typeof data.gcpApplicationCredentialPrivateKey === "string" - ? { - gcpApplicationCredentialPrivateKey: encrypt( - data.gcpApplicationCredentialPrivateKey, - ), - } - : {}), - }, + data, }); }; diff --git a/src/shared/db/wallet-credentials/create-wallet-credential.ts b/src/shared/db/wallet-credentials/create-wallet-credential.ts new file mode 100644 index 00000000..db3bfad2 --- /dev/null +++ b/src/shared/db/wallet-credentials/create-wallet-credential.ts @@ -0,0 +1,79 @@ +import { encrypt } from "../../utils/crypto"; +import { registerEntitySecretCiphertext } from "@circle-fin/developer-controlled-wallets"; +import { prisma } from "../client"; +import { getConfig } from "../../utils/cache/get-config"; +import { WalletCredentialsError } from "./get-wallet-credential"; +import { randomBytes } from "node:crypto"; +import { cirlceEntitySecretZodSchema } from "../../schemas/wallet"; + +// will be expanded to be a discriminated union of all supported wallet types +export type CreateWalletCredentialsParams = { + type: "circle"; + label: string; + entitySecret?: string; + isDefault?: boolean; +}; + +export const createWalletCredential = async ({ + type, + label, + entitySecret, + isDefault, +}: CreateWalletCredentialsParams) => { + const { walletConfiguration } = await getConfig(); + + switch (type) { + case "circle": { + const circleApiKey = walletConfiguration.circle?.apiKey; + + if (!circleApiKey) { + throw new WalletCredentialsError("No Circle API Key Configured"); + } + + if (entitySecret) { + const { error } = cirlceEntitySecretZodSchema.safeParse(entitySecret); + if (error) { + throw new WalletCredentialsError( + "Invalid provided entity secret for Circle", + ); + } + } + + // If entitySecret is not provided, generate a random one + const finalEntitySecret = entitySecret ?? randomBytes(32).toString("hex"); + // Create the wallet credentials + const walletCredentials = await prisma.walletCredentials.create({ + data: { + type, + label, + isDefault: isDefault ?? null, + data: { + entitySecret: encrypt(finalEntitySecret), + }, + }, + }); + + // try registering the entity secret. See: https://developers.circle.com/w3s/developer-controlled-create-your-first-wallet + try { + await registerEntitySecretCiphertext({ + apiKey: circleApiKey, + entitySecret: finalEntitySecret, + recoveryFileDownloadPath: "/dev/null", + }); + } catch (e: unknown) { + // If failed to registeer, permanently delete erroneously created credential + await prisma.walletCredentials.delete({ + where: { + id: walletCredentials.id, + }, + }); + + throw new WalletCredentialsError( + `Could not register Entity Secret with Circle\n${JSON.stringify(e)}`, + ); + } + + return walletCredentials; + } + } +}; diff --git a/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts b/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts new file mode 100644 index 00000000..828cbdb6 --- /dev/null +++ b/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts @@ -0,0 +1,34 @@ +import { prisma } from "../client"; +import type { PrismaTransaction } from "../../schemas/prisma"; + +interface GetAllWalletCredentialsParams { + pgtx?: PrismaTransaction; + page?: number; + limit?: number; +} + +export const getAllWalletCredentials = async ({ + page = 1, + limit = 10, +}: GetAllWalletCredentialsParams) => { + const credentials = await prisma.walletCredentials.findMany({ + where: { + deletedAt: null, + }, + skip: (page - 1) * limit, + take: limit, + select: { + id: true, + type: true, + label: true, + isDefault: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return credentials; +}; diff --git a/src/shared/db/wallet-credentials/get-wallet-credential.ts b/src/shared/db/wallet-credentials/get-wallet-credential.ts new file mode 100644 index 00000000..21b7b8a2 --- /dev/null +++ b/src/shared/db/wallet-credentials/get-wallet-credential.ts @@ -0,0 +1,81 @@ +import LRUMap from "mnemonist/lru-map"; +import { z } from "zod"; +import { decrypt } from "../../utils/crypto"; +import { env } from "../../utils/env"; +import { prisma } from "../client"; + +export class WalletCredentialsError extends Error { + constructor(message: string) { + super(message); + this.name = "WalletCredentialsError"; + } +} + +const walletCredentialsSchema = z.object({ + id: z.string(), + type: z.literal("circle"), + label: z.string().nullable(), + data: z.object({ + entitySecret: z.string(), + }), + isDefault: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), + deletedAt: z.date().nullable(), +}); + +export type ParsedWalletCredential = z.infer; + +export const walletCredentialsCache = new LRUMap< + string, + ParsedWalletCredential +>(2048); + +interface GetWalletCredentialParams { + id: string; +} + +/** + * Return the wallet credentials for the given id. + * The entitySecret will be decrypted. + * If the credentials are not found, an error is thrown. + */ +export const getWalletCredential = async ({ + id, +}: GetWalletCredentialParams) => { + const cachedCredentials = walletCredentialsCache.get(id); + if (cachedCredentials) { + return cachedCredentials; + } + + const credential = await prisma.walletCredentials.findUnique({ + where: { + id, + }, + }); + + if (!credential) { + throw new WalletCredentialsError( + `No wallet credentials found for id ${id}`, + ); + } + + const { data: parsedCredential, error: parseError } = + walletCredentialsSchema.safeParse(credential); + + if (parseError) { + throw new WalletCredentialsError( + `Invalid Credential found for ${id}:\n${parseError.errors + .map((error) => error.message) + .join(", ")}`, + ); + } + + parsedCredential.data.entitySecret = decrypt( + parsedCredential.data.entitySecret, + env.ENCRYPTION_PASSWORD, + ); + + walletCredentialsCache.set(id, parsedCredential); + return parsedCredential; +}; diff --git a/src/shared/db/wallets/get-wallet-details.ts b/src/shared/db/wallets/get-wallet-details.ts index c6612d62..0258feb7 100644 --- a/src/shared/db/wallets/get-wallet-details.ts +++ b/src/shared/db/wallets/get-wallet-details.ts @@ -57,8 +57,7 @@ const smartLocalWalletSchema = localWalletSchema .extend({ type: z.literal("smart:local"), }) - .merge(smartWalletPartialSchema) - .merge(baseWalletPartialSchema); + .merge(smartWalletPartialSchema); const awsKmsWalletSchema = z .object({ @@ -73,8 +72,7 @@ const smartAwsKmsWalletSchema = awsKmsWalletSchema .extend({ type: z.literal("smart:aws-kms"), }) - .merge(smartWalletPartialSchema) - .merge(baseWalletPartialSchema); + .merge(smartWalletPartialSchema); const gcpKmsWalletSchema = z .object({ @@ -89,9 +87,26 @@ const smartGcpKmsWalletSchema = gcpKmsWalletSchema .extend({ type: z.literal("smart:gcp-kms"), }) - .merge(smartWalletPartialSchema) + .merge(smartWalletPartialSchema); + +const circleWalletSchema = z + .object({ + type: z.literal("circle"), + platformIdentifiers: z.object({ + circleWalletId: z.string(), + walletSetId: z.string(), + isTestnet: z.boolean(), + }), + credentialId: z.string(), + }) .merge(baseWalletPartialSchema); +const smartCircleWalletSchema = circleWalletSchema + .extend({ + type: z.literal("smart:circle"), + }) + .merge(smartWalletPartialSchema); + const walletDetailsSchema = z.discriminatedUnion("type", [ localWalletSchema, smartLocalWalletSchema, @@ -99,12 +114,15 @@ const walletDetailsSchema = z.discriminatedUnion("type", [ smartAwsKmsWalletSchema, gcpKmsWalletSchema, smartGcpKmsWalletSchema, + circleWalletSchema, + smartCircleWalletSchema, ]); export type SmartBackendWalletDetails = | z.infer | z.infer - | z.infer; + | z.infer + | z.infer; export function isSmartBackendWallet( wallet: ParsedWalletDetails, @@ -118,12 +136,14 @@ export const SmartBackendWalletTypes = [ "smart:local", "smart:aws-kms", "smart:gcp-kms", + "smart:circle", ] as const; export const BackendWalletTypes = [ "local", "aws-kms", "gcp-kms", + "circle", ...SmartBackendWalletTypes, ] as const; @@ -181,7 +201,7 @@ export const getWalletDetails = async ({ walletDetails.awsKmsSecretAccessKey = walletDetails.awsKmsSecretAccessKey ? decrypt(walletDetails.awsKmsSecretAccessKey, env.ENCRYPTION_PASSWORD) - : (config.walletConfiguration.aws?.awsSecretAccessKey ?? null); + : config.walletConfiguration.aws?.awsSecretAccessKey ?? null; walletDetails.awsKmsAccessKeyId = walletDetails.awsKmsAccessKeyId ?? @@ -206,8 +226,8 @@ export const getWalletDetails = async ({ walletDetails.gcpApplicationCredentialPrivateKey, env.ENCRYPTION_PASSWORD, ) - : (config.walletConfiguration.gcp?.gcpApplicationCredentialPrivateKey ?? - null); + : config.walletConfiguration.gcp?.gcpApplicationCredentialPrivateKey ?? + null; walletDetails.gcpApplicationCredentialEmail = walletDetails.gcpApplicationCredentialEmail ?? diff --git a/src/shared/schemas/config.ts b/src/shared/schemas/config.ts index 6205c381..97d7c6d3 100644 --- a/src/shared/schemas/config.ts +++ b/src/shared/schemas/config.ts @@ -21,6 +21,10 @@ export type GcpWalletConfiguration = { defaultGcpApplicationProjectId: string; }; +export type CircleWalletConfiguration = { + apiKey: string; +}; + export interface ParsedConfig extends Omit< Configuration, @@ -35,10 +39,12 @@ export interface ParsedConfig | "contractSubscriptionsRetryDelaySeconds" | "mtlsCertificateEncrypted" | "mtlsPrivateKeyEncrypted" + | "walletProviderConfigs" > { walletConfiguration: { aws: AwsWalletConfiguration | null; gcp: GcpWalletConfiguration | null; + circle: CircleWalletConfiguration | null; legacyWalletType_removeInNextBreakingChange: WalletType; }; contractSubscriptionsRequeryDelaySeconds: string; diff --git a/src/shared/schemas/wallet.ts b/src/shared/schemas/wallet.ts index 4200e1eb..6c856e86 100644 --- a/src/shared/schemas/wallet.ts +++ b/src/shared/schemas/wallet.ts @@ -1,4 +1,25 @@ +import * as z from "zod"; + +export enum CircleWalletType { + circle = "circle", + + // Smart wallets + smartCircle = "smart:circle", +} + +export enum LegacyWalletType { + local = "local", + awsKms = "aws-kms", + gcpKms = "gcp-kms", + + // Smart wallets + smartAwsKms = "smart:aws-kms", + smartGcpKms = "smart:gcp-kms", + smartLocal = "smart:local", +} + export enum WalletType { + // Legacy wallet types local = "local", awsKms = "aws-kms", gcpKms = "gcp-kms", @@ -7,4 +28,14 @@ export enum WalletType { smartAwsKms = "smart:aws-kms", smartGcpKms = "smart:gcp-kms", smartLocal = "smart:local", + + // New credential based wallet types + circle = "circle", + + // Smart wallets + smartCircle = "smart:circle", } + +export const cirlceEntitySecretZodSchema = z.string().regex(/^[0-9a-fA-F]{64}$/, { + message: "entitySecret must be a 32-byte hex string", +}); diff --git a/src/shared/utils/account.ts b/src/shared/utils/account.ts index 496071e3..10944fa6 100644 --- a/src/shared/utils/account.ts +++ b/src/shared/utils/account.ts @@ -18,6 +18,9 @@ import { import { getSmartWalletV5 } from "./cache/get-smart-wallet-v5"; import { getChain } from "./chain"; import { thirdwebClient } from "./sdk"; +import { getWalletCredential } from "../db/wallet-credentials/get-wallet-credential"; +import { getCircleAccount } from "../../server/utils/wallets/circle"; +import { getConfig } from "./cache/get-config"; export const _accountsCache = new LRUMap(2048); @@ -152,6 +155,58 @@ export const walletDetailsToAccount = async ({ return { account: connectedWallet, adminAccount }; } + + case WalletType.circle: { + const { + walletConfiguration: { circle }, + } = await getConfig(); + + if (!circle) + throw new Error("No configuration found for circle wallet type"); + + const credentials = await getWalletCredential({ + id: walletDetails.credentialId, + }); + + const account = await getCircleAccount({ + apiKey: circle.apiKey, + client: thirdwebClient, + entitySecret: credentials.data.entitySecret, + walletId: walletDetails.platformIdentifiers.circleWalletId, + }); + + return { account }; + } + + case WalletType.smartCircle: { + const { + walletConfiguration: { circle }, + } = await getConfig(); + + if (!circle) + throw new Error("No configuration found for circle wallet type"); + + const credentials = await getWalletCredential({ + id: walletDetails.credentialId, + }); + + const adminAccount = await getCircleAccount({ + apiKey: circle.apiKey, + client: thirdwebClient, + entitySecret: credentials.data.entitySecret, + walletId: walletDetails.platformIdentifiers.circleWalletId, + }); + + const connectedWallet = await getConnectedSmartWallet({ + adminAccount: adminAccount, + accountFactoryAddress: walletDetails.accountFactoryAddress ?? undefined, + entrypointAddress: walletDetails.entrypointAddress ?? undefined, + chain: chain, + }); + + return { account: connectedWallet, adminAccount }; + } + default: throw new Error(`Wallet type not supported: ${walletDetails.type}`); } diff --git a/src/shared/utils/cache/get-contract.ts b/src/shared/utils/cache/get-contract.ts index c590434d..baa5a49e 100644 --- a/src/shared/utils/cache/get-contract.ts +++ b/src/shared/utils/cache/get-contract.ts @@ -3,6 +3,7 @@ import { StatusCodes } from "http-status-codes"; import { createCustomError } from "../../../server/middleware/error"; import { abiSchema } from "../../../server/schemas/contract"; import { getSdk } from "./get-sdk"; +import type { ThirdwebSDK } from "@thirdweb-dev/sdk"; const abiArraySchema = Type.Array(abiSchema); @@ -21,7 +22,17 @@ export const getContract = async ({ accountAddress, abi, }: GetContractParams) => { - const sdk = await getSdk({ chainId, walletAddress, accountAddress }); + let sdk: ThirdwebSDK; + + try { + sdk = await getSdk({ chainId, walletAddress, accountAddress }); + } catch (e) { + throw createCustomError( + `Could not get SDK: ${e}`, + StatusCodes.BAD_REQUEST, + "INVALID_CHAIN_OR_WALLET_TYPE_FOR_ROUTE", + ); + } try { if (abi) { diff --git a/src/shared/utils/cache/get-wallet.ts b/src/shared/utils/cache/get-wallet.ts index 4f353341..bc3124b8 100644 --- a/src/shared/utils/cache/get-wallet.ts +++ b/src/shared/utils/cache/get-wallet.ts @@ -171,7 +171,7 @@ export const getWallet = async ({ default: throw new Error( - `Wallet with address ${walletAddress} was configured with unknown wallet type ${walletDetails.type}`, + `Wallet with address ${walletAddress} of type ${walletDetails.type} is not supported for these routes yet`, ); } diff --git a/yarn.lock b/yarn.lock index 55f064b9..8e200fea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -658,6 +658,13 @@ dependencies: "@bull-board/api" "5.23.0" +"@circle-fin/developer-controlled-wallets@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@circle-fin/developer-controlled-wallets/-/developer-controlled-wallets-7.0.0.tgz#520bbe54e050dbf9585b54bc61d372887d0dc149" + integrity sha512-GbouORrWpec27DIOVuWfdyP25inrGQUNj2Vwgp7pJm15Z09E9OQBQjB334rGCIM4MT4NVuKKDkbOHTIphoi7zg== + dependencies: + axios "^1.6.2" + "@cloud-cryptographic-wallet/asn1-parser@^0.0.4": version "0.0.4" resolved "https://registry.yarnpkg.com/@cloud-cryptographic-wallet/asn1-parser/-/asn1-parser-0.0.4.tgz#4494a8f46d2b3974731d6cc2f3f34cb8afeb0c78" @@ -5158,7 +5165,7 @@ aws4fetch@1.0.20: resolved "https://registry.yarnpkg.com/aws4fetch/-/aws4fetch-1.0.20.tgz#090d6c65e32c6df645dd5e5acf04cc56da575cbe" integrity sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g== -axios@>=1.7.8, axios@^0.21.0, axios@^0.27.2: +axios@>=1.7.8, axios@^0.21.0, axios@^0.27.2, axios@^1.6.2: version "1.7.9" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== @@ -8857,6 +8864,19 @@ ox@0.4.2: abitype "^1.0.6" eventemitter3 "5.0.1" +ox@^0.6.9: + version "0.6.9" + resolved "https://registry.yarnpkg.com/ox/-/ox-0.6.9.tgz#da1ee04fa10de30c8d04c15bfb80fe58b1f554bd" + integrity sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug== + dependencies: + "@adraffy/ens-normalize" "^1.10.1" + "@noble/curves" "^1.6.0" + "@noble/hashes" "^1.5.0" + "@scure/bip32" "^1.5.0" + "@scure/bip39" "^1.4.0" + abitype "^1.0.6" + eventemitter3 "5.0.1" + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" From 6652e44b8fe8d3d965a5bf14a96ab11f6a22ada1 Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Sat, 8 Feb 2025 09:32:26 +0800 Subject: [PATCH 07/10] fix: add auth health check (#843) * fix: add auth health check * fix reply schema for 5xx --- src/server/routes/system/health.ts | 69 +++++++++++++++--------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/src/server/routes/system/health.ts b/src/server/routes/system/health.ts index 23672c92..f358ad84 100644 --- a/src/server/routes/system/health.ts +++ b/src/server/routes/system/health.ts @@ -5,7 +5,6 @@ import { isDatabaseReachable } from "../../../shared/db/client"; import { env } from "../../../shared/utils/env"; import { isRedisReachable } from "../../../shared/utils/redis/redis"; import { thirdwebClientId } from "../../../shared/utils/sdk"; -import { createCustomError } from "../../middleware/error"; type EngineFeature = | "KEYPAIR_AUTH" @@ -14,8 +13,10 @@ type EngineFeature = | "HETEROGENEOUS_WALLET_TYPES" | "SMART_BACKEND_WALLETS"; -const ReplySchemaOk = Type.Object({ - status: Type.String(), +const ReplySchema = Type.Object({ + db: Type.Boolean(), + redis: Type.Boolean(), + auth: Type.Boolean(), engineVersion: Type.Optional(Type.String()), engineTier: Type.Optional(Type.String()), features: Type.Array( @@ -30,15 +31,9 @@ const ReplySchemaOk = Type.Object({ clientId: Type.String(), }); -const ReplySchemaError = Type.Object({ - error: Type.String(), -}); - -const responseBodySchema = Type.Union([ReplySchemaOk, ReplySchemaError]); - export async function healthCheck(fastify: FastifyInstance) { fastify.route<{ - Reply: Static; + Reply: Static; }>({ method: "GET", url: "/system/health", @@ -49,34 +44,27 @@ export async function healthCheck(fastify: FastifyInstance) { tags: ["System"], operationId: "checkHealth", response: { - [StatusCodes.OK]: ReplySchemaOk, - [StatusCodes.SERVICE_UNAVAILABLE]: ReplySchemaError, + [StatusCodes.OK]: ReplySchema, + [StatusCodes.SERVICE_UNAVAILABLE]: ReplySchema, }, }, handler: async (_, res) => { - if (!(await isDatabaseReachable())) { - throw createCustomError( - "The database is unreachable.", - StatusCodes.SERVICE_UNAVAILABLE, - "FAILED_HEALTHCHECK", - ); - } - - if (!(await isRedisReachable())) { - throw createCustomError( - "Redis is unreachable.", - StatusCodes.SERVICE_UNAVAILABLE, - "FAILED_HEALTHCHECK", - ); - } + const db = await isDatabaseReachable(); + const redis = await isRedisReachable(); + const auth = await isAuthValid(); + const isHealthy = db && redis && auth; - res.status(StatusCodes.OK).send({ - status: "OK", - engineVersion: env.ENGINE_VERSION, - engineTier: env.ENGINE_TIER ?? "SELF_HOSTED", - features: getFeatures(), - clientId: thirdwebClientId, - }); + res + .status(isHealthy ? StatusCodes.OK : StatusCodes.SERVICE_UNAVAILABLE) + .send({ + db, + redis, + auth, + engineVersion: env.ENGINE_VERSION, + engineTier: env.ENGINE_TIER ?? "SELF_HOSTED", + features: getFeatures(), + clientId: thirdwebClientId, + }); }, }); } @@ -95,3 +83,16 @@ const getFeatures = (): EngineFeature[] => { return features; }; + +async function isAuthValid() { + try { + const resp = await fetch("https://api.thirdweb.com/v2/keys/use", { + headers: { + "x-secret-key": env.THIRDWEB_API_SECRET_KEY, + }, + }); + return resp.ok; + } catch { + return false; + } +} From f4e728f2f7589866358bcb1a48a35461e4c608ea Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Sat, 8 Feb 2025 07:21:47 +0530 Subject: [PATCH 08/10] credentials feature flag and sdk bump (#844) * build SDK * bump SDK * feature flag * update flag name --- sdk/package.json | 2 +- sdk/src/Engine.ts | 3 + sdk/src/index.ts | 1 + sdk/src/services/BackendWalletService.ts | 24 ++-- sdk/src/services/ConfigurationService.ts | 24 +++- sdk/src/services/ContractEventsService.ts | 8 +- sdk/src/services/WalletCredentialsService.ts | 125 +++++++++++++++++++ sdk/src/services/WebhooksService.ts | 2 +- src/server/routes/system/health.ts | 5 +- 9 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 sdk/src/services/WalletCredentialsService.ts diff --git a/sdk/package.json b/sdk/package.json index 19a522e9..5cc6fa59 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@thirdweb-dev/engine", - "version": "0.0.18", + "version": "0.0.19", "main": "dist/thirdweb-dev-engine.cjs.js", "module": "dist/thirdweb-dev-engine.esm.js", "files": [ diff --git a/sdk/src/Engine.ts b/sdk/src/Engine.ts index 8a8e397a..72dd69d9 100644 --- a/sdk/src/Engine.ts +++ b/sdk/src/Engine.ts @@ -30,6 +30,7 @@ import { MarketplaceOffersService } from './services/MarketplaceOffersService'; import { PermissionsService } from './services/PermissionsService'; import { RelayerService } from './services/RelayerService'; import { TransactionService } from './services/TransactionService'; +import { WalletCredentialsService } from './services/WalletCredentialsService'; import { WebhooksService } from './services/WebhooksService'; type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest; @@ -60,6 +61,7 @@ class EngineLogic { public readonly permissions: PermissionsService; public readonly relayer: RelayerService; public readonly transaction: TransactionService; + public readonly walletCredentials: WalletCredentialsService; public readonly webhooks: WebhooksService; public readonly request: BaseHttpRequest; @@ -101,6 +103,7 @@ class EngineLogic { this.permissions = new PermissionsService(this.request); this.relayer = new RelayerService(this.request); this.transaction = new TransactionService(this.request); + this.walletCredentials = new WalletCredentialsService(this.request); this.webhooks = new WebhooksService(this.request); } } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index bb6705ea..9a6e7cda 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -34,4 +34,5 @@ export { MarketplaceOffersService } from './services/MarketplaceOffersService'; export { PermissionsService } from './services/PermissionsService'; export { RelayerService } from './services/RelayerService'; export { TransactionService } from './services/TransactionService'; +export { WalletCredentialsService } from './services/WalletCredentialsService'; export { WebhooksService } from './services/WebhooksService'; diff --git a/sdk/src/services/BackendWalletService.ts b/sdk/src/services/BackendWalletService.ts index 7c25b097..9be83408 100644 --- a/sdk/src/services/BackendWalletService.ts +++ b/sdk/src/services/BackendWalletService.ts @@ -17,13 +17,19 @@ export class BackendWalletService { * @throws ApiError */ public create( - requestBody?: { + requestBody?: ({ label?: string; + type?: ('local' | 'aws-kms' | 'gcp-kms' | 'smart:aws-kms' | 'smart:gcp-kms' | 'smart:local'); + } | { + label?: string; + type: ('circle' | 'smart:circle'); /** - * Type of new wallet to create. It is recommended to always provide this value. If not provided, the default wallet type will be used. + * If your engine is configured with a testnet API Key for Circle, you can only create testnet wallets and send testnet transactions. Enable this field for testnet wallets. NOTE: A production API Key cannot be used for testnet transactions, and a testnet API Key cannot be used for production transactions. See: https://developers.circle.com/w3s/sandbox-vs-production */ - type?: ('local' | 'aws-kms' | 'gcp-kms' | 'smart:aws-kms' | 'smart:gcp-kms' | 'smart:local'); - }, + isTestnet?: boolean; + credentialId: string; + walletSetId?: string; + }), ): CancelablePromise<{ result: { /** @@ -31,7 +37,7 @@ export class BackendWalletService { */ walletAddress: string; status: string; - type: ('local' | 'aws-kms' | 'gcp-kms' | 'smart:aws-kms' | 'smart:gcp-kms' | 'smart:local'); + type: ('local' | 'aws-kms' | 'gcp-kms' | 'smart:aws-kms' | 'smart:gcp-kms' | 'smart:local' | 'circle' | 'smart:circle'); }; }> { return this.httpRequest.request({ @@ -721,7 +727,7 @@ export class BackendWalletService { gasPrice?: string; data?: string; value?: string; - chainId?: number; + chainId: number; type?: number; accessList?: any; maxFeePerGas?: string; @@ -1032,7 +1038,7 @@ export class BackendWalletService { * @throws ApiError */ public resetNonces( - requestBody?: { + requestBody: { /** * The chain ID to reset nonces for. */ @@ -1041,6 +1047,10 @@ export class BackendWalletService { * The backend wallet address to reset nonces for. Omit to reset all backend wallets. */ walletAddress?: string; + /** + * Resync nonces to match the onchain transaction count for your backend wallets. (Default: true) + */ + syncOnchainNonces: boolean; }, ): CancelablePromise<{ result: { diff --git a/sdk/src/services/ConfigurationService.ts b/sdk/src/services/ConfigurationService.ts index 57b32942..40de38b9 100644 --- a/sdk/src/services/ConfigurationService.ts +++ b/sdk/src/services/ConfigurationService.ts @@ -17,7 +17,7 @@ export class ConfigurationService { */ public getWalletsConfiguration(): CancelablePromise<{ result: { - type: ('local' | 'aws-kms' | 'gcp-kms' | 'smart:aws-kms' | 'smart:gcp-kms' | 'smart:local'); + type: ('local' | 'aws-kms' | 'gcp-kms' | 'smart:aws-kms' | 'smart:gcp-kms' | 'smart:local' | 'circle' | 'smart:circle'); awsAccessKeyId: (string | null); awsRegion: (string | null); gcpApplicationProjectId: (string | null); @@ -55,10 +55,12 @@ export class ConfigurationService { gcpKmsKeyRingId: string; gcpApplicationCredentialEmail: string; gcpApplicationCredentialPrivateKey: string; + } | { + circleApiKey: string; }), ): CancelablePromise<{ result: { - type: ('local' | 'aws-kms' | 'gcp-kms' | 'smart:aws-kms' | 'smart:gcp-kms' | 'smart:local'); + type: ('local' | 'aws-kms' | 'gcp-kms' | 'smart:aws-kms' | 'smart:gcp-kms' | 'smart:local' | 'circle' | 'smart:circle'); awsAccessKeyId: (string | null); awsRegion: (string | null); gcpApplicationProjectId: (string | null); @@ -329,7 +331,8 @@ export class ConfigurationService { */ public getAuthConfiguration(): CancelablePromise<{ result: { - domain: string; + authDomain: string; + mtlsCertificate: (string | null); }; }> { return this.httpRequest.request({ @@ -351,12 +354,21 @@ export class ConfigurationService { * @throws ApiError */ public updateAuthConfiguration( - requestBody: { - domain: string; + requestBody?: { + authDomain?: string; + /** + * Engine certificate used for outbound mTLS requests. Must provide the full certificate chain. + */ + mtlsCertificate?: string; + /** + * Engine private key used for outbound mTLS requests. + */ + mtlsPrivateKey?: string; }, ): CancelablePromise<{ result: { - domain: string; + authDomain: string; + mtlsCertificate: (string | null); }; }> { return this.httpRequest.request({ diff --git a/sdk/src/services/ContractEventsService.ts b/sdk/src/services/ContractEventsService.ts index a15c9eab..d799dd2e 100644 --- a/sdk/src/services/ContractEventsService.ts +++ b/sdk/src/services/ContractEventsService.ts @@ -23,8 +23,8 @@ export class ContractEventsService { public getAllEvents( chain: string, contractAddress: string, - fromBlock?: (number | string), - toBlock?: (number | string), + fromBlock?: (number | 'latest' | 'earliest' | 'pending' | 'safe' | 'finalized'), + toBlock?: (number | 'latest' | 'earliest' | 'pending' | 'safe' | 'finalized'), order?: ('asc' | 'desc'), ): CancelablePromise<{ result: Array>; @@ -63,8 +63,8 @@ export class ContractEventsService { contractAddress: string, requestBody: { eventName: string; - fromBlock?: (number | string); - toBlock?: (number | string); + fromBlock?: (number | 'latest' | 'earliest' | 'pending' | 'safe' | 'finalized'); + toBlock?: (number | 'latest' | 'earliest' | 'pending' | 'safe' | 'finalized'); order?: ('asc' | 'desc'); filters?: any; }, diff --git a/sdk/src/services/WalletCredentialsService.ts b/sdk/src/services/WalletCredentialsService.ts new file mode 100644 index 00000000..1ac32e4a --- /dev/null +++ b/sdk/src/services/WalletCredentialsService.ts @@ -0,0 +1,125 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import type { BaseHttpRequest } from '../core/BaseHttpRequest'; + +export class WalletCredentialsService { + + constructor(public readonly httpRequest: BaseHttpRequest) {} + + /** + * Create wallet credentials + * Create a new set of wallet credentials. + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public createWalletCredential( + requestBody: { + label: string; + type: 'circle'; + /** + * 32-byte hex string. If not provided, a random one will be generated. + */ + entitySecret?: string; + /** + * Whether this credential should be set as the default for its type. Only one credential can be default per type. + */ + isDefault?: boolean; + }, + ): CancelablePromise<{ + result: { + id: string; + type: string; + label: string; + isDefault: (boolean | null); + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/wallet-credentials', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Get all wallet credentials + * Get all wallet credentials with pagination. + * @param page Specify the page number. + * @param limit Specify the number of results to return per page. + * @returns any Default Response + * @throws ApiError + */ + public getAllWalletCredentials( + page: number = 1, + limit: number = 100, + ): CancelablePromise<{ + result: Array<{ + id: string; + type: string; + label: (string | null); + isDefault: (boolean | null); + createdAt: string; + updatedAt: string; + }>; + }> { + return this.httpRequest.request({ + method: 'GET', + url: '/wallet-credentials', + query: { + 'page': page, + 'limit': limit, + }, + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Get wallet credential + * Get a wallet credential by ID. + * @param id The ID of the wallet credential to get. + * @returns any Default Response + * @throws ApiError + */ + public getWalletCredential( + id: string, + ): CancelablePromise<{ + result: { + id: string; + type: string; + label: (string | null); + isDefault: boolean; + createdAt: string; + updatedAt: string; + deletedAt: (string | null); + }; + }> { + return this.httpRequest.request({ + method: 'GET', + url: '/wallet-credentials/{id}', + path: { + 'id': id, + }, + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + +} diff --git a/sdk/src/services/WebhooksService.ts b/sdk/src/services/WebhooksService.ts index 88604c53..5e6a0b90 100644 --- a/sdk/src/services/WebhooksService.ts +++ b/sdk/src/services/WebhooksService.ts @@ -38,7 +38,7 @@ export class WebhooksService { } /** - * Create a webhook + * Create webhook * Create a webhook to call when a specific Engine event occurs. * @param requestBody * @returns any Default Response diff --git a/src/server/routes/system/health.ts b/src/server/routes/system/health.ts index f358ad84..ed3718df 100644 --- a/src/server/routes/system/health.ts +++ b/src/server/routes/system/health.ts @@ -11,7 +11,8 @@ type EngineFeature = | "CONTRACT_SUBSCRIPTIONS" | "IP_ALLOWLIST" | "HETEROGENEOUS_WALLET_TYPES" - | "SMART_BACKEND_WALLETS"; + | "SMART_BACKEND_WALLETS" + | "WALLET_CREDENTIALS"; const ReplySchema = Type.Object({ db: Type.Boolean(), @@ -26,6 +27,7 @@ const ReplySchema = Type.Object({ Type.Literal("IP_ALLOWLIST"), Type.Literal("HETEROGENEOUS_WALLET_TYPES"), Type.Literal("SMART_BACKEND_WALLETS"), + Type.Literal("WALLET_CREDENTIALS"), ]), ), clientId: Type.String(), @@ -77,6 +79,7 @@ const getFeatures = (): EngineFeature[] => { "HETEROGENEOUS_WALLET_TYPES", "CONTRACT_SUBSCRIPTIONS", "SMART_BACKEND_WALLETS", + "WALLET_CREDENTIALS", ]; if (env.ENABLE_KEYPAIR_AUTH) features.push("KEYPAIR_AUTH"); From 07d1d7d3b5c28c5977d2b7457bf6ba33c22692d6 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Sat, 8 Feb 2025 14:44:19 +0530 Subject: [PATCH 09/10] [feat] update credentials (#845) * remove auto creation and register of circle entity secret * Update wallet credentials * remove uneeded import * reuse schema --- src/server/routes/index.ts | 2 + .../routes/wallet-credentials/create.ts | 13 +-- src/server/routes/wallet-credentials/get.ts | 9 +- .../routes/wallet-credentials/update.ts | 106 ++++++++++++++++++ .../create-wallet-credential.ts | 44 +------- .../get-wallet-credential.ts | 2 +- .../update-wallet-credential.ts | 55 +++++++++ 7 files changed, 179 insertions(+), 52 deletions(-) create mode 100644 src/server/routes/wallet-credentials/update.ts create mode 100644 src/shared/db/wallet-credentials/update-wallet-credential.ts diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 370ab9e6..be174a78 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -116,6 +116,7 @@ import { sendTransactionBatchAtomicRoute } from "./backend-wallet/send-transacti import { createWalletCredentialRoute } from "./wallet-credentials/create"; import { getWalletCredentialRoute } from "./wallet-credentials/get"; import { getAllWalletCredentialsRoute } from "./wallet-credentials/get-all"; +import { updateWalletCredentialRoute } from "./wallet-credentials/update"; export async function withRoutes(fastify: FastifyInstance) { // Backend Wallets @@ -144,6 +145,7 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(createWalletCredentialRoute); await fastify.register(getWalletCredentialRoute); await fastify.register(getAllWalletCredentialsRoute); + await fastify.register(updateWalletCredentialRoute); // Configuration await fastify.register(getWalletsConfiguration); diff --git a/src/server/routes/wallet-credentials/create.ts b/src/server/routes/wallet-credentials/create.ts index 53ec65f3..5afbffc9 100644 --- a/src/server/routes/wallet-credentials/create.ts +++ b/src/server/routes/wallet-credentials/create.ts @@ -9,13 +9,11 @@ import { createCustomError } from "../../middleware/error"; const requestBodySchema = Type.Object({ label: Type.String(), type: Type.Literal("circle"), - entitySecret: Type.Optional( - Type.String({ - description: - "32-byte hex string. If not provided, a random one will be generated.", - pattern: "^[0-9a-fA-F]{64}$", - }), - ), + entitySecret: Type.String({ + description: + "32-byte hex string. Consult https://developers.circle.com/w3s/entity-secret-management to create and register an entity secret.", + pattern: "^[0-9a-fA-F]{64}$", + }), isDefault: Type.Optional( Type.Boolean({ description: @@ -98,6 +96,7 @@ export const createWalletCredentialRoute = async (fastify: FastifyInstance) => { "WALLET_CREDENTIAL_ERROR", ); } + throw e; } }, }); diff --git a/src/server/routes/wallet-credentials/get.ts b/src/server/routes/wallet-credentials/get.ts index c0141efe..f132c08a 100644 --- a/src/server/routes/wallet-credentials/get.ts +++ b/src/server/routes/wallet-credentials/get.ts @@ -1,7 +1,10 @@ import { Type, type Static } from "@sinclair/typebox"; import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; -import { getWalletCredential, WalletCredentialsError } from "../../../shared/db/wallet-credentials/get-wallet-credential"; +import { + getWalletCredential, + WalletCredentialsError, +} from "../../../shared/db/wallet-credentials/get-wallet-credential"; import { createCustomError } from "../../middleware/error"; import { standardResponseSchema } from "../../schemas/shared-api-schemas"; @@ -16,7 +19,7 @@ const responseSchema = Type.Object({ id: Type.String(), type: Type.String(), label: Type.Union([Type.String(), Type.Null()]), - isDefault: Type.Boolean(), + isDefault: Type.Union([Type.Boolean(), Type.Null()]), createdAt: Type.String(), updatedAt: Type.String(), deletedAt: Type.Union([Type.String(), Type.Null()]), @@ -82,4 +85,4 @@ export async function getWalletCredentialRoute(fastify: FastifyInstance) { } }, }); -} \ No newline at end of file +} diff --git a/src/server/routes/wallet-credentials/update.ts b/src/server/routes/wallet-credentials/update.ts new file mode 100644 index 00000000..e7a4eb50 --- /dev/null +++ b/src/server/routes/wallet-credentials/update.ts @@ -0,0 +1,106 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { updateWalletCredential } from "../../../shared/db/wallet-credentials/update-wallet-credential"; +import { WalletCredentialsError } from "../../../shared/db/wallet-credentials/get-wallet-credential"; +import { createCustomError } from "../../middleware/error"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; + +const ParamsSchema = Type.Object({ + id: Type.String({ + description: "The ID of the wallet credential to update.", + }), +}); + +const requestBodySchema = Type.Object({ + label: Type.Optional(Type.String()), + isDefault: Type.Optional( + Type.Boolean({ + description: + "Whether this credential should be set as the default for its type. Only one credential can be default per type.", + }), + ), + entitySecret: Type.Optional( + Type.String({ + description: + "32-byte hex string. Consult https://developers.circle.com/w3s/entity-secret-management to create and register an entity secret.", + pattern: "^[0-9a-fA-F]{64}$", + }), + ), +}); + +const responseSchema = Type.Object({ + result: Type.Object({ + id: Type.String(), + type: Type.String(), + label: Type.Union([Type.String(), Type.Null()]), + isDefault: Type.Union([Type.Boolean(), Type.Null()]), + createdAt: Type.String(), + updatedAt: Type.String(), + }), +}); + +responseSchema.example = { + result: { + id: "123e4567-e89b-12d3-a456-426614174000", + type: "circle", + label: "My Updated Circle Credential", + isDefault: true, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, +}; + +export async function updateWalletCredentialRoute(fastify: FastifyInstance) { + fastify.route<{ + Params: Static; + Body: Static; + Reply: Static; + }>({ + method: "PUT", + url: "/wallet-credentials/:id", + schema: { + summary: "Update wallet credential", + description: + "Update a wallet credential's label, default status, and entity secret.", + tags: ["Wallet Credentials"], + operationId: "updateWalletCredential", + params: ParamsSchema, + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (req, reply) => { + try { + const credential = await updateWalletCredential({ + id: req.params.id, + label: req.body.label, + isDefault: req.body.isDefault, + entitySecret: req.body.entitySecret, + }); + + reply.status(StatusCodes.OK).send({ + result: { + id: credential.id, + type: credential.type, + label: credential.label, + isDefault: credential.isDefault, + createdAt: credential.createdAt.toISOString(), + updatedAt: credential.updatedAt.toISOString(), + }, + }); + } catch (e) { + if (e instanceof WalletCredentialsError) { + throw createCustomError( + e.message, + StatusCodes.NOT_FOUND, + "WALLET_CREDENTIAL_NOT_FOUND", + ); + } + throw e; + } + }, + }); +} diff --git a/src/shared/db/wallet-credentials/create-wallet-credential.ts b/src/shared/db/wallet-credentials/create-wallet-credential.ts index db3bfad2..51b9cae7 100644 --- a/src/shared/db/wallet-credentials/create-wallet-credential.ts +++ b/src/shared/db/wallet-credentials/create-wallet-credential.ts @@ -1,16 +1,13 @@ import { encrypt } from "../../utils/crypto"; -import { registerEntitySecretCiphertext } from "@circle-fin/developer-controlled-wallets"; import { prisma } from "../client"; import { getConfig } from "../../utils/cache/get-config"; import { WalletCredentialsError } from "./get-wallet-credential"; -import { randomBytes } from "node:crypto"; -import { cirlceEntitySecretZodSchema } from "../../schemas/wallet"; // will be expanded to be a discriminated union of all supported wallet types export type CreateWalletCredentialsParams = { type: "circle"; label: string; - entitySecret?: string; + entitySecret: string; isDefault?: boolean; }; @@ -21,58 +18,23 @@ export const createWalletCredential = async ({ isDefault, }: CreateWalletCredentialsParams) => { const { walletConfiguration } = await getConfig(); - switch (type) { case "circle": { const circleApiKey = walletConfiguration.circle?.apiKey; - if (!circleApiKey) { throw new WalletCredentialsError("No Circle API Key Configured"); } - - if (entitySecret) { - const { error } = cirlceEntitySecretZodSchema.safeParse(entitySecret); - if (error) { - throw new WalletCredentialsError( - "Invalid provided entity secret for Circle", - ); - } - } - - // If entitySecret is not provided, generate a random one - const finalEntitySecret = entitySecret ?? randomBytes(32).toString("hex"); // Create the wallet credentials const walletCredentials = await prisma.walletCredentials.create({ data: { type, label, - isDefault: isDefault ?? null, + isDefault: isDefault || null, data: { - entitySecret: encrypt(finalEntitySecret), + entitySecret: encrypt(entitySecret), }, }, }); - - // try registering the entity secret. See: https://developers.circle.com/w3s/developer-controlled-create-your-first-wallet - try { - await registerEntitySecretCiphertext({ - apiKey: circleApiKey, - entitySecret: finalEntitySecret, - recoveryFileDownloadPath: "/dev/null", - }); - } catch (e: unknown) { - // If failed to registeer, permanently delete erroneously created credential - await prisma.walletCredentials.delete({ - where: { - id: walletCredentials.id, - }, - }); - - throw new WalletCredentialsError( - `Could not register Entity Secret with Circle\n${JSON.stringify(e)}`, - ); - } - return walletCredentials; } } diff --git a/src/shared/db/wallet-credentials/get-wallet-credential.ts b/src/shared/db/wallet-credentials/get-wallet-credential.ts index 21b7b8a2..f1cf5107 100644 --- a/src/shared/db/wallet-credentials/get-wallet-credential.ts +++ b/src/shared/db/wallet-credentials/get-wallet-credential.ts @@ -18,7 +18,7 @@ const walletCredentialsSchema = z.object({ data: z.object({ entitySecret: z.string(), }), - isDefault: z.boolean(), + isDefault: z.boolean().nullable(), createdAt: z.date(), updatedAt: z.date(), deletedAt: z.date().nullable(), diff --git a/src/shared/db/wallet-credentials/update-wallet-credential.ts b/src/shared/db/wallet-credentials/update-wallet-credential.ts new file mode 100644 index 00000000..018eb562 --- /dev/null +++ b/src/shared/db/wallet-credentials/update-wallet-credential.ts @@ -0,0 +1,55 @@ +import { getWalletCredential } from "./get-wallet-credential"; +import { encrypt } from "../../utils/crypto"; +import { prisma } from "../client"; +import { cirlceEntitySecretZodSchema } from "../../schemas/wallet"; + +interface UpdateWalletCredentialParams { + id: string; + label?: string; + isDefault?: boolean; + entitySecret?: string; +} + +type UpdateData = { + label?: string; + isDefault: boolean | null; + data?: { + entitySecret: string; + }; +}; + +export const updateWalletCredential = async ({ + id, + label, + isDefault, + entitySecret, +}: UpdateWalletCredentialParams) => { + // First check if credential exists + await getWalletCredential({ id }); + + // If entitySecret is provided, validate and encrypt it + const data: UpdateData = { + label, + isDefault: isDefault || null, + }; + + if (entitySecret) { + // Validate the entity secret + cirlceEntitySecretZodSchema.parse(entitySecret); + + // Only update data field if entitySecret is provided + data.data = { + entitySecret: encrypt(entitySecret), + }; + } + + // Update the credential + const updatedCredential = await prisma.walletCredentials.update({ + where: { + id, + }, + data, + }); + + return updatedCredential; +}; From 503f37e26d5e9988c7164bd4ee64f77ed5c260fc Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Tue, 18 Feb 2025 09:56:09 +0530 Subject: [PATCH 10/10] feat: Add Balance Subscriptions feature (#849) * feat: Add Balance Subscriptions feature * remove bad logs * feat: Enhance Balance Subscriptions webhook handling - Add support for creating new webhooks with optional labels - Allow using existing webhooks by ID - Validate webhook event type and revocation status - Improve error handling for webhook creation and selection * refactor: Simplify balance retrieval using thirdweb SDK - Replace manual balance fetching with `getWalletBalance` method - Support both native token and ERC20 token balance retrieval - Remove redundant contract and RPC client initialization code * generate SDK * refactor: Rename contractAddress to tokenAddress in Balance Subscriptions - Update Prisma schema, migration, and database indexes - Modify TypeScript interfaces and schemas across services and routes - Ensure consistent naming for token-related address fields - Update worker and database interaction methods * change to wallet subscriptions pattern * addressed review comments --- sdk/src/Engine.ts | 3 + sdk/src/index.ts | 1 + sdk/src/services/WalletCredentialsService.ts | 53 ++- .../services/WalletSubscriptionsService.ts | 323 ++++++++++++++++++ sdk/src/services/WebhooksService.ts | 4 +- .../migration.sql | 25 ++ src/prisma/schema.prisma | 22 ++ src/scripts/generate-sdk.ts | 4 +- src/server/middleware/admin-routes.ts | 2 + .../configuration/wallet-subscriptions/get.ts | 42 +++ .../wallet-subscriptions/update.ts | 64 ++++ src/server/routes/index.ts | 10 + src/server/routes/system/health.ts | 5 +- src/server/routes/wallet-subscriptions/add.ts | 122 +++++++ .../routes/wallet-subscriptions/delete.ts | 50 +++ .../routes/wallet-subscriptions/get-all.ts | 47 +++ .../routes/wallet-subscriptions/update.ts | 81 +++++ src/server/schemas/wallet-subscription.ts | 48 +++ .../create-wallet-subscription.ts | 55 +++ .../delete-wallet-subscription.ts | 16 + .../get-all-wallet-subscriptions.ts | 27 ++ .../update-wallet-subscription.ts | 53 +++ .../schemas/wallet-subscription-conditions.ts | 53 +++ src/shared/schemas/webhooks.ts | 10 + src/worker/index.ts | 3 +- src/worker/queues/send-webhook-queue.ts | 26 +- .../queues/wallet-subscription-queue.ts | 14 + src/worker/tasks/send-webhook-worker.ts | 15 +- .../tasks/wallet-subscription-worker.ts | 155 +++++++++ .../wallet-subscription-worker.test.ts | 222 ++++++++++++ 30 files changed, 1545 insertions(+), 10 deletions(-) create mode 100644 sdk/src/services/WalletSubscriptionsService.ts create mode 100644 src/prisma/migrations/20250212235511_wallet_subscriptions/migration.sql create mode 100644 src/server/routes/configuration/wallet-subscriptions/get.ts create mode 100644 src/server/routes/configuration/wallet-subscriptions/update.ts create mode 100644 src/server/routes/wallet-subscriptions/add.ts create mode 100644 src/server/routes/wallet-subscriptions/delete.ts create mode 100644 src/server/routes/wallet-subscriptions/get-all.ts create mode 100644 src/server/routes/wallet-subscriptions/update.ts create mode 100644 src/server/schemas/wallet-subscription.ts create mode 100644 src/shared/db/wallet-subscriptions/create-wallet-subscription.ts create mode 100644 src/shared/db/wallet-subscriptions/delete-wallet-subscription.ts create mode 100644 src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts create mode 100644 src/shared/db/wallet-subscriptions/update-wallet-subscription.ts create mode 100644 src/shared/schemas/wallet-subscription-conditions.ts create mode 100644 src/worker/queues/wallet-subscription-queue.ts create mode 100644 src/worker/tasks/wallet-subscription-worker.ts create mode 100644 tests/e2e/tests/workers/wallet-subscription-worker.test.ts diff --git a/sdk/src/Engine.ts b/sdk/src/Engine.ts index 72dd69d9..dba4af76 100644 --- a/sdk/src/Engine.ts +++ b/sdk/src/Engine.ts @@ -31,6 +31,7 @@ import { PermissionsService } from './services/PermissionsService'; import { RelayerService } from './services/RelayerService'; import { TransactionService } from './services/TransactionService'; import { WalletCredentialsService } from './services/WalletCredentialsService'; +import { WalletSubscriptionsService } from './services/WalletSubscriptionsService'; import { WebhooksService } from './services/WebhooksService'; type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest; @@ -62,6 +63,7 @@ class EngineLogic { public readonly relayer: RelayerService; public readonly transaction: TransactionService; public readonly walletCredentials: WalletCredentialsService; + public readonly walletSubscriptions: WalletSubscriptionsService; public readonly webhooks: WebhooksService; public readonly request: BaseHttpRequest; @@ -104,6 +106,7 @@ class EngineLogic { this.relayer = new RelayerService(this.request); this.transaction = new TransactionService(this.request); this.walletCredentials = new WalletCredentialsService(this.request); + this.walletSubscriptions = new WalletSubscriptionsService(this.request); this.webhooks = new WebhooksService(this.request); } } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 9a6e7cda..992231be 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -35,4 +35,5 @@ export { PermissionsService } from './services/PermissionsService'; export { RelayerService } from './services/RelayerService'; export { TransactionService } from './services/TransactionService'; export { WalletCredentialsService } from './services/WalletCredentialsService'; +export { WalletSubscriptionsService } from './services/WalletSubscriptionsService'; export { WebhooksService } from './services/WebhooksService'; diff --git a/sdk/src/services/WalletCredentialsService.ts b/sdk/src/services/WalletCredentialsService.ts index 1ac32e4a..bb973fc7 100644 --- a/sdk/src/services/WalletCredentialsService.ts +++ b/sdk/src/services/WalletCredentialsService.ts @@ -21,9 +21,9 @@ export class WalletCredentialsService { label: string; type: 'circle'; /** - * 32-byte hex string. If not provided, a random one will be generated. + * 32-byte hex string. Consult https://developers.circle.com/w3s/entity-secret-management to create and register an entity secret. */ - entitySecret?: string; + entitySecret: string; /** * Whether this credential should be set as the default for its type. Only one credential can be default per type. */ @@ -102,7 +102,7 @@ export class WalletCredentialsService { id: string; type: string; label: (string | null); - isDefault: boolean; + isDefault: (boolean | null); createdAt: string; updatedAt: string; deletedAt: (string | null); @@ -122,4 +122,51 @@ export class WalletCredentialsService { }); } + /** + * Update wallet credential + * Update a wallet credential's label, default status, and entity secret. + * @param id The ID of the wallet credential to update. + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public updateWalletCredential( + id: string, + requestBody?: { + label?: string; + /** + * Whether this credential should be set as the default for its type. Only one credential can be default per type. + */ + isDefault?: boolean; + /** + * 32-byte hex string. Consult https://developers.circle.com/w3s/entity-secret-management to create and register an entity secret. + */ + entitySecret?: string; + }, + ): CancelablePromise<{ + result: { + id: string; + type: string; + label: (string | null); + isDefault: (boolean | null); + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'PUT', + url: '/wallet-credentials/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + } diff --git a/sdk/src/services/WalletSubscriptionsService.ts b/sdk/src/services/WalletSubscriptionsService.ts new file mode 100644 index 00000000..b699eb88 --- /dev/null +++ b/sdk/src/services/WalletSubscriptionsService.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import type { BaseHttpRequest } from '../core/BaseHttpRequest'; + +export class WalletSubscriptionsService { + + constructor(public readonly httpRequest: BaseHttpRequest) {} + + /** + * Get wallet subscriptions + * Get all wallet subscriptions. + * @param page Specify the page number. + * @param limit Specify the number of results to return per page. + * @returns any Default Response + * @throws ApiError + */ + public getAllWalletSubscriptions( + page: number = 1, + limit: number = 100, + ): CancelablePromise<{ + result: Array<{ + id: string; + /** + * The chain ID of the subscription. + */ + chainId: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }>; + }> { + return this.httpRequest.request({ + method: 'GET', + url: '/wallet-subscriptions/get-all', + path: { + 'page': page, + 'limit': limit, + }, + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Add wallet subscription + * Subscribe to wallet conditions. + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public addWalletSubscription( + requestBody?: ({ + /** + * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + */ + chain: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + } & ({ + /** + * Webhook URL to create a new webhook + */ + webhookUrl: string; + /** + * Optional label for the webhook when creating a new one + */ + webhookLabel?: string; + } | { + /** + * ID of an existing webhook to use + */ + webhookId: number; + })), + ): CancelablePromise<{ + result: { + id: string; + /** + * The chain ID of the subscription. + */ + chainId: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/wallet-subscriptions', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Update wallet subscription + * Update an existing wallet subscription. + * @param subscriptionId The ID of the wallet subscription to update. + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public updateWalletSubscription( + subscriptionId: string, + requestBody?: { + /** + * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + */ + chain?: string; + /** + * A contract or wallet address + */ + walletAddress?: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions?: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhookId?: (number | null); + }, + ): CancelablePromise<{ + result: { + id: string; + /** + * The chain ID of the subscription. + */ + chainId: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/wallet-subscriptions/{subscriptionId}', + path: { + 'subscriptionId': subscriptionId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Delete wallet subscription + * Delete an existing wallet subscription. + * @param subscriptionId The ID of the wallet subscription to update. + * @returns any Default Response + * @throws ApiError + */ + public deleteWalletSubscription( + subscriptionId: string, + ): CancelablePromise<{ + result: { + id: string; + /** + * The chain ID of the subscription. + */ + chainId: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'DELETE', + url: '/wallet-subscriptions/{subscriptionId}', + path: { + 'subscriptionId': subscriptionId, + }, + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + +} diff --git a/sdk/src/services/WebhooksService.ts b/sdk/src/services/WebhooksService.ts index 5e6a0b90..d06835db 100644 --- a/sdk/src/services/WebhooksService.ts +++ b/sdk/src/services/WebhooksService.ts @@ -51,7 +51,7 @@ export class WebhooksService { */ url: string; name?: string; - eventType: ('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription'); + eventType: ('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription' | 'wallet_subscription'); }, ): CancelablePromise<{ result: { @@ -113,7 +113,7 @@ export class WebhooksService { * @throws ApiError */ public getEventTypes(): CancelablePromise<{ - result: Array<('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription')>; + result: Array<('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription' | 'wallet_subscription')>; }> { return this.httpRequest.request({ method: 'GET', diff --git a/src/prisma/migrations/20250212235511_wallet_subscriptions/migration.sql b/src/prisma/migrations/20250212235511_wallet_subscriptions/migration.sql new file mode 100644 index 00000000..92fdb940 --- /dev/null +++ b/src/prisma/migrations/20250212235511_wallet_subscriptions/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "configuration" ADD COLUMN "walletSubscriptionsCronSchedule" TEXT; + +-- CreateTable +CREATE TABLE "wallet_subscriptions" ( + "id" TEXT NOT NULL, + "chainId" TEXT NOT NULL, + "walletAddress" TEXT NOT NULL, + "conditions" JSONB[], + "webhookId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "wallet_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "wallet_subscriptions_chainId_idx" ON "wallet_subscriptions"("chainId"); + +-- CreateIndex +CREATE INDEX "wallet_subscriptions_walletAddress_idx" ON "wallet_subscriptions"("walletAddress"); + +-- AddForeignKey +ALTER TABLE "wallet_subscriptions" ADD CONSTRAINT "wallet_subscriptions_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "webhooks"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 1c4a473c..602edf6a 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -29,6 +29,8 @@ model Configuration { cursorDelaySeconds Int @default(2) @map("cursorDelaySeconds") contractSubscriptionsRetryDelaySeconds String @default("10") @map("contractSubscriptionsRetryDelaySeconds") + walletSubscriptionsCronSchedule String? @map("walletSubscriptionsCronSchedule") + // Wallet provider specific configurations, non-credential walletProviderConfigs Json @default("{}") @map("walletProviderConfigs") /// Eg: { "aws": { "defaultAwsRegion": "us-east-1" }, "gcp": { "defaultGcpKmsLocationId": "us-east1-b" } } @@ -221,6 +223,7 @@ model Webhooks { updatedAt DateTime @updatedAt @map("updatedAt") revokedAt DateTime? @map("revokedAt") ContractSubscriptions ContractSubscriptions[] + WalletSubscriptions WalletSubscriptions[] @@map("webhooks") } @@ -286,6 +289,25 @@ model ContractEventLogs { @@map("contract_event_logs") } +model WalletSubscriptions { + id String @id @default(uuid()) + chainId String + walletAddress String + + conditions Json[] // Array of condition objects with discriminated union type + + webhookId Int? + webhook Webhooks? @relation(fields: [webhookId], references: [id], onDelete: SetNull) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([chainId]) + @@index([walletAddress]) + @@map("wallet_subscriptions") +} + model ContractTransactionReceipts { chainId String blockNumber Int diff --git a/src/scripts/generate-sdk.ts b/src/scripts/generate-sdk.ts index 759dd8f0..13c3ca5f 100644 --- a/src/scripts/generate-sdk.ts +++ b/src/scripts/generate-sdk.ts @@ -142,7 +142,9 @@ export class Engine extends EngineLogic { const ercServices: string[] = ["erc20", "erc721", "erc1155"]; for (const tag of ercServices) { - const fileName = `${tag.charAt(0).toUpperCase() + tag.slice(1)}Service.ts`; + const fileName = `${ + tag.charAt(0).toUpperCase() + tag.slice(1) + }Service.ts`; const filePath = path.join(servicesDir, fileName); const originalCode = fs.readFileSync(filePath, "utf-8"); diff --git a/src/server/middleware/admin-routes.ts b/src/server/middleware/admin-routes.ts index f503a5b9..4fd700ab 100644 --- a/src/server/middleware/admin-routes.ts +++ b/src/server/middleware/admin-routes.ts @@ -15,6 +15,7 @@ import { ProcessTransactionReceiptsQueue } from "../../worker/queues/process-tra import { PruneTransactionsQueue } from "../../worker/queues/prune-transactions-queue"; import { SendTransactionQueue } from "../../worker/queues/send-transaction-queue"; import { SendWebhookQueue } from "../../worker/queues/send-webhook-queue"; +import { WalletSubscriptionQueue } from "../../worker/queues/wallet-subscription-queue"; export const ADMIN_QUEUES_BASEPATH = "/admin/queues"; const ADMIN_ROUTES_USERNAME = "admin"; @@ -31,6 +32,7 @@ const QUEUES: Queue[] = [ PruneTransactionsQueue.q, NonceResyncQueue.q, NonceHealthCheckQueue.q, + WalletSubscriptionQueue.q, ]; export const withAdminRoutes = async (fastify: FastifyInstance) => { diff --git a/src/server/routes/configuration/wallet-subscriptions/get.ts b/src/server/routes/configuration/wallet-subscriptions/get.ts new file mode 100644 index 00000000..19d1932a --- /dev/null +++ b/src/server/routes/configuration/wallet-subscriptions/get.ts @@ -0,0 +1,42 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { getConfig } from "../../../../shared/utils/cache/get-config"; +import { standardResponseSchema } from "../../../schemas/shared-api-schemas"; + +const responseBodySchema = Type.Object({ + result: Type.Object({ + walletSubscriptionsCronSchedule: Type.String(), + }), +}); + +export async function getWalletSubscriptionsConfiguration( + fastify: FastifyInstance, +) { + fastify.route<{ + Reply: Static; + }>({ + method: "GET", + url: "/configuration/wallet-subscriptions", + schema: { + summary: "Get wallet subscriptions configuration", + description: + "Get wallet subscriptions configuration including cron schedule", + tags: ["Configuration"], + operationId: "getWalletSubscriptionsConfiguration", + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseBodySchema, + }, + }, + handler: async (_req, res) => { + const config = await getConfig(false); + res.status(StatusCodes.OK).send({ + result: { + walletSubscriptionsCronSchedule: + config.walletSubscriptionsCronSchedule || "*/30 * * * * *", + }, + }); + }, + }); +} diff --git a/src/server/routes/configuration/wallet-subscriptions/update.ts b/src/server/routes/configuration/wallet-subscriptions/update.ts new file mode 100644 index 00000000..94f35813 --- /dev/null +++ b/src/server/routes/configuration/wallet-subscriptions/update.ts @@ -0,0 +1,64 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { updateConfiguration } from "../../../../shared/db/configuration/update-configuration"; +import { getConfig } from "../../../../shared/utils/cache/get-config"; +import { isValidCron } from "../../../../shared/utils/cron/is-valid-cron"; +import { createCustomError } from "../../../middleware/error"; +import { standardResponseSchema } from "../../../schemas/shared-api-schemas"; + +const requestBodySchema = Type.Object({ + walletSubscriptionsCronSchedule: Type.String({ + description: + "Cron expression for wallet subscription checks. It should be in the format of 'ss mm hh * * *' where ss is seconds, mm is minutes and hh is hours. Seconds should not be '*' or less than 10", + default: "*/30 * * * * *", + }), +}); + +const responseBodySchema = Type.Object({ + result: Type.Object({ + walletSubscriptionsCronSchedule: Type.String(), + }), +}); + +export async function updateWalletSubscriptionsConfiguration( + fastify: FastifyInstance, +) { + fastify.route<{ + Body: Static; + }>({ + method: "POST", + url: "/configuration/wallet-subscriptions", + schema: { + summary: "Update wallet subscriptions configuration", + description: + "Update wallet subscriptions configuration including cron schedule", + tags: ["Configuration"], + operationId: "updateWalletSubscriptionsConfiguration", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseBodySchema, + }, + }, + handler: async (req, res) => { + const { walletSubscriptionsCronSchedule } = req.body; + if (isValidCron(walletSubscriptionsCronSchedule) === false) { + throw createCustomError( + "Invalid cron expression.", + StatusCodes.BAD_REQUEST, + "INVALID_CRON", + ); + } + + await updateConfiguration({ walletSubscriptionsCronSchedule }); + const config = await getConfig(false); + res.status(StatusCodes.OK).send({ + result: { + walletSubscriptionsCronSchedule: + config.walletSubscriptionsCronSchedule, + }, + }); + }, + }); +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index be174a78..4a74394e 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -117,6 +117,10 @@ import { createWalletCredentialRoute } from "./wallet-credentials/create"; import { getWalletCredentialRoute } from "./wallet-credentials/get"; import { getAllWalletCredentialsRoute } from "./wallet-credentials/get-all"; import { updateWalletCredentialRoute } from "./wallet-credentials/update"; +import { getAllWalletSubscriptionsRoute } from "./wallet-subscriptions/get-all"; +import { addWalletSubscriptionRoute } from "./wallet-subscriptions/add"; +import { updateWalletSubscriptionRoute } from "./wallet-subscriptions/update"; +import { deleteWalletSubscriptionRoute } from "./wallet-subscriptions/delete"; export async function withRoutes(fastify: FastifyInstance) { // Backend Wallets @@ -268,6 +272,12 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(getContractIndexedBlockRange); await fastify.register(getLatestBlock); + // Wallet Subscriptions + await fastify.register(getAllWalletSubscriptionsRoute); + await fastify.register(addWalletSubscriptionRoute); + await fastify.register(updateWalletSubscriptionRoute); + await fastify.register(deleteWalletSubscriptionRoute); + // Contract Transactions // @deprecated await fastify.register(getContractTransactionReceipts); diff --git a/src/server/routes/system/health.ts b/src/server/routes/system/health.ts index ed3718df..3613c64d 100644 --- a/src/server/routes/system/health.ts +++ b/src/server/routes/system/health.ts @@ -12,7 +12,8 @@ type EngineFeature = | "IP_ALLOWLIST" | "HETEROGENEOUS_WALLET_TYPES" | "SMART_BACKEND_WALLETS" - | "WALLET_CREDENTIALS"; + | "WALLET_CREDENTIALS" + | "BALANCE_SUBSCRIPTIONS"; const ReplySchema = Type.Object({ db: Type.Boolean(), @@ -28,6 +29,7 @@ const ReplySchema = Type.Object({ Type.Literal("HETEROGENEOUS_WALLET_TYPES"), Type.Literal("SMART_BACKEND_WALLETS"), Type.Literal("WALLET_CREDENTIALS"), + Type.Literal("BALANCE_SUBSCRIPTIONS"), ]), ), clientId: Type.String(), @@ -80,6 +82,7 @@ const getFeatures = (): EngineFeature[] => { "CONTRACT_SUBSCRIPTIONS", "SMART_BACKEND_WALLETS", "WALLET_CREDENTIALS", + "BALANCE_SUBSCRIPTIONS", ]; if (env.ENABLE_KEYPAIR_AUTH) features.push("KEYPAIR_AUTH"); diff --git a/src/server/routes/wallet-subscriptions/add.ts b/src/server/routes/wallet-subscriptions/add.ts new file mode 100644 index 00000000..9239011c --- /dev/null +++ b/src/server/routes/wallet-subscriptions/add.ts @@ -0,0 +1,122 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { createWalletSubscription } from "../../../shared/db/wallet-subscriptions/create-wallet-subscription"; +import { insertWebhook } from "../../../shared/db/webhooks/create-webhook"; +import { getWebhook } from "../../../shared/db/webhooks/get-webhook"; +import { WebhooksEventTypes } from "../../../shared/schemas/webhooks"; +import { createCustomError } from "../../middleware/error"; +import { AddressSchema } from "../../schemas/address"; +import { chainIdOrSlugSchema } from "../../schemas/chain"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; +import { getChainIdFromChain } from "../../utils/chain"; +import { isValidWebhookUrl } from "../../utils/validator"; +import { + walletSubscriptionSchema, + toWalletSubscriptionSchema, +} from "../../schemas/wallet-subscription"; +import { WalletConditionsSchema } from "../../../shared/schemas/wallet-subscription-conditions"; + +const webhookUrlSchema = Type.Object({ + webhookUrl: Type.String({ + description: "Webhook URL to create a new webhook", + examples: ["https://example.com/webhook"], + }), + webhookLabel: Type.Optional( + Type.String({ + description: "Optional label for the webhook when creating a new one", + examples: ["My Wallet Subscription Webhook"], + minLength: 3, + }), + ), +}); + +const webhookIdSchema = Type.Object({ + webhookId: Type.Integer({ + description: "ID of an existing webhook to use", + }), +}); + +const requestBodySchema = Type.Intersect([ + Type.Object({ + chain: chainIdOrSlugSchema, + walletAddress: AddressSchema, + conditions: WalletConditionsSchema, + }), + Type.Optional(Type.Union([webhookUrlSchema, webhookIdSchema])), +]); + +const responseSchema = Type.Object({ + result: walletSubscriptionSchema, +}); + +export async function addWalletSubscriptionRoute(fastify: FastifyInstance) { + fastify.route<{ + Body: Static; + Reply: Static; + }>({ + method: "POST", + url: "/wallet-subscriptions", + schema: { + summary: "Add wallet subscription", + description: "Subscribe to wallet conditions.", + tags: ["Wallet-Subscriptions"], + operationId: "addWalletSubscription", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { chain, walletAddress, conditions } = request.body; + const chainId = await getChainIdFromChain(chain); + + let finalWebhookId: number | undefined; + + if ("webhookUrl" in request.body) { + const { webhookUrl, webhookLabel } = request.body; + + if (!isValidWebhookUrl(webhookUrl)) { + throw createCustomError( + "Invalid webhook URL. Make sure it starts with 'https://'.", + StatusCodes.BAD_REQUEST, + "BAD_REQUEST", + ); + } + + const webhook = await insertWebhook({ + url: webhookUrl, + name: webhookLabel, + eventType: WebhooksEventTypes.WALLET_SUBSCRIPTION, + }); + + finalWebhookId = webhook.id; + } else { + const { webhookId } = request.body; + const webhook = await getWebhook(webhookId); + + if (!webhook || webhook.revokedAt) { + throw createCustomError( + "Invalid webhook ID or webhook has been revoked.", + StatusCodes.BAD_REQUEST, + "BAD_REQUEST", + ); + } + + finalWebhookId = webhookId; + } + + const subscription = await createWalletSubscription({ + chainId: chainId.toString(), + walletAddress, + conditions, + webhookId: finalWebhookId, + }); + + reply.status(StatusCodes.OK).send({ + result: toWalletSubscriptionSchema(subscription), + }); + }, + }); +} diff --git a/src/server/routes/wallet-subscriptions/delete.ts b/src/server/routes/wallet-subscriptions/delete.ts new file mode 100644 index 00000000..94708710 --- /dev/null +++ b/src/server/routes/wallet-subscriptions/delete.ts @@ -0,0 +1,50 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { deleteWalletSubscription } from "../../../shared/db/wallet-subscriptions/delete-wallet-subscription"; +import { + walletSubscriptionSchema, + toWalletSubscriptionSchema, +} from "../../schemas/wallet-subscription"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; + +const responseSchema = Type.Object({ + result: walletSubscriptionSchema, +}); + +const paramsSchema = Type.Object({ + subscriptionId: Type.String({ + description: "The ID of the wallet subscription to update.", + }), +}); + + +export async function deleteWalletSubscriptionRoute(fastify: FastifyInstance) { + fastify.route<{ + Reply: Static; + Params: Static; + }>({ + method: "DELETE", + url: "/wallet-subscriptions/:subscriptionId", + schema: { + summary: "Delete wallet subscription", + description: "Delete an existing wallet subscription.", + tags: ["Wallet-Subscriptions"], + operationId: "deleteWalletSubscription", + params: paramsSchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { subscriptionId } = request.params; + + const subscription = await deleteWalletSubscription(subscriptionId); + + reply.status(StatusCodes.OK).send({ + result: toWalletSubscriptionSchema(subscription), + }); + }, + }); +} diff --git a/src/server/routes/wallet-subscriptions/get-all.ts b/src/server/routes/wallet-subscriptions/get-all.ts new file mode 100644 index 00000000..9f562163 --- /dev/null +++ b/src/server/routes/wallet-subscriptions/get-all.ts @@ -0,0 +1,47 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { getAllWalletSubscriptions } from "../../../shared/db/wallet-subscriptions/get-all-wallet-subscriptions"; +import { + walletSubscriptionSchema, + toWalletSubscriptionSchema, +} from "../../schemas/wallet-subscription"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; +import { PaginationSchema } from "../../schemas/pagination"; + +const responseSchema = Type.Object({ + result: Type.Array(walletSubscriptionSchema), +}); + +export async function getAllWalletSubscriptionsRoute(fastify: FastifyInstance) { + fastify.route<{ + Reply: Static; + Params: Static; + }>({ + method: "GET", + url: "/wallet-subscriptions/get-all", + schema: { + params: PaginationSchema, + summary: "Get wallet subscriptions", + description: "Get all wallet subscriptions.", + tags: ["Wallet-Subscriptions"], + operationId: "getAllWalletSubscriptions", + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { page, limit } = request.params; + + const subscriptions = await getAllWalletSubscriptions({ + page, + limit, + }); + + reply.status(StatusCodes.OK).send({ + result: subscriptions.map(toWalletSubscriptionSchema), + }); + }, + }); +} diff --git a/src/server/routes/wallet-subscriptions/update.ts b/src/server/routes/wallet-subscriptions/update.ts new file mode 100644 index 00000000..c4030dcb --- /dev/null +++ b/src/server/routes/wallet-subscriptions/update.ts @@ -0,0 +1,81 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { updateWalletSubscription } from "../../../shared/db/wallet-subscriptions/update-wallet-subscription"; +import { WalletConditionsSchema } from "../../../shared/schemas/wallet-subscription-conditions"; +import { AddressSchema } from "../../schemas/address"; +import { chainIdOrSlugSchema } from "../../schemas/chain"; +import { + walletSubscriptionSchema, + toWalletSubscriptionSchema, +} from "../../schemas/wallet-subscription"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; +import { getChainIdFromChain } from "../../utils/chain"; + +const requestBodySchema = Type.Object({ + chain: Type.Optional(chainIdOrSlugSchema), + walletAddress: Type.Optional(AddressSchema), + conditions: Type.Optional(WalletConditionsSchema), + webhookId: Type.Optional( + Type.Union([ + Type.Integer({ + description: "The ID of an existing webhook to use.", + }), + Type.Null(), + ]), + ), +}); + +const paramsSchema = Type.Object({ + subscriptionId: Type.String({ + description: "The ID of the wallet subscription to update.", + }), +}); + +const responseSchema = Type.Object({ + result: walletSubscriptionSchema, +}); + +export async function updateWalletSubscriptionRoute(fastify: FastifyInstance) { + fastify.route<{ + Body: Static; + Reply: Static; + Params: Static; + }>({ + method: "POST", + url: "/wallet-subscriptions/:subscriptionId", + schema: { + params: paramsSchema, + summary: "Update wallet subscription", + description: "Update an existing wallet subscription.", + tags: ["Wallet-Subscriptions"], + operationId: "updateWalletSubscription", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { subscriptionId } = request.params; + + const { chain, walletAddress, conditions, webhookId } = request.body; + + // Get chainId if chain is provided + const chainId = chain ? await getChainIdFromChain(chain) : undefined; + + // Update the subscription + const subscription = await updateWalletSubscription({ + id: subscriptionId, + chainId: chainId?.toString(), + walletAddress, + conditions, + webhookId, + }); + + reply.status(StatusCodes.OK).send({ + result: toWalletSubscriptionSchema(subscription), + }); + }, + }); +} diff --git a/src/server/schemas/wallet-subscription.ts b/src/server/schemas/wallet-subscription.ts new file mode 100644 index 00000000..8930c01d --- /dev/null +++ b/src/server/schemas/wallet-subscription.ts @@ -0,0 +1,48 @@ +import { Type } from "@sinclair/typebox"; +import type { WalletSubscriptions, Webhooks } from "@prisma/client"; +import { AddressSchema } from "./address"; +import { + WalletConditionsSchema, + validateConditions, +} from "../../shared/schemas/wallet-subscription-conditions"; + +type WalletSubscriptionWithWebhook = WalletSubscriptions & { + webhook: Webhooks | null; +}; + +export const walletSubscriptionSchema = Type.Object({ + id: Type.String(), + chainId: Type.String({ + description: "The chain ID of the subscription.", + }), + walletAddress: AddressSchema, + conditions: WalletConditionsSchema, + webhook: Type.Optional( + Type.Object({ + url: Type.String(), + }), + ), + createdAt: Type.String(), + updatedAt: Type.String(), +}); + +export type WalletSubscriptionSchema = typeof walletSubscriptionSchema; + +export function toWalletSubscriptionSchema( + subscription: WalletSubscriptionWithWebhook, +) { + return { + id: subscription.id, + chainId: subscription.chainId, + walletAddress: subscription.walletAddress, + conditions: validateConditions(subscription.conditions), + webhook: + subscription.webhookId && subscription.webhook + ? { + url: subscription.webhook.url, + } + : undefined, + createdAt: subscription.createdAt.toISOString(), + updatedAt: subscription.updatedAt.toISOString(), + }; +} diff --git a/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts b/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts new file mode 100644 index 00000000..437833a4 --- /dev/null +++ b/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts @@ -0,0 +1,55 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../client"; +import type { WalletConditions } from "../../schemas/wallet-subscription-conditions"; +import { validateConditions } from "../../schemas/wallet-subscription-conditions"; +import { getWebhook } from "../webhooks/get-webhook"; +import { WebhooksEventTypes } from "../../schemas/webhooks"; + +interface CreateWalletSubscriptionParams { + chainId: string; + walletAddress: string; + conditions: WalletConditions; + webhookId?: number; +} + +export async function createWalletSubscription({ + chainId, + walletAddress, + conditions, + webhookId, +}: CreateWalletSubscriptionParams) { + // Validate conditions + const validatedConditions = validateConditions(conditions); + + if (webhookId) { + const webhook = await getWebhook(webhookId); + if (!webhook) { + throw new Error("Webhook not found"); + } + if (webhook.revokedAt) { + throw new Error("Webhook has been revoked"); + } + if (webhook.eventType !== WebhooksEventTypes.WALLET_SUBSCRIPTION) { + throw new Error("Webhook is not a wallet subscription webhook"); + } + } + + const existingSubscriptionsCount = await prisma.walletSubscriptions.count({}); + + if (existingSubscriptionsCount >= 1000) { + throw new Error("Maximum number of wallet subscriptions reached"); + } + + // Create a new subscription + return await prisma.walletSubscriptions.create({ + data: { + chainId, + walletAddress: walletAddress.toLowerCase(), + conditions: validatedConditions as Prisma.InputJsonValue[], + webhookId, + }, + include: { + webhook: true, + }, + }); +} diff --git a/src/shared/db/wallet-subscriptions/delete-wallet-subscription.ts b/src/shared/db/wallet-subscriptions/delete-wallet-subscription.ts new file mode 100644 index 00000000..65e3ecd4 --- /dev/null +++ b/src/shared/db/wallet-subscriptions/delete-wallet-subscription.ts @@ -0,0 +1,16 @@ +import { prisma } from "../client"; + +export async function deleteWalletSubscription(id: string) { + return await prisma.walletSubscriptions.update({ + where: { + id, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + }, + include: { + webhook: true, + }, + }); +} \ No newline at end of file diff --git a/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts b/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts new file mode 100644 index 00000000..63125437 --- /dev/null +++ b/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts @@ -0,0 +1,27 @@ +import { validateConditions } from "../../schemas/wallet-subscription-conditions"; +import { prisma } from "../client"; + +export async function getAllWalletSubscriptions(args?: { + page?: number; + limit?: number; +}) { + const { page, limit } = args || {}; + const subscriptions = await prisma.walletSubscriptions.findMany({ + where: { + deletedAt: null, + }, + include: { + webhook: true, + }, + skip: page && limit ? (page - 1) * limit : undefined, + take: limit, + orderBy: { + updatedAt: "desc", + }, + }); + + return subscriptions.map((subscription) => ({ + ...subscription, + conditions: validateConditions(subscription.conditions), + })); +} diff --git a/src/shared/db/wallet-subscriptions/update-wallet-subscription.ts b/src/shared/db/wallet-subscriptions/update-wallet-subscription.ts new file mode 100644 index 00000000..2082470d --- /dev/null +++ b/src/shared/db/wallet-subscriptions/update-wallet-subscription.ts @@ -0,0 +1,53 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../client"; +import type { WalletConditions } from "../../schemas/wallet-subscription-conditions"; +import { validateConditions } from "../../schemas/wallet-subscription-conditions"; +import { WebhooksEventTypes } from "../../schemas/webhooks"; +import { getWebhook } from "../webhooks/get-webhook"; + +interface UpdateWalletSubscriptionParams { + id: string; + chainId?: string; + walletAddress?: string; + conditions?: WalletConditions; + webhookId?: number | null; +} + +export async function updateWalletSubscription({ + id, + chainId, + walletAddress, + conditions, + webhookId, +}: UpdateWalletSubscriptionParams) { + if (webhookId) { + const webhook = await getWebhook(webhookId); + if (!webhook) { + throw new Error("Webhook not found"); + } + if (webhook.revokedAt) { + throw new Error("Webhook has been revoked"); + } + if (webhook.eventType !== WebhooksEventTypes.WALLET_SUBSCRIPTION) { + throw new Error("Webhook is not a wallet subscription webhook"); + } + } + + return await prisma.walletSubscriptions.update({ + where: { + id, + deletedAt: null, + }, + data: { + ...(chainId && { chainId }), + ...(walletAddress && { walletAddress: walletAddress.toLowerCase() }), + ...(conditions && { + conditions: validateConditions(conditions) as Prisma.InputJsonValue[], + }), + ...(webhookId !== undefined && { webhookId }), + }, + include: { + webhook: true, + }, + }); +} diff --git a/src/shared/schemas/wallet-subscription-conditions.ts b/src/shared/schemas/wallet-subscription-conditions.ts new file mode 100644 index 00000000..72e27f04 --- /dev/null +++ b/src/shared/schemas/wallet-subscription-conditions.ts @@ -0,0 +1,53 @@ +import { Type } from "@sinclair/typebox"; +import { z } from "zod"; +import { AddressSchema } from "../../server/schemas/address"; + +// TypeBox schemas for API validation +export const WalletConditionSchema = Type.Union([ + Type.Object({ + type: Type.Literal('token_balance_lt'), + tokenAddress: Type.Union([AddressSchema, Type.Literal('native')]), + value: Type.String({ + description: "The threshold value in wei", + examples: ["1000000000000000000"] // 1 ETH + }) + }), + Type.Object({ + type: Type.Literal('token_balance_gt'), + tokenAddress: Type.Union([AddressSchema, Type.Literal('native')]), + value: Type.String({ + description: "The threshold value in wei", + examples: ["1000000000000000000"] // 1 ETH + }) + }) +]); + +export const WalletConditionsSchema = Type.Array(WalletConditionSchema, { + maxItems: 100, + description: "Array of conditions to monitor for this wallet" +}); + +// Zod schemas for internal validation +export const WalletConditionZ = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('token_balance_lt'), + tokenAddress: z.union([z.string(), z.literal('native')]), + value: z.string() + }), + z.object({ + type: z.literal('token_balance_gt'), + tokenAddress: z.union([z.string(), z.literal('native')]), + value: z.string() + }) +]); + +export const WalletConditionsZ = z.array(WalletConditionZ).max(100); + +// Type exports +export type WalletCondition = z.infer; +export type WalletConditions = z.infer; + +// Helper to validate conditions +export function validateConditions(conditions: unknown): WalletConditions { + return WalletConditionsZ.parse(conditions); +} \ No newline at end of file diff --git a/src/shared/schemas/webhooks.ts b/src/shared/schemas/webhooks.ts index 57279d37..47816843 100644 --- a/src/shared/schemas/webhooks.ts +++ b/src/shared/schemas/webhooks.ts @@ -1,3 +1,5 @@ +import type { WalletCondition } from "./wallet-subscription-conditions"; + export enum WebhooksEventTypes { QUEUED_TX = "queued_transaction", SENT_TX = "sent_transaction", @@ -8,6 +10,7 @@ export enum WebhooksEventTypes { BACKEND_WALLET_BALANCE = "backend_wallet_balance", AUTH = "auth", CONTRACT_SUBSCRIPTION = "contract_subscription", + WALLET_SUBSCRIPTION = "wallet_subscription", } export type BackendWalletBalanceWebhookParams = { @@ -17,3 +20,10 @@ export type BackendWalletBalanceWebhookParams = { chainId: number; message: string; }; +export interface WalletSubscriptionWebhookParams { + subscriptionId: string; + chainId: string; + walletAddress: string; + condition: WalletCondition; + currentValue: string; +} diff --git a/src/worker/index.ts b/src/worker/index.ts index dce96767..41b3825d 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -16,6 +16,7 @@ import { initProcessTransactionReceiptsWorker } from "./tasks/process-transactio import { initPruneTransactionsWorker } from "./tasks/prune-transactions-worker"; import { initSendTransactionWorker } from "./tasks/send-transaction-worker"; import { initSendWebhookWorker } from "./tasks/send-webhook-worker"; +import { initWalletSubscriptionWorker } from "./tasks/wallet-subscription-worker"; export const initWorker = async () => { initCancelRecycledNoncesWorker(); @@ -25,10 +26,10 @@ export const initWorker = async () => { initSendTransactionWorker(); initMineTransactionWorker(); initSendWebhookWorker(); - initNonceHealthCheckWorker(); await initNonceResyncWorker(); + await initWalletSubscriptionWorker(); // Listen for new & updated configuration data. await newConfigurationListener(); diff --git a/src/worker/queues/send-webhook-queue.ts b/src/worker/queues/send-webhook-queue.ts index 1f79bc22..6259670c 100644 --- a/src/worker/queues/send-webhook-queue.ts +++ b/src/worker/queues/send-webhook-queue.ts @@ -8,6 +8,7 @@ import SuperJSON from "superjson"; import { WebhooksEventTypes, type BackendWalletBalanceWebhookParams, + type WalletSubscriptionWebhookParams, } from "../../shared/schemas/webhooks"; import { getWebhooksByEventType } from "../../shared/utils/cache/get-webhook"; import { redis } from "../../shared/utils/redis/redis"; @@ -34,11 +35,18 @@ export type EnqueueLowBalanceWebhookData = { body: BackendWalletBalanceWebhookParams; }; +export type EnqueueWalletSubscriptionWebhookData = { + type: WebhooksEventTypes.WALLET_SUBSCRIPTION; + webhook: Webhooks; + body: WalletSubscriptionWebhookParams; +}; + // Add other webhook event types here. type EnqueueWebhookData = | EnqueueContractSubscriptionWebhookData | EnqueueTransactionWebhookData - | EnqueueLowBalanceWebhookData; + | EnqueueLowBalanceWebhookData + | EnqueueWalletSubscriptionWebhookData; export interface WebhookJob { data: EnqueueWebhookData; @@ -66,6 +74,8 @@ export class SendWebhookQueue { return this._enqueueTransactionWebhook(data); case WebhooksEventTypes.BACKEND_WALLET_BALANCE: return this._enqueueBackendWalletBalanceWebhook(data); + case WebhooksEventTypes.WALLET_SUBSCRIPTION: + return this._enqueueWalletSubscriptionWebhook(data); } }; @@ -161,4 +171,18 @@ export class SendWebhookQueue { ); } }; + + private static _enqueueWalletSubscriptionWebhook = async ( + data: EnqueueWalletSubscriptionWebhookData, + ) => { + const { type, webhook, body } = data; + if (!webhook.revokedAt && type === webhook.eventType) { + const job: WebhookJob = { data, webhook }; + const serialized = SuperJSON.stringify(job); + await this.q.add( + `${type}:${body.chainId}:${body.walletAddress}:${body.subscriptionId}`, + serialized, + ); + } + }; } diff --git a/src/worker/queues/wallet-subscription-queue.ts b/src/worker/queues/wallet-subscription-queue.ts new file mode 100644 index 00000000..15f4344a --- /dev/null +++ b/src/worker/queues/wallet-subscription-queue.ts @@ -0,0 +1,14 @@ +import { Queue } from "bullmq"; +import { redis } from "../../shared/utils/redis/redis"; +import { defaultJobOptions } from "./queues"; + +export class WalletSubscriptionQueue { + static q = new Queue("wallet-subscription", { + connection: redis, + defaultJobOptions, + }); + + constructor() { + WalletSubscriptionQueue.q.setGlobalConcurrency(1); + } +} \ No newline at end of file diff --git a/src/worker/tasks/send-webhook-worker.ts b/src/worker/tasks/send-webhook-worker.ts index dda1f1f5..61649133 100644 --- a/src/worker/tasks/send-webhook-worker.ts +++ b/src/worker/tasks/send-webhook-worker.ts @@ -5,6 +5,7 @@ import { TransactionDB } from "../../shared/db/transactions/db"; import { WebhooksEventTypes, type BackendWalletBalanceWebhookParams, + type WalletSubscriptionWebhookParams, } from "../../shared/schemas/webhooks"; import { toEventLogSchema } from "../../server/schemas/event-log"; import { @@ -18,7 +19,10 @@ import { sendWebhookRequest, type WebhookResponse, } from "../../shared/utils/webhook"; -import { SendWebhookQueue, type WebhookJob } from "../queues/send-webhook-queue"; +import { + SendWebhookQueue, + type WebhookJob, +} from "../queues/send-webhook-queue"; const handler: Processor = async (job: Job) => { const { data, webhook } = superjson.parse(job.data); @@ -69,6 +73,15 @@ const handler: Processor = async (job: Job) => { resp = await sendWebhookRequest(webhook, webhookBody); break; } + + case WebhooksEventTypes.WALLET_SUBSCRIPTION: { + const webhookBody: WalletSubscriptionWebhookParams = data.body; + resp = await sendWebhookRequest( + webhook, + webhookBody as unknown as Record, + ); + break; + } } // Throw on 5xx so it remains in the queue to retry later. diff --git a/src/worker/tasks/wallet-subscription-worker.ts b/src/worker/tasks/wallet-subscription-worker.ts new file mode 100644 index 00000000..9ede4242 --- /dev/null +++ b/src/worker/tasks/wallet-subscription-worker.ts @@ -0,0 +1,155 @@ +import { type Job, type Processor, Worker } from "bullmq"; +import { getAllWalletSubscriptions } from "../../shared/db/wallet-subscriptions/get-all-wallet-subscriptions"; +import { getConfig } from "../../shared/utils/cache/get-config"; +import { logger } from "../../shared/utils/logger"; +import { redis } from "../../shared/utils/redis/redis"; +import { WalletSubscriptionQueue } from "../queues/wallet-subscription-queue"; +import { logWorkerExceptions } from "../queues/queues"; +import { SendWebhookQueue } from "../queues/send-webhook-queue"; +import { WebhooksEventTypes } from "../../shared/schemas/webhooks"; +import { getChain } from "../../shared/utils/chain"; +import { thirdwebClient } from "../../shared/utils/sdk"; +import { getWalletBalance } from "thirdweb/wallets"; +import type { Chain } from "thirdweb/chains"; +import type { WalletCondition } from "../../shared/schemas/wallet-subscription-conditions"; +import type { WalletSubscriptions, Webhooks } from "@prisma/client"; +import { prettifyError } from "../../shared/utils/error"; + +type WalletSubscriptionWithWebhook = WalletSubscriptions & { + conditions: WalletCondition[]; + webhook: Webhooks | null; +}; + +// Split array into chunks of specified size +function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size), + ); +} + +/** + * Verify if a condition is met for a given wallet + * Returns the current value if condition is met, undefined otherwise + */ +async function verifyCondition({ + condition, + walletAddress, + chain, +}: { + condition: WalletCondition; + walletAddress: string; + chain: Chain; +}): Promise { + switch (condition.type) { + case "token_balance_lt": + case "token_balance_gt": { + const currentBalanceResponse = await getWalletBalance({ + address: walletAddress, + client: thirdwebClient, + tokenAddress: + condition.tokenAddress === "native" + ? undefined + : condition.tokenAddress, + chain, + }); + + const currentBalance = currentBalanceResponse.value; + const threshold = BigInt(condition.value); + + const isConditionMet = + condition.type === "token_balance_lt" + ? currentBalance < threshold + : currentBalance > threshold; + + return isConditionMet ? currentBalance.toString() : null; + } + } +} + +/** + * Process a batch of subscriptions and trigger webhooks for any met conditions + */ +async function processSubscriptions( + subscriptions: WalletSubscriptionWithWebhook[], +) { + await Promise.all( + subscriptions.map(async (subscription) => { + try { + const chain = await getChain(Number.parseInt(subscription.chainId)); + + // Process each condition for the subscription + for (const condition of subscription.conditions) { + const currentValue = await verifyCondition({ + condition, + walletAddress: subscription.walletAddress, + chain, + }); + + if (currentValue && subscription.webhookId && subscription.webhook) { + await SendWebhookQueue.enqueueWebhook({ + type: WebhooksEventTypes.WALLET_SUBSCRIPTION, + webhook: subscription.webhook, + body: { + subscriptionId: subscription.id, + chainId: subscription.chainId, + walletAddress: subscription.walletAddress, + condition, + currentValue, + }, + }); + } + } + } catch (error) { + // Log error but continue processing other subscriptions + const message = prettifyError(error); + logger({ + service: "worker", + level: "error", + message: `Error processing wallet subscription ${subscription.id}: ${message}`, + error: error as Error, + }); + } + }), + ); +} + +// Must be explicitly called for the worker to run on this host. +export const initWalletSubscriptionWorker = async () => { + const config = await getConfig(); + const cronPattern = + config.walletSubscriptionsCronSchedule || "*/30 * * * * *"; // Default to every 30 seconds + + logger({ + service: "worker", + level: "info", + message: `Initializing wallet subscription worker with cron pattern: ${cronPattern}`, + }); + + WalletSubscriptionQueue.q.add("cron", "", { + repeat: { pattern: cronPattern }, + jobId: "wallet-subscription-cron", + }); + + const _worker = new Worker(WalletSubscriptionQueue.q.name, handler, { + connection: redis, + concurrency: 1, + }); + logWorkerExceptions(_worker); +}; + +/** + * Process all wallet subscriptions and notify webhooks when conditions are met. + */ +const handler: Processor = async (_job: Job) => { + // Get all active wallet subscriptions + const subscriptions = await getAllWalletSubscriptions(); + if (subscriptions.length === 0) { + return; + } + + // Process in batches of 50 + const batches = chunk(subscriptions, 50); + for (const batch of batches) { + await processSubscriptions(batch); + } +}; diff --git a/tests/e2e/tests/workers/wallet-subscription-worker.test.ts b/tests/e2e/tests/workers/wallet-subscription-worker.test.ts new file mode 100644 index 00000000..5bdaec1f --- /dev/null +++ b/tests/e2e/tests/workers/wallet-subscription-worker.test.ts @@ -0,0 +1,222 @@ +import { + beforeAll, + afterAll, + describe, + expect, + test, + beforeEach, + afterEach, +} from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import { setup } from "../setup"; +import type { WalletSubscriptionWebhookParams } from "../../../../src/shared/schemas/webhooks"; +import type { Engine } from "../../../../sdk/dist/thirdweb-dev-engine.cjs"; +import type { WalletCondition } from "../../../../src/shared/schemas/wallet-subscription-conditions"; +import { sleep } from "bun"; + +describe("Wallet Subscription Worker", () => { + let testCallbackServer: FastifyInstance; + let engine: Engine; + let webhookPayloads: WalletSubscriptionWebhookParams[] = []; + let webhookId: number; + + beforeAll(async () => { + engine = (await setup()).engine; + testCallbackServer = await createTempCallbackServer(); + + // Create a webhook that we'll reuse for all tests + const webhook = await engine.webhooks.create({ + url: "http://localhost:3006/callback", + eventType: "wallet_subscription", + }); + webhookId = webhook.result.id; + }); + + afterAll(async () => { + await testCallbackServer.close(); + }); + + beforeEach(() => { + // Clear webhook payloads before each test + webhookPayloads = []; + }); + + afterEach(async () => { + await sleep(5000); // wait for any unsent webhooks to be sent + }); + + const createTempCallbackServer = async () => { + const tempServer = Fastify(); + + tempServer.post("/callback", async (request) => { + const payload = request.body as WalletSubscriptionWebhookParams; + webhookPayloads.push(payload); + return { success: true }; + }); + + await tempServer.listen({ port: 3006 }); + return tempServer; + }; + + const waitForWebhookPayloads = async ( + timeoutMs = 5000, + ): Promise => { + // Wait for initial webhooks to come in + await new Promise((resolve) => setTimeout(resolve, timeoutMs)); + return webhookPayloads; + }; + + const createSubscription = async (conditions: WalletCondition[]) => { + const subscription = await engine.walletSubscriptions.addWalletSubscription( + { + chain: "137", + walletAddress: "0xE52772e599b3fa747Af9595266b527A31611cebd", + conditions, + webhookId, + }, + ); + + return subscription.result; + }; + + test("should create and validate wallet subscription", async () => { + const condition: WalletCondition = { + type: "token_balance_lt", + value: "100000000000000000", // 0.1 ETH + tokenAddress: "native", + }; + + const subscription = await createSubscription([condition]); + + expect(subscription.chainId).toBe("137"); + expect(subscription.walletAddress.toLowerCase()).toBe( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(subscription.conditions).toEqual([condition]); + expect(subscription.webhook?.url).toBe("http://localhost:3006/callback"); + + // Cleanup + await engine.walletSubscriptions.deleteWalletSubscription(subscription.id); + }); + + test("should fire webhooks for token balance less than threshold", async () => { + const condition: WalletCondition = { + type: "token_balance_lt", + value: "1000000000000000000000", // 1000 ETH (high threshold to ensure trigger) + tokenAddress: "native", + }; + + const subscription = await createSubscription([condition]); + + try { + const payloads = await waitForWebhookPayloads(); + + // Verify we got webhooks + expect(payloads.length).toBeGreaterThan(0); + + // Verify webhook data is correct + for (const payload of payloads) { + expect(payload.subscriptionId).toBe(subscription.id); + expect(payload.chainId).toBe("137"); + expect(payload.walletAddress.toLowerCase()).toBe( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(payload.condition).toEqual(condition); + expect(BigInt(payload.currentValue)).toBeLessThan( + BigInt(condition.value), + ); + } + } finally { + await engine.walletSubscriptions.deleteWalletSubscription( + subscription.id, + ); + } + }); + + test("should fire webhooks for token balance greater than threshold", async () => { + const condition: WalletCondition = { + type: "token_balance_gt", + value: "1000000000000", // Very small threshold to ensure trigger + tokenAddress: "native", + }; + + const subscription = await createSubscription([condition]); + + try { + const payloads = await waitForWebhookPayloads(); + + // Verify we got webhooks + expect(payloads.length).toBeGreaterThan(0); + + // Verify webhook data is correct + for (const payload of payloads) { + expect(payload.subscriptionId).toBe(subscription.id); + expect(payload.chainId).toBe("137"); + expect(payload.walletAddress.toLowerCase()).toBe( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(payload.condition).toEqual(condition); + expect(BigInt(payload.currentValue)).toBeGreaterThan( + BigInt(condition.value), + ); + } + } finally { + await engine.walletSubscriptions.deleteWalletSubscription( + subscription.id, + ); + } + }); + + test("should fire webhooks for multiple conditions", async () => { + const conditions: WalletCondition[] = [ + { + type: "token_balance_gt", + value: "1000000000000", // Very small threshold to ensure trigger + tokenAddress: "native", + }, + { + type: "token_balance_lt", + value: "1000000000000000000000", // 1000 ETH (high threshold to ensure trigger) + tokenAddress: "native", + }, + ]; + + const subscription = await createSubscription(conditions); + + try { + const payloads = await waitForWebhookPayloads(); + + // Verify we got webhooks for both conditions + expect(payloads.length).toBeGreaterThan(1); + + // Verify we got webhooks for both conditions + const uniqueConditions = new Set(payloads.map((p) => p.condition.type)); + expect(uniqueConditions.size).toBe(2); + + // Verify each webhook has correct data + for (const payload of payloads) { + expect(payload.subscriptionId).toBe(subscription.id); + expect(payload.chainId).toBe("137"); + expect(payload.walletAddress.toLowerCase()).toBe( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(payload.currentValue).toBeDefined(); + + // Verify the value satisfies the condition + if (payload.condition.type === "token_balance_gt") { + expect(BigInt(payload.currentValue)).toBeGreaterThan( + BigInt(payload.condition.value), + ); + } else { + expect(BigInt(payload.currentValue)).toBeLessThan( + BigInt(payload.condition.value), + ); + } + } + } finally { + await engine.walletSubscriptions.deleteWalletSubscription( + subscription.id, + ); + } + }); +});