From 7c79dba02fb055927f9e0f02316ded8e5024022c Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Fri, 7 Feb 2025 20:16:28 +0530 Subject: [PATCH] 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 5be6c5aa4..e8b1eb1ae 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 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/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 44608303b..1c4a473c4 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 707616b47..107fde4a6 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 dd4ed2b96..b807a9604 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 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/create.ts b/src/server/routes/wallet-credentials/create.ts new file mode 100644 index 000000000..53ec65f3b --- /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 000000000..f7a48a654 --- /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 000000000..c0141efe1 --- /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 000000000..5f1759530 --- /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 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/db/wallet-credentials/create-wallet-credential.ts b/src/shared/db/wallet-credentials/create-wallet-credential.ts new file mode 100644 index 000000000..db3bfad2d --- /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 000000000..828cbdb6a --- /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 000000000..21b7b8a29 --- /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 c6612d628..0258feb79 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 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; diff --git a/src/shared/schemas/wallet.ts b/src/shared/schemas/wallet.ts index 4200e1eb6..6c856e86b 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 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/src/shared/utils/cache/get-contract.ts b/src/shared/utils/cache/get-contract.ts index c590434d0..baa5a49e8 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 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`, ); } diff --git a/yarn.lock b/yarn.lock index 55f064b9b..8e200fea4 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"