diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 370ab9e6..be174a78 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -116,6 +116,7 @@ import { sendTransactionBatchAtomicRoute } from "./backend-wallet/send-transacti import { createWalletCredentialRoute } from "./wallet-credentials/create"; import { getWalletCredentialRoute } from "./wallet-credentials/get"; import { getAllWalletCredentialsRoute } from "./wallet-credentials/get-all"; +import { updateWalletCredentialRoute } from "./wallet-credentials/update"; export async function withRoutes(fastify: FastifyInstance) { // Backend Wallets @@ -144,6 +145,7 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(createWalletCredentialRoute); await fastify.register(getWalletCredentialRoute); await fastify.register(getAllWalletCredentialsRoute); + await fastify.register(updateWalletCredentialRoute); // Configuration await fastify.register(getWalletsConfiguration); diff --git a/src/server/routes/wallet-credentials/create.ts b/src/server/routes/wallet-credentials/create.ts index 53ec65f3..5afbffc9 100644 --- a/src/server/routes/wallet-credentials/create.ts +++ b/src/server/routes/wallet-credentials/create.ts @@ -9,13 +9,11 @@ import { createCustomError } from "../../middleware/error"; const requestBodySchema = Type.Object({ label: Type.String(), type: Type.Literal("circle"), - entitySecret: Type.Optional( - Type.String({ - description: - "32-byte hex string. If not provided, a random one will be generated.", - pattern: "^[0-9a-fA-F]{64}$", - }), - ), + entitySecret: Type.String({ + description: + "32-byte hex string. Consult https://developers.circle.com/w3s/entity-secret-management to create and register an entity secret.", + pattern: "^[0-9a-fA-F]{64}$", + }), isDefault: Type.Optional( Type.Boolean({ description: @@ -98,6 +96,7 @@ export const createWalletCredentialRoute = async (fastify: FastifyInstance) => { "WALLET_CREDENTIAL_ERROR", ); } + throw e; } }, }); diff --git a/src/server/routes/wallet-credentials/get.ts b/src/server/routes/wallet-credentials/get.ts index c0141efe..f132c08a 100644 --- a/src/server/routes/wallet-credentials/get.ts +++ b/src/server/routes/wallet-credentials/get.ts @@ -1,7 +1,10 @@ import { Type, type Static } from "@sinclair/typebox"; import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; -import { getWalletCredential, WalletCredentialsError } from "../../../shared/db/wallet-credentials/get-wallet-credential"; +import { + getWalletCredential, + WalletCredentialsError, +} from "../../../shared/db/wallet-credentials/get-wallet-credential"; import { createCustomError } from "../../middleware/error"; import { standardResponseSchema } from "../../schemas/shared-api-schemas"; @@ -16,7 +19,7 @@ const responseSchema = Type.Object({ id: Type.String(), type: Type.String(), label: Type.Union([Type.String(), Type.Null()]), - isDefault: Type.Boolean(), + isDefault: Type.Union([Type.Boolean(), Type.Null()]), createdAt: Type.String(), updatedAt: Type.String(), deletedAt: Type.Union([Type.String(), Type.Null()]), @@ -82,4 +85,4 @@ export async function getWalletCredentialRoute(fastify: FastifyInstance) { } }, }); -} \ No newline at end of file +} diff --git a/src/server/routes/wallet-credentials/update.ts b/src/server/routes/wallet-credentials/update.ts new file mode 100644 index 00000000..e7a4eb50 --- /dev/null +++ b/src/server/routes/wallet-credentials/update.ts @@ -0,0 +1,106 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { updateWalletCredential } from "../../../shared/db/wallet-credentials/update-wallet-credential"; +import { WalletCredentialsError } from "../../../shared/db/wallet-credentials/get-wallet-credential"; +import { createCustomError } from "../../middleware/error"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; + +const ParamsSchema = Type.Object({ + id: Type.String({ + description: "The ID of the wallet credential to update.", + }), +}); + +const requestBodySchema = Type.Object({ + label: Type.Optional(Type.String()), + isDefault: Type.Optional( + Type.Boolean({ + description: + "Whether this credential should be set as the default for its type. Only one credential can be default per type.", + }), + ), + entitySecret: Type.Optional( + Type.String({ + description: + "32-byte hex string. Consult https://developers.circle.com/w3s/entity-secret-management to create and register an entity secret.", + pattern: "^[0-9a-fA-F]{64}$", + }), + ), +}); + +const responseSchema = Type.Object({ + result: Type.Object({ + id: Type.String(), + type: Type.String(), + label: Type.Union([Type.String(), Type.Null()]), + isDefault: Type.Union([Type.Boolean(), Type.Null()]), + createdAt: Type.String(), + updatedAt: Type.String(), + }), +}); + +responseSchema.example = { + result: { + id: "123e4567-e89b-12d3-a456-426614174000", + type: "circle", + label: "My Updated Circle Credential", + isDefault: true, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, +}; + +export async function updateWalletCredentialRoute(fastify: FastifyInstance) { + fastify.route<{ + Params: Static; + Body: Static; + Reply: Static; + }>({ + method: "PUT", + url: "/wallet-credentials/:id", + schema: { + summary: "Update wallet credential", + description: + "Update a wallet credential's label, default status, and entity secret.", + tags: ["Wallet Credentials"], + operationId: "updateWalletCredential", + params: ParamsSchema, + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (req, reply) => { + try { + const credential = await updateWalletCredential({ + id: req.params.id, + label: req.body.label, + isDefault: req.body.isDefault, + entitySecret: req.body.entitySecret, + }); + + reply.status(StatusCodes.OK).send({ + result: { + id: credential.id, + type: credential.type, + label: credential.label, + isDefault: credential.isDefault, + createdAt: credential.createdAt.toISOString(), + updatedAt: credential.updatedAt.toISOString(), + }, + }); + } catch (e) { + if (e instanceof WalletCredentialsError) { + throw createCustomError( + e.message, + StatusCodes.NOT_FOUND, + "WALLET_CREDENTIAL_NOT_FOUND", + ); + } + throw e; + } + }, + }); +} diff --git a/src/shared/db/wallet-credentials/create-wallet-credential.ts b/src/shared/db/wallet-credentials/create-wallet-credential.ts index db3bfad2..51b9cae7 100644 --- a/src/shared/db/wallet-credentials/create-wallet-credential.ts +++ b/src/shared/db/wallet-credentials/create-wallet-credential.ts @@ -1,16 +1,13 @@ import { encrypt } from "../../utils/crypto"; -import { registerEntitySecretCiphertext } from "@circle-fin/developer-controlled-wallets"; import { prisma } from "../client"; import { getConfig } from "../../utils/cache/get-config"; import { WalletCredentialsError } from "./get-wallet-credential"; -import { randomBytes } from "node:crypto"; -import { cirlceEntitySecretZodSchema } from "../../schemas/wallet"; // will be expanded to be a discriminated union of all supported wallet types export type CreateWalletCredentialsParams = { type: "circle"; label: string; - entitySecret?: string; + entitySecret: string; isDefault?: boolean; }; @@ -21,58 +18,23 @@ export const createWalletCredential = async ({ isDefault, }: CreateWalletCredentialsParams) => { const { walletConfiguration } = await getConfig(); - switch (type) { case "circle": { const circleApiKey = walletConfiguration.circle?.apiKey; - if (!circleApiKey) { throw new WalletCredentialsError("No Circle API Key Configured"); } - - if (entitySecret) { - const { error } = cirlceEntitySecretZodSchema.safeParse(entitySecret); - if (error) { - throw new WalletCredentialsError( - "Invalid provided entity secret for Circle", - ); - } - } - - // If entitySecret is not provided, generate a random one - const finalEntitySecret = entitySecret ?? randomBytes(32).toString("hex"); // Create the wallet credentials const walletCredentials = await prisma.walletCredentials.create({ data: { type, label, - isDefault: isDefault ?? null, + isDefault: isDefault || null, data: { - entitySecret: encrypt(finalEntitySecret), + entitySecret: encrypt(entitySecret), }, }, }); - - // try registering the entity secret. See: https://developers.circle.com/w3s/developer-controlled-create-your-first-wallet - try { - await registerEntitySecretCiphertext({ - apiKey: circleApiKey, - entitySecret: finalEntitySecret, - recoveryFileDownloadPath: "/dev/null", - }); - } catch (e: unknown) { - // If failed to registeer, permanently delete erroneously created credential - await prisma.walletCredentials.delete({ - where: { - id: walletCredentials.id, - }, - }); - - throw new WalletCredentialsError( - `Could not register Entity Secret with Circle\n${JSON.stringify(e)}`, - ); - } - return walletCredentials; } } diff --git a/src/shared/db/wallet-credentials/get-wallet-credential.ts b/src/shared/db/wallet-credentials/get-wallet-credential.ts index 21b7b8a2..f1cf5107 100644 --- a/src/shared/db/wallet-credentials/get-wallet-credential.ts +++ b/src/shared/db/wallet-credentials/get-wallet-credential.ts @@ -18,7 +18,7 @@ const walletCredentialsSchema = z.object({ data: z.object({ entitySecret: z.string(), }), - isDefault: z.boolean(), + isDefault: z.boolean().nullable(), createdAt: z.date(), updatedAt: z.date(), deletedAt: z.date().nullable(), diff --git a/src/shared/db/wallet-credentials/update-wallet-credential.ts b/src/shared/db/wallet-credentials/update-wallet-credential.ts new file mode 100644 index 00000000..018eb562 --- /dev/null +++ b/src/shared/db/wallet-credentials/update-wallet-credential.ts @@ -0,0 +1,55 @@ +import { getWalletCredential } from "./get-wallet-credential"; +import { encrypt } from "../../utils/crypto"; +import { prisma } from "../client"; +import { cirlceEntitySecretZodSchema } from "../../schemas/wallet"; + +interface UpdateWalletCredentialParams { + id: string; + label?: string; + isDefault?: boolean; + entitySecret?: string; +} + +type UpdateData = { + label?: string; + isDefault: boolean | null; + data?: { + entitySecret: string; + }; +}; + +export const updateWalletCredential = async ({ + id, + label, + isDefault, + entitySecret, +}: UpdateWalletCredentialParams) => { + // First check if credential exists + await getWalletCredential({ id }); + + // If entitySecret is provided, validate and encrypt it + const data: UpdateData = { + label, + isDefault: isDefault || null, + }; + + if (entitySecret) { + // Validate the entity secret + cirlceEntitySecretZodSchema.parse(entitySecret); + + // Only update data field if entitySecret is provided + data.data = { + entitySecret: encrypt(entitySecret), + }; + } + + // Update the credential + const updatedCredential = await prisma.walletCredentials.update({ + where: { + id, + }, + data, + }); + + return updatedCredential; +};