Skip to content

Commit

Permalink
Circle Wallets (#841)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
d4mr authored Feb 7, 2025
1 parent a828191 commit 7c79dba
Show file tree
Hide file tree
Showing 23 changed files with 1,215 additions and 43 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "wallet_credentials" ALTER COLUMN "isDefault" DROP NOT NULL;
47 changes: 40 additions & 7 deletions src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
107 changes: 96 additions & 11 deletions src/server/routes/backend-wallet/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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",
},
});
Expand Down
13 changes: 13 additions & 0 deletions src/server/routes/configuration/wallets/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const requestBodySchema = Type.Union([
gcpApplicationCredentialEmail: Type.String(),
gcpApplicationCredentialPrivateKey: Type.String(),
}),
Type.Object({
circleApiKey: Type.String(),
}),
]);

requestBodySchema.examples = [
Expand Down Expand Up @@ -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 } =
Expand Down
8 changes: 8 additions & 0 deletions src/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
Loading

0 comments on commit 7c79dba

Please sign in to comment.