Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 6 additions & 7 deletions src/server/routes/wallet-credentials/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -98,6 +96,7 @@ export const createWalletCredentialRoute = async (fastify: FastifyInstance) => {
"WALLET_CREDENTIAL_ERROR",
);
}
throw e;
}
},
});
Expand Down
9 changes: 6 additions & 3 deletions src/server/routes/wallet-credentials/get.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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()]),
Expand Down Expand Up @@ -82,4 +85,4 @@ export async function getWalletCredentialRoute(fastify: FastifyInstance) {
}
},
});
}
}
106 changes: 106 additions & 0 deletions src/server/routes/wallet-credentials/update.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ParamsSchema>;
Body: Static<typeof requestBodySchema>;
Reply: Static<typeof responseSchema>;
}>({
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;
}
},
});
}
44 changes: 3 additions & 41 deletions src/shared/db/wallet-credentials/create-wallet-credential.ts
Original file line number Diff line number Diff line change
@@ -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;
};

Expand All @@ -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;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/db/wallet-credentials/get-wallet-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
55 changes: 55 additions & 0 deletions src/shared/db/wallet-credentials/update-wallet-credential.ts
Original file line number Diff line number Diff line change
@@ -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;
};