From 710332bb8ddad942063be18ace2e5abd7bb49a2c Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Tue, 4 Feb 2025 22:30:58 +0530 Subject: [PATCH 01/11] configuration capability for circle w3s API key --- src/prisma/schema.prisma | 47 +++++++++++++--- src/server/utils/wallets/circle/index.ts | 0 .../db/configuration/get-configuration.ts | 39 ++++++++++++++ .../db/configuration/update-configuration.ts | 53 ++++++++++++++----- src/shared/schemas/config.ts | 6 +++ 5 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 src/server/utils/wallets/circle/index.ts diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 44608303b..18f4bf454 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()) @map("id") + type String @map("type") + label String? @map("label") + data Json @map("data") + isDefault Boolean @default(false) @map("isDefault") + + createdAt DateTime @default(now()) @map("createdAt") + updatedAt DateTime @updatedAt @map("updatedAt") + deletedAt DateTime? @map("deletedAt") + + 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/utils/wallets/circle/index.ts b/src/server/utils/wallets/circle/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/shared/db/configuration/get-configuration.ts b/src/shared/db/configuration/get-configuration.ts index 71776b87b..d765fafbc 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 4e153cde9..41e49df01 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/schemas/config.ts b/src/shared/schemas/config.ts index 6205c3816..97d7c6d38 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; From ee8c926d15db4d564c713561b7a7fc9a8b13c71b Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Tue, 4 Feb 2025 23:36:36 +0530 Subject: [PATCH 02/11] Wallet Credentials API --- package.json | 1 + src/prisma/schema.prisma | 2 +- .../routes/wallet-credentials/create.ts | 106 ++++++++++++++++++ .../routes/wallet-credentials/get-all.ts | 84 ++++++++++++++ src/server/routes/wallet-credentials/get.ts | 85 ++++++++++++++ .../create-wallet-credential.ts | 78 +++++++++++++ .../get-all-wallet-credentials.ts | 38 +++++++ .../get-wallet-credential.ts | 82 ++++++++++++++ yarn.lock | 9 +- 9 files changed, 483 insertions(+), 2 deletions(-) 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/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 5be6c5aa4..49b752027 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", diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 18f4bf454..3c3fadeb8 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -122,7 +122,7 @@ model WalletDetails { model WalletCredentials { id String @id @default(uuid()) @map("id") type String @map("type") - label String? @map("label") + label String @map("label") data Json @map("data") isDefault Boolean @default(false) @map("isDefault") diff --git a/src/server/routes/wallet-credentials/create.ts b/src/server/routes/wallet-credentials/create.ts new file mode 100644 index 000000000..df97311da --- /dev/null +++ b/src/server/routes/wallet-credentials/create.ts @@ -0,0 +1,106 @@ +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.Boolean(), + 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 000000000..dfdfacca2 --- /dev/null +++ b/src/server/routes/wallet-credentials/get-all.ts @@ -0,0 +1,84 @@ +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"; + +const QuerySchema = Type.Object({ + page: Type.Integer({ + description: "The page of credentials to get.", + examples: ["1"], + default: "1", + minimum: 1, + }), + limit: Type.Integer({ + description: "The number of credentials to get per page.", + examples: ["10"], + default: "10", + minimum: 1, + }), +}); + +const responseSchema = Type.Object({ + result: Type.Array( + 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 getAllWalletCredentialsEndpoint(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(), + deletedAt: cred.deletedAt?.toISOString() || null, + })), + }); + }, + }); +} \ No newline at end of file diff --git a/src/server/routes/wallet-credentials/get.ts b/src/server/routes/wallet-credentials/get.ts new file mode 100644 index 000000000..99d944cbc --- /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 getWalletCredentialEndpoint(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/shared/db/wallet-credentials/create-wallet-credential.ts b/src/shared/db/wallet-credentials/create-wallet-credential.ts new file mode 100644 index 000000000..26091936c --- /dev/null +++ b/src/shared/db/wallet-credentials/create-wallet-credential.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +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"; + +export const entitySecretSchema = z.string().regex(/^[0-9a-fA-F]{64}$/, { + message: "entitySecret must be a 32-byte hex string", +}); + +// 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) => { + // not handling other wallet types because we only support circle for now + const { walletConfiguration } = await getConfig(); + const circleApiKey = walletConfiguration.circle?.apiKey; + + if (!circleApiKey) { + throw new WalletCredentialsError("No Circle API Key Configured"); + } + + if (entitySecret) { + const { error } = entitySecretSchema.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 ?? false, + 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, + }); + } 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 000000000..8928e5988 --- /dev/null +++ b/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts @@ -0,0 +1,38 @@ +import { getPrismaWithPostgresTx } from "../client"; +import type { PrismaTransaction } from "../../schemas/prisma"; + +interface GetAllWalletCredentialsParams { + pgtx?: PrismaTransaction; + page?: number; + limit?: number; +} + +export const getAllWalletCredentials = async ({ + pgtx, + page = 1, + limit = 10, +}: GetAllWalletCredentialsParams) => { + const prisma = getPrismaWithPostgresTx(pgtx); + + 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, + deletedAt: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return credentials; +}; \ No newline at end of file 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 000000000..662cb851b --- /dev/null +++ b/src/shared/db/wallet-credentials/get-wallet-credential.ts @@ -0,0 +1,82 @@ +import LRUMap from "mnemonist/lru-map"; +import { z } from "zod"; +import { decrypt } from "../../utils/crypto"; +import { env } from "../../utils/env"; +import { prisma } from "../client"; +import { entitySecretSchema } from "./create-wallet-credential"; + +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: entitySecretSchema, + }), + 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/yarn.lock b/yarn.lock index 55f064b9b..2bcdfa3ea 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== From f102ee3f8d788af84965fa38d29d280ff64fed86 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Wed, 5 Feb 2025 02:32:17 +0530 Subject: [PATCH 03/11] tested transactions --- package.json | 1 + .../migration.sql | 29 ++ src/server/routes/backend-wallet/create.ts | 102 +++++- .../routes/configuration/wallets/update.ts | 18 ++ src/server/routes/index.ts | 8 + .../routes/wallet-credentials/get-all.ts | 2 +- src/server/routes/wallet-credentials/get.ts | 2 +- src/server/utils/wallets/circle/index.ts | 292 ++++++++++++++++++ .../get-wallet-credential.ts | 3 +- src/shared/db/wallets/get-wallet-details.ts | 28 +- src/shared/schemas/wallet.ts | 25 ++ src/shared/utils/account.ts | 55 ++++ yarn.lock | 13 + 13 files changed, 559 insertions(+), 19 deletions(-) create mode 100644 src/prisma/migrations/20250204201526_add_wallet_credentials/migration.sql diff --git a/package.json b/package.json index 49b752027..e8b1eb1ae 100644 --- a/package.json +++ b/package.json @@ -64,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 000000000..9776a0c96 --- /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/server/routes/backend-wallet/create.ts b/src/server/routes/backend-wallet/create.ts index 707616b47..b73fc2208 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,26 @@ import { createSmartGcpWalletDetails, createSmartLocalWalletDetails, } from "../../utils/wallets/create-smart-wallet"; +import { + CircleWalletError, + createCircleWalletDetails, +} from "../../utils/wallets/circle"; -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)]), + credentialId: Type.String(), + walletSetId: Type.Optional(Type.String()), + }), +]); const responseSchema = Type.Object({ result: Type.Object({ @@ -73,7 +87,7 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { handler: async (req, reply) => { const { label } = req.body; - let walletAddress: string; + let walletAddress: string | undefined = undefined; const config = await getConfig(); const walletType = @@ -112,6 +126,66 @@ 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 + if (req.body.type !== "circle") + throw new Error("Invalid Circle wallet type"); // invariant + + const { credentialId, walletSetId } = req.body; + + try { + const wallet = await createCircleWalletDetails({ + label, + isSmart: false, + credentialId, + walletSetId, + }); + + 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 + if (req.body.type !== "smart:circle") + throw new Error("Invalid Circle wallet type"); // invariant + + const { credentialId, walletSetId } = req.body; + + try { + const wallet = await createCircleWalletDetails({ + label, + isSmart: true, + credentialId, + walletSetId, + }); + + 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({ @@ -163,10 +237,14 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { break; } + if (!walletAddress) { + throw new Error("Invalid state"); // invariant, typescript cannot exhaustive check because enums + } + 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 dd4ed2b96..a5f7b9e83 100644 --- a/src/server/routes/configuration/wallets/update.ts +++ b/src/server/routes/configuration/wallets/update.ts @@ -21,6 +21,14 @@ const requestBodySchema = Type.Union([ gcpApplicationCredentialEmail: Type.String(), gcpApplicationCredentialPrivateKey: Type.String(), }), + Type.Object({ + awsAccessKeyId: Type.String(), + awsSecretAccessKey: Type.String(), + awsRegion: Type.String(), + }), + Type.Object({ + circleApiKey: Type.String(), + }), ]); requestBodySchema.examples = [ @@ -107,6 +115,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 1606debcc..370ab9e67 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/get-all.ts b/src/server/routes/wallet-credentials/get-all.ts index dfdfacca2..38f83e73b 100644 --- a/src/server/routes/wallet-credentials/get-all.ts +++ b/src/server/routes/wallet-credentials/get-all.ts @@ -47,7 +47,7 @@ responseSchema.example = { ], }; -export async function getAllWalletCredentialsEndpoint(fastify: FastifyInstance) { +export async function getAllWalletCredentialsRoute(fastify: FastifyInstance) { fastify.route<{ Querystring: Static; Reply: Static; diff --git a/src/server/routes/wallet-credentials/get.ts b/src/server/routes/wallet-credentials/get.ts index 99d944cbc..c0141efe1 100644 --- a/src/server/routes/wallet-credentials/get.ts +++ b/src/server/routes/wallet-credentials/get.ts @@ -35,7 +35,7 @@ responseSchema.example = { }, }; -export async function getWalletCredentialEndpoint(fastify: FastifyInstance) { +export async function getWalletCredentialRoute(fastify: FastifyInstance) { fastify.route<{ Params: Static; Reply: Static; diff --git a/src/server/utils/wallets/circle/index.ts b/src/server/utils/wallets/circle/index.ts index e69de29bb..4fd1f59a1 100644 --- a/src/server/utils/wallets/circle/index.ts +++ b/src/server/utils/wallets/circle/index.ts @@ -0,0 +1,292 @@ +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"; + +export class CircleWalletError extends Error { + constructor(message: string) { + super(message); + this.name = "CircleWalletError"; + } +} + +export async function provisionCircleWallet({ + entitySecret, + apiKey, + walletSetId, + client, +}: { + entitySecret: string; + apiKey: string; + walletSetId?: string; + client: ThirdwebClient; +}) { + const circleDeveloperSdk = initiateDeveloperControlledWalletsClient({ + apiKey: apiKey, + entitySecret: entitySecret, + }); + + if (!walletSetId) { + const walletSet = await circleDeveloperSdk.createWalletSet({ + name: `Engine WalletSet ${new Date().toISOString()}`, + }); + + 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: ["EVM"], + count: 1, + walletSetId: walletSetId, + }); + + 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 }); + if (!walletResponse) { + throw new CircleWalletError( + `Unable to get circle wallet with id:${walletId}`, + ); + } + const wallet = walletResponse.data?.wallet; + const address = wallet?.address as Address; + + function stringify(data: unknown) { + return JSON.stringify(data, (_, value) => { + if (typeof value === "bigint") { + return value.toString(); + } + return value; + }); + } + + async function signTransaction(tx: SerializableTransaction) { + const signature = await circleDeveloperSdk.signTransaction({ + walletId, + transaction: stringify(tx), + }); + + 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, + }); + + 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, + }); + + 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, +}: { + credentialId: string; + walletSetId?: string; + label?: string; + isSmart: 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, + }); + + 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, + }, + ...(isSmart ? sbwDetails : {}), + }, + }); +} diff --git a/src/shared/db/wallet-credentials/get-wallet-credential.ts b/src/shared/db/wallet-credentials/get-wallet-credential.ts index 662cb851b..21b7b8a29 100644 --- a/src/shared/db/wallet-credentials/get-wallet-credential.ts +++ b/src/shared/db/wallet-credentials/get-wallet-credential.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { decrypt } from "../../utils/crypto"; import { env } from "../../utils/env"; import { prisma } from "../client"; -import { entitySecretSchema } from "./create-wallet-credential"; export class WalletCredentialsError extends Error { constructor(message: string) { @@ -17,7 +16,7 @@ const walletCredentialsSchema = z.object({ type: z.literal("circle"), label: z.string().nullable(), data: z.object({ - entitySecret: entitySecretSchema, + entitySecret: z.string(), }), isDefault: z.boolean(), createdAt: z.date(), diff --git a/src/shared/db/wallets/get-wallet-details.ts b/src/shared/db/wallets/get-wallet-details.ts index c6612d628..0f9ede7c2 100644 --- a/src/shared/db/wallets/get-wallet-details.ts +++ b/src/shared/db/wallets/get-wallet-details.ts @@ -92,6 +92,24 @@ const smartGcpKmsWalletSchema = gcpKmsWalletSchema .merge(smartWalletPartialSchema) .merge(baseWalletPartialSchema); +const circleWalletSchema = z + .object({ + type: z.literal("circle"), + platformIdentifiers: z.object({ + circleWalletId: z.string(), + walletSetId: z.string(), + }), + credentialId: z.string(), + }) + .merge(baseWalletPartialSchema); + +const smartCircleWalletSchema = circleWalletSchema + .extend({ + type: z.literal("smart:circle"), + }) + .merge(smartWalletPartialSchema) + .merge(baseWalletPartialSchema); + const walletDetailsSchema = z.discriminatedUnion("type", [ localWalletSchema, smartLocalWalletSchema, @@ -99,6 +117,8 @@ const walletDetailsSchema = z.discriminatedUnion("type", [ smartAwsKmsWalletSchema, gcpKmsWalletSchema, smartGcpKmsWalletSchema, + circleWalletSchema, + smartCircleWalletSchema, ]); export type SmartBackendWalletDetails = @@ -118,12 +138,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 +203,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 +228,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/wallet.ts b/src/shared/schemas/wallet.ts index 4200e1eb6..3a55db467 100644 --- a/src/shared/schemas/wallet.ts +++ b/src/shared/schemas/wallet.ts @@ -1,4 +1,23 @@ +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 +26,10 @@ export enum WalletType { smartAwsKms = "smart:aws-kms", smartGcpKms = "smart:gcp-kms", smartLocal = "smart:local", + + //breaking + circle = "circle", + + // Smart wallets + smartCircle = "smart:circle", } diff --git a/src/shared/utils/account.ts b/src/shared/utils/account.ts index 496071e3d..10944fa66 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/yarn.lock b/yarn.lock index 2bcdfa3ea..8e200fea4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8864,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 3459d8ff63a3d817a73ee38123a8f3fd7900b86d Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Wed, 5 Feb 2025 02:40:52 +0530 Subject: [PATCH 04/11] better error handling --- src/server/utils/wallets/circle/index.ts | 95 ++++++++++++++++++------ 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/src/server/utils/wallets/circle/index.ts b/src/server/utils/wallets/circle/index.ts index 4fd1f59a1..6ca689e18 100644 --- a/src/server/utils/wallets/circle/index.ts +++ b/src/server/utils/wallets/circle/index.ts @@ -51,9 +51,17 @@ export async function provisionCircleWallet({ }); if (!walletSetId) { - const walletSet = await circleDeveloperSdk.createWalletSet({ - name: `Engine WalletSet ${new Date().toISOString()}`, - }); + 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; } @@ -63,12 +71,20 @@ export async function provisionCircleWallet({ "Did not receive walletSetId, and failed to create one automatically", ); - const provisionWalletResponse = await circleDeveloperSdk.createWallets({ - accountType: "EOA", - blockchains: ["EVM"], - count: 1, - walletSetId: walletSetId, - }); + const provisionWalletResponse = await circleDeveloperSdk + .createWallets({ + accountType: "EOA", + blockchains: ["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]; @@ -119,7 +135,16 @@ export async function getCircleAccount({ entitySecret, }); - const walletResponse = await circleDeveloperSdk.getWallet({ id: walletId }); + 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}`, @@ -138,10 +163,18 @@ export async function getCircleAccount({ } async function signTransaction(tx: SerializableTransaction) { - const signature = await circleDeveloperSdk.signTransaction({ - walletId, - transaction: stringify(tx), - }); + 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"); @@ -177,10 +210,18 @@ export async function getCircleAccount({ const typedData extends TypedData | Record, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData, >(_typedData: TypedDataDefinition): Promise { - const signatureResponse = await circleDeveloperSdk.signTypedData({ - data: stringify(_typedData), - walletId, - }); + 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"); @@ -201,11 +242,19 @@ export async function getCircleAccount({ messageToSign = toHex(messageToSign); } - const signatureResponse = await circleDeveloperSdk.signMessage({ - walletId, - message: messageToSign, - encodedByHex: isRawMessage, - }); + 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"); From b60a8bb3877c038ca6f13b885c4569fd8caf9e9c Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 7 Feb 2025 19:50:49 +0530 Subject: [PATCH 05/11] Addressed review comments --- .../migration.sql | 2 + src/prisma/schema.prisma | 18 ++-- src/server/routes/backend-wallet/create.ts | 21 ++-- .../routes/configuration/wallets/update.ts | 5 - .../routes/wallet-credentials/create.ts | 6 +- .../routes/wallet-credentials/get-all.ts | 22 +---- src/server/utils/wallets/circle/index.ts | 19 +--- .../create-wallet-credential.ts | 99 ++++++++++--------- .../get-all-wallet-credentials.ts | 2 - src/shared/db/wallets/get-wallet-details.ts | 7 +- src/shared/schemas/wallet.ts | 8 +- 11 files changed, 91 insertions(+), 118 deletions(-) create mode 100644 src/prisma/migrations/20250207135644_nullable_is_default/migration.sql 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 000000000..3712fa67e --- /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 3c3fadeb8..1c4a473c4 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -120,15 +120,15 @@ model WalletDetails { } model WalletCredentials { - id String @id @default(uuid()) @map("id") - type String @map("type") - label String @map("label") - data Json @map("data") - isDefault Boolean @default(false) @map("isDefault") - - createdAt DateTime @default(now()) @map("createdAt") - updatedAt DateTime @updatedAt @map("updatedAt") - deletedAt DateTime? @map("deletedAt") + 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[] diff --git a/src/server/routes/backend-wallet/create.ts b/src/server/routes/backend-wallet/create.ts index b73fc2208..1e30f16d0 100644 --- a/src/server/routes/backend-wallet/create.ts +++ b/src/server/routes/backend-wallet/create.ts @@ -33,6 +33,7 @@ import { CircleWalletError, createCircleWalletDetails, } from "../../utils/wallets/circle"; +import assert from "node:assert"; const requestBodySchema = Type.Union([ // Base schema for non-circle wallet types @@ -87,7 +88,7 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { handler: async (req, reply) => { const { label } = req.body; - let walletAddress: string | undefined = undefined; + let walletAddress: string; const config = await getConfig(); const walletType = @@ -129,9 +130,7 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { case CircleWalletType.circle: { // we need this if here for typescript to statically type the credentialId and walletSetId - if (req.body.type !== "circle") - throw new Error("Invalid Circle wallet type"); // invariant - + assert(req.body.type === "circle", "Expected circle wallet type"); const { credentialId, walletSetId } = req.body; try { @@ -159,9 +158,7 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { case CircleWalletType.smartCircle: { // we need this if here for typescript to statically type the credentialId and walletSetId - if (req.body.type !== "smart:circle") - throw new Error("Invalid Circle wallet type"); // invariant - + assert(req.body.type === "circle", "Expected circle wallet type"); const { credentialId, walletSetId } = req.body; try { @@ -235,10 +232,12 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { walletAddress = getAddress(smartLocalWallet.address); } break; - } - - if (!walletAddress) { - throw new Error("Invalid state"); // invariant, typescript cannot exhaustive check because enums + default: + throw createCustomError( + "Unkown wallet type", + StatusCodes.BAD_REQUEST, + "CREATE_WALLET_ERROR", + ); } reply.status(StatusCodes.OK).send({ diff --git a/src/server/routes/configuration/wallets/update.ts b/src/server/routes/configuration/wallets/update.ts index a5f7b9e83..b807a9604 100644 --- a/src/server/routes/configuration/wallets/update.ts +++ b/src/server/routes/configuration/wallets/update.ts @@ -21,11 +21,6 @@ const requestBodySchema = Type.Union([ gcpApplicationCredentialEmail: Type.String(), gcpApplicationCredentialPrivateKey: Type.String(), }), - Type.Object({ - awsAccessKeyId: Type.String(), - awsSecretAccessKey: Type.String(), - awsRegion: Type.String(), - }), Type.Object({ circleApiKey: Type.String(), }), diff --git a/src/server/routes/wallet-credentials/create.ts b/src/server/routes/wallet-credentials/create.ts index df97311da..53ec65f3b 100644 --- a/src/server/routes/wallet-credentials/create.ts +++ b/src/server/routes/wallet-credentials/create.ts @@ -30,7 +30,7 @@ const responseSchema = Type.Object({ id: Type.String(), type: Type.String(), label: Type.String(), - isDefault: Type.Boolean(), + isDefault: Type.Union([Type.Boolean(), Type.Null()]), createdAt: Type.String(), updatedAt: Type.String(), }), @@ -47,9 +47,7 @@ responseSchema.example = { }, }; -export const createWalletCredentialRoute = async ( - fastify: FastifyInstance, -) => { +export const createWalletCredentialRoute = async (fastify: FastifyInstance) => { fastify.withTypeProvider().route<{ Body: Static; Reply: Static; diff --git a/src/server/routes/wallet-credentials/get-all.ts b/src/server/routes/wallet-credentials/get-all.ts index 38f83e73b..f7a48a654 100644 --- a/src/server/routes/wallet-credentials/get-all.ts +++ b/src/server/routes/wallet-credentials/get-all.ts @@ -3,21 +3,9 @@ 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 = Type.Object({ - page: Type.Integer({ - description: "The page of credentials to get.", - examples: ["1"], - default: "1", - minimum: 1, - }), - limit: Type.Integer({ - description: "The number of credentials to get per page.", - examples: ["10"], - default: "10", - minimum: 1, - }), -}); +const QuerySchema = PaginationSchema; const responseSchema = Type.Object({ result: Type.Array( @@ -25,10 +13,9 @@ 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()]), }), ), }); @@ -76,9 +63,8 @@ export async function getAllWalletCredentialsRoute(fastify: FastifyInstance) { ...cred, createdAt: cred.createdAt.toISOString(), updatedAt: cred.updatedAt.toISOString(), - deletedAt: cred.deletedAt?.toISOString() || null, })), }); }, }); -} \ No newline at end of file +} diff --git a/src/server/utils/wallets/circle/index.ts b/src/server/utils/wallets/circle/index.ts index 6ca689e18..cd5f5c865 100644 --- a/src/server/utils/wallets/circle/index.ts +++ b/src/server/utils/wallets/circle/index.ts @@ -26,6 +26,7 @@ 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) { @@ -64,13 +65,12 @@ export async function provisionCircleWallet({ }); walletSetId = walletSet.data?.walletSet.id; + if (!walletSetId) + throw new CircleWalletError( + "Did not receive walletSetId, and failed to create one automatically", + ); } - if (!walletSetId) - throw new CircleWalletError( - "Did not receive walletSetId, and failed to create one automatically", - ); - const provisionWalletResponse = await circleDeveloperSdk .createWallets({ accountType: "EOA", @@ -153,15 +153,6 @@ export async function getCircleAccount({ const wallet = walletResponse.data?.wallet; const address = wallet?.address as Address; - function stringify(data: unknown) { - return JSON.stringify(data, (_, value) => { - if (typeof value === "bigint") { - return value.toString(); - } - return value; - }); - } - async function signTransaction(tx: SerializableTransaction) { const signature = await circleDeveloperSdk .signTransaction({ diff --git a/src/shared/db/wallet-credentials/create-wallet-credential.ts b/src/shared/db/wallet-credentials/create-wallet-credential.ts index 26091936c..0d91d6f37 100644 --- a/src/shared/db/wallet-credentials/create-wallet-credential.ts +++ b/src/shared/db/wallet-credentials/create-wallet-credential.ts @@ -1,14 +1,10 @@ -import { z } from "zod"; 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"; - -export const entitySecretSchema = z.string().regex(/^[0-9a-fA-F]{64}$/, { - message: "entitySecret must be a 32-byte hex string", -}); +import { cirlceEntitySecretZodSchema } from "../../schemas/wallet"; // will be expanded to be a discriminated union of all supported wallet types export type CreateWalletCredentialsParams = { @@ -24,55 +20,60 @@ export const createWalletCredential = async ({ entitySecret, isDefault, }: CreateWalletCredentialsParams) => { - // not handling other wallet types because we only support circle for now const { walletConfiguration } = await getConfig(); - const circleApiKey = walletConfiguration.circle?.apiKey; - if (!circleApiKey) { - throw new WalletCredentialsError("No Circle API Key Configured"); - } + switch (type) { + case "circle": { + const circleApiKey = walletConfiguration.circle?.apiKey; - if (entitySecret) { - const { error } = entitySecretSchema.safeParse(entitySecret); - if (error) { - throw new WalletCredentialsError( - "Invalid provided entity secret for Circle", - ); - } - } + if (!circleApiKey) { + throw new WalletCredentialsError("No Circle API Key Configured"); + } - // 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 ?? false, - data: { - entitySecret: encrypt(finalEntitySecret), - }, - }, - }); + if (entitySecret) { + const { error } = cirlceEntitySecretZodSchema.safeParse(entitySecret); + if (error) { + throw new WalletCredentialsError( + "Invalid provided entity secret for Circle", + ); + } + } - // try registering the entity secret. See: https://developers.circle.com/w3s/developer-controlled-create-your-first-wallet - try { - await registerEntitySecretCiphertext({ - apiKey: circleApiKey, - entitySecret: finalEntitySecret, - }); - } catch (e: unknown) { - // If failed to registeer, permanently delete erroneously created credential - await prisma.walletCredentials.delete({ - where: { - id: walletCredentials.id, - }, - }); + // 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 ?? false, + data: { + entitySecret: encrypt(finalEntitySecret), + }, + }, + }); - throw new WalletCredentialsError( - `Could not register Entity Secret with Circle\n${JSON.stringify(e)}`, - ); - } + // 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; + 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 index 8928e5988..88331b4fd 100644 --- a/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts +++ b/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts @@ -8,7 +8,6 @@ interface GetAllWalletCredentialsParams { } export const getAllWalletCredentials = async ({ - pgtx, page = 1, limit = 10, }: GetAllWalletCredentialsParams) => { @@ -27,7 +26,6 @@ export const getAllWalletCredentials = async ({ isDefault: true, createdAt: true, updatedAt: true, - deletedAt: true, }, orderBy: { createdAt: "desc", diff --git a/src/shared/db/wallets/get-wallet-details.ts b/src/shared/db/wallets/get-wallet-details.ts index 0f9ede7c2..f46b5d31d 100644 --- a/src/shared/db/wallets/get-wallet-details.ts +++ b/src/shared/db/wallets/get-wallet-details.ts @@ -58,7 +58,6 @@ const smartLocalWalletSchema = localWalletSchema type: z.literal("smart:local"), }) .merge(smartWalletPartialSchema) - .merge(baseWalletPartialSchema); const awsKmsWalletSchema = z .object({ @@ -74,7 +73,6 @@ const smartAwsKmsWalletSchema = awsKmsWalletSchema type: z.literal("smart:aws-kms"), }) .merge(smartWalletPartialSchema) - .merge(baseWalletPartialSchema); const gcpKmsWalletSchema = z .object({ @@ -90,7 +88,6 @@ const smartGcpKmsWalletSchema = gcpKmsWalletSchema type: z.literal("smart:gcp-kms"), }) .merge(smartWalletPartialSchema) - .merge(baseWalletPartialSchema); const circleWalletSchema = z .object({ @@ -108,7 +105,6 @@ const smartCircleWalletSchema = circleWalletSchema type: z.literal("smart:circle"), }) .merge(smartWalletPartialSchema) - .merge(baseWalletPartialSchema); const walletDetailsSchema = z.discriminatedUnion("type", [ localWalletSchema, @@ -124,7 +120,8 @@ const walletDetailsSchema = z.discriminatedUnion("type", [ export type SmartBackendWalletDetails = | z.infer | z.infer - | z.infer; + | z.infer + | z.infer; export function isSmartBackendWallet( wallet: ParsedWalletDetails, diff --git a/src/shared/schemas/wallet.ts b/src/shared/schemas/wallet.ts index 3a55db467..6c856e86b 100644 --- a/src/shared/schemas/wallet.ts +++ b/src/shared/schemas/wallet.ts @@ -1,3 +1,5 @@ +import * as z from "zod"; + export enum CircleWalletType { circle = "circle", @@ -27,9 +29,13 @@ export enum WalletType { smartGcpKms = "smart:gcp-kms", smartLocal = "smart:local", - //breaking + // 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", +}); From 37d9c31bfdba3081271a369e83c5f23632c01703 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 7 Feb 2025 19:54:46 +0530 Subject: [PATCH 06/11] clearer messaging for unsupported wallet --- src/shared/utils/cache/get-wallet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/utils/cache/get-wallet.ts b/src/shared/utils/cache/get-wallet.ts index 4f3533410..bc3124b86 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`, ); } From dabe685c743982be8dd26fb463d76cd2b9b5e0ef Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 7 Feb 2025 19:56:45 +0530 Subject: [PATCH 07/11] better messaging for v4 incompatible wallets --- src/shared/utils/cache/get-contract.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/shared/utils/cache/get-contract.ts b/src/shared/utils/cache/get-contract.ts index c590434d0..28e028e17 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 { 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) { From 78bcdd800a51c0dfb7e3ef36d18be6751ff53cd2 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 7 Feb 2025 19:57:16 +0530 Subject: [PATCH 08/11] lint issue --- src/shared/utils/cache/get-contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/utils/cache/get-contract.ts b/src/shared/utils/cache/get-contract.ts index 28e028e17..baa5a49e8 100644 --- a/src/shared/utils/cache/get-contract.ts +++ b/src/shared/utils/cache/get-contract.ts @@ -3,7 +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 { ThirdwebSDK } from "@thirdweb-dev/sdk"; +import type { ThirdwebSDK } from "@thirdweb-dev/sdk"; const abiArraySchema = Type.Array(abiSchema); From 25b2568b8b82872659828ff29eaf698d9e68a7bd Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 7 Feb 2025 20:09:21 +0530 Subject: [PATCH 09/11] fixes --- src/server/routes/backend-wallet/create.ts | 14 +++++++++++--- src/server/utils/wallets/circle/index.ts | 8 +++++++- src/shared/db/wallets/get-wallet-details.ts | 9 +++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/server/routes/backend-wallet/create.ts b/src/server/routes/backend-wallet/create.ts index 1e30f16d0..107fde4a6 100644 --- a/src/server/routes/backend-wallet/create.ts +++ b/src/server/routes/backend-wallet/create.ts @@ -46,6 +46,12 @@ const requestBodySchema = Type.Union([ 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()), }), @@ -131,7 +137,7 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { { // 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 } = req.body; + const { credentialId, walletSetId, isTestnet } = req.body; try { const wallet = await createCircleWalletDetails({ @@ -139,6 +145,7 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { isSmart: false, credentialId, walletSetId, + isTestnet: isTestnet, }); walletAddress = getAddress(wallet.address); @@ -158,8 +165,8 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { case CircleWalletType.smartCircle: { // 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 } = req.body; + assert(req.body.type === "smart:circle", "Expected circle wallet type"); + const { credentialId, walletSetId, isTestnet } = req.body; try { const wallet = await createCircleWalletDetails({ @@ -167,6 +174,7 @@ export const createBackendWallet = async (fastify: FastifyInstance) => { isSmart: true, credentialId, walletSetId, + isTestnet: isTestnet, }); walletAddress = getAddress(wallet.address); diff --git a/src/server/utils/wallets/circle/index.ts b/src/server/utils/wallets/circle/index.ts index cd5f5c865..5f1759530 100644 --- a/src/server/utils/wallets/circle/index.ts +++ b/src/server/utils/wallets/circle/index.ts @@ -40,11 +40,13 @@ export async function provisionCircleWallet({ apiKey, walletSetId, client, + isTestnet, }: { entitySecret: string; apiKey: string; walletSetId?: string; client: ThirdwebClient; + isTestnet?: boolean; }) { const circleDeveloperSdk = initiateDeveloperControlledWalletsClient({ apiKey: apiKey, @@ -74,7 +76,7 @@ export async function provisionCircleWallet({ const provisionWalletResponse = await circleDeveloperSdk .createWallets({ accountType: "EOA", - blockchains: ["EVM"], + blockchains: [isTestnet ? "EVM-TESTNET" : "EVM"], count: 1, walletSetId: walletSetId, }) @@ -266,11 +268,13 @@ export async function createCircleWalletDetails({ walletSetId, label, isSmart, + isTestnet, }: { credentialId: string; walletSetId?: string; label?: string; isSmart: boolean; + isTestnet?: boolean; }) { const { walletConfiguration: { circle }, @@ -297,6 +301,7 @@ export async function createCircleWalletDetails({ apiKey: circle.apiKey, client: thirdwebClient, walletSetId, + isTestnet, }); let address = provisionedDetails.account.address; @@ -325,6 +330,7 @@ export async function createCircleWalletDetails({ platformIdentifiers: { circleWalletId: provisionedDetails.provisionedWallet.id, walletSetId: provisionedDetails.walletSetId, + isTestnet: isTestnet ?? false, }, ...(isSmart ? sbwDetails : {}), }, diff --git a/src/shared/db/wallets/get-wallet-details.ts b/src/shared/db/wallets/get-wallet-details.ts index f46b5d31d..0258feb79 100644 --- a/src/shared/db/wallets/get-wallet-details.ts +++ b/src/shared/db/wallets/get-wallet-details.ts @@ -57,7 +57,7 @@ const smartLocalWalletSchema = localWalletSchema .extend({ type: z.literal("smart:local"), }) - .merge(smartWalletPartialSchema) + .merge(smartWalletPartialSchema); const awsKmsWalletSchema = z .object({ @@ -72,7 +72,7 @@ const smartAwsKmsWalletSchema = awsKmsWalletSchema .extend({ type: z.literal("smart:aws-kms"), }) - .merge(smartWalletPartialSchema) + .merge(smartWalletPartialSchema); const gcpKmsWalletSchema = z .object({ @@ -87,7 +87,7 @@ const smartGcpKmsWalletSchema = gcpKmsWalletSchema .extend({ type: z.literal("smart:gcp-kms"), }) - .merge(smartWalletPartialSchema) + .merge(smartWalletPartialSchema); const circleWalletSchema = z .object({ @@ -95,6 +95,7 @@ const circleWalletSchema = z platformIdentifiers: z.object({ circleWalletId: z.string(), walletSetId: z.string(), + isTestnet: z.boolean(), }), credentialId: z.string(), }) @@ -104,7 +105,7 @@ const smartCircleWalletSchema = circleWalletSchema .extend({ type: z.literal("smart:circle"), }) - .merge(smartWalletPartialSchema) + .merge(smartWalletPartialSchema); const walletDetailsSchema = z.discriminatedUnion("type", [ localWalletSchema, From 92431bbc2b067fe00fc9cac6f6e14a272ea9a9c2 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 7 Feb 2025 20:12:02 +0530 Subject: [PATCH 10/11] fix build --- .../db/wallet-credentials/get-all-wallet-credentials.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts b/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts index 88331b4fd..828cbdb6a 100644 --- a/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts +++ b/src/shared/db/wallet-credentials/get-all-wallet-credentials.ts @@ -1,4 +1,4 @@ -import { getPrismaWithPostgresTx } from "../client"; +import { prisma } from "../client"; import type { PrismaTransaction } from "../../schemas/prisma"; interface GetAllWalletCredentialsParams { @@ -11,8 +11,6 @@ export const getAllWalletCredentials = async ({ page = 1, limit = 10, }: GetAllWalletCredentialsParams) => { - const prisma = getPrismaWithPostgresTx(pgtx); - const credentials = await prisma.walletCredentials.findMany({ where: { deletedAt: null, @@ -33,4 +31,4 @@ export const getAllWalletCredentials = async ({ }); return credentials; -}; \ No newline at end of file +}; From 7fbcebab5ed04193389ac66af4869d93fb5935dd Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 7 Feb 2025 20:13:48 +0530 Subject: [PATCH 11/11] wallet credential isDefault: if not true then null --- src/shared/db/wallet-credentials/create-wallet-credential.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/db/wallet-credentials/create-wallet-credential.ts b/src/shared/db/wallet-credentials/create-wallet-credential.ts index 0d91d6f37..db3bfad2d 100644 --- a/src/shared/db/wallet-credentials/create-wallet-credential.ts +++ b/src/shared/db/wallet-credentials/create-wallet-credential.ts @@ -46,7 +46,7 @@ export const createWalletCredential = async ({ data: { type, label, - isDefault: isDefault ?? false, + isDefault: isDefault ?? null, data: { entitySecret: encrypt(finalEntitySecret), },