Skip to content

Commit 7c79dba

Browse files
authored
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
1 parent a828191 commit 7c79dba

File tree

23 files changed

+1215
-43
lines changed

23 files changed

+1215
-43
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dependencies": {
3030
"@aws-sdk/client-kms": "^3.679.0",
3131
"@bull-board/fastify": "^5.23.0",
32+
"@circle-fin/developer-controlled-wallets": "^7.0.0",
3233
"@cloud-cryptographic-wallet/cloud-kms-signer": "^0.1.2",
3334
"@cloud-cryptographic-wallet/signer": "^0.0.5",
3435
"@ethersproject/json-wallets": "^5.7.0",
@@ -63,6 +64,7 @@
6364
"knex": "^3.1.0",
6465
"mnemonist": "^0.39.8",
6566
"node-cron": "^3.0.2",
67+
"ox": "^0.6.9",
6668
"pg": "^8.11.3",
6769
"prisma": "^5.14.0",
6870
"prom-client": "^15.1.3",
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- AlterTable
2+
ALTER TABLE "configuration" ADD COLUMN "walletProviderConfigs" JSONB NOT NULL DEFAULT '{}';
3+
4+
-- AlterTable
5+
ALTER TABLE "wallet_details" ADD COLUMN "credentialId" TEXT,
6+
ADD COLUMN "platformIdentifiers" JSONB;
7+
8+
-- CreateTable
9+
CREATE TABLE "wallet_credentials" (
10+
"id" TEXT NOT NULL,
11+
"type" TEXT NOT NULL,
12+
"label" TEXT NOT NULL,
13+
"data" JSONB NOT NULL,
14+
"isDefault" BOOLEAN NOT NULL DEFAULT false,
15+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16+
"updatedAt" TIMESTAMP(3) NOT NULL,
17+
"deletedAt" TIMESTAMP(3),
18+
19+
CONSTRAINT "wallet_credentials_pkey" PRIMARY KEY ("id")
20+
);
21+
22+
-- CreateIndex
23+
CREATE INDEX "wallet_credentials_type_idx" ON "wallet_credentials"("type");
24+
25+
-- CreateIndex
26+
CREATE UNIQUE INDEX "wallet_credentials_type_is_default_key" ON "wallet_credentials"("type", "isDefault");
27+
28+
-- AddForeignKey
29+
ALTER TABLE "wallet_details" ADD CONSTRAINT "wallet_details_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "wallet_credentials"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "wallet_credentials" ALTER COLUMN "isDefault" DROP NOT NULL;

src/prisma/schema.prisma

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ model Configuration {
2929
cursorDelaySeconds Int @default(2) @map("cursorDelaySeconds")
3030
contractSubscriptionsRetryDelaySeconds String @default("10") @map("contractSubscriptionsRetryDelaySeconds")
3131
32+
// Wallet provider specific configurations, non-credential
33+
walletProviderConfigs Json @default("{}") @map("walletProviderConfigs") /// Eg: { "aws": { "defaultAwsRegion": "us-east-1" }, "gcp": { "defaultGcpKmsLocationId": "us-east1-b" } }
34+
35+
// Legacy wallet provider credentials
36+
// Use default credentials instead, and store non-credential wallet provider configuration in walletProviderConfig
3237
// AWS
3338
awsAccessKeyId String? @map("awsAccessKeyId") /// global config, precedence goes to WalletDetails
3439
awsSecretAccessKey String? @map("awsSecretAccessKey") /// global config, precedence goes to WalletDetails
@@ -79,11 +84,19 @@ model Tokens {
7984
}
8085

8186
model WalletDetails {
82-
address String @id @map("address")
83-
type String @map("type")
84-
label String? @map("label")
87+
address String @id @map("address")
88+
type String @map("type")
89+
label String? @map("label")
90+
8591
// Local
86-
encryptedJson String? @map("encryptedJson")
92+
encryptedJson String? @map("encryptedJson")
93+
94+
// New approach: platform identifiers + wallet credentials
95+
platformIdentifiers Json? @map("platformIdentifiers") /// Eg: { "awsKmsArn": "..." } or { "gcpKmsResourcePath": "..." }
96+
credentialId String? @map("credentialId")
97+
credential WalletCredentials? @relation(fields: [credentialId], references: [id])
98+
99+
// Legacy AWS KMS fields - use platformIdentifiers + WalletCredentials for new wallets
87100
// KMS
88101
awsKmsKeyId String? @map("awsKmsKeyId") /// deprecated and unused, todo: remove with next breaking change. Use awsKmsArn
89102
awsKmsArn String? @map("awsKmsArn")
@@ -97,14 +110,34 @@ model WalletDetails {
97110
gcpKmsResourcePath String? @map("gcpKmsResourcePath") @db.Text
98111
gcpApplicationCredentialEmail String? @map("gcpApplicationCredentialEmail") /// if not available, default to: Configuration.gcpApplicationCredentialEmail
99112
gcpApplicationCredentialPrivateKey String? @map("gcpApplicationCredentialPrivateKey") /// if not available, default to: Configuration.gcpApplicationCredentialPrivateKey
113+
100114
// Smart Backend Wallet
101-
accountSignerAddress String? @map("accountSignerAddress") /// this, and either local, aws or gcp encryptedJson, are required for smart wallet
102-
accountFactoryAddress String? @map("accountFactoryAddress") /// optional even for smart wallet, if not available default factory will be used
103-
entrypointAddress String? @map("entrypointAddress") /// optional even for smart wallet, if not available SDK will use default entrypoint
115+
accountSignerAddress String? @map("accountSignerAddress") /// this, and either local, aws or gcp encryptedJson, are required for smart wallet
116+
accountFactoryAddress String? @map("accountFactoryAddress") /// optional even for smart wallet, if not available default factory will be used
117+
entrypointAddress String? @map("entrypointAddress") /// optional even for smart wallet, if not available SDK will use default entrypoint
104118
105119
@@map("wallet_details")
106120
}
107121

122+
model WalletCredentials {
123+
id String @id @default(uuid())
124+
type String
125+
label String
126+
data Json
127+
isDefault Boolean? @default(false)
128+
129+
createdAt DateTime @default(now())
130+
updatedAt DateTime @updatedAt
131+
deletedAt DateTime?
132+
133+
wallets WalletDetails[]
134+
135+
// A maximum of one default credential per type
136+
@@unique([type, isDefault], name: "unique_default_per_type", map: "wallet_credentials_type_is_default_key")
137+
@@index([type])
138+
@@map("wallet_credentials")
139+
}
140+
108141
model WalletNonce {
109142
address String @map("address")
110143
chainId String @map("chainId")

src/server/routes/backend-wallet/create.ts

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
DEFAULT_ACCOUNT_FACTORY_V0_7,
77
ENTRYPOINT_ADDRESS_v0_7,
88
} from "thirdweb/wallets/smart";
9-
import { WalletType } from "../../../shared/schemas/wallet";
9+
import {
10+
LegacyWalletType,
11+
WalletType,
12+
CircleWalletType,
13+
} from "../../../shared/schemas/wallet";
1014
import { getConfig } from "../../../shared/utils/cache/get-config";
1115
import { createCustomError } from "../../middleware/error";
1216
import { AddressSchema } from "../../schemas/address";
@@ -25,16 +29,33 @@ import {
2529
createSmartGcpWalletDetails,
2630
createSmartLocalWalletDetails,
2731
} from "../../utils/wallets/create-smart-wallet";
32+
import {
33+
CircleWalletError,
34+
createCircleWalletDetails,
35+
} from "../../utils/wallets/circle";
36+
import assert from "node:assert";
2837

29-
const requestBodySchema = Type.Object({
30-
label: Type.Optional(Type.String()),
31-
type: Type.Optional(
32-
Type.Enum(WalletType, {
33-
description:
34-
"Type of new wallet to create. It is recommended to always provide this value. If not provided, the default wallet type will be used.",
35-
}),
36-
),
37-
});
38+
const requestBodySchema = Type.Union([
39+
// Base schema for non-circle wallet types
40+
Type.Object({
41+
label: Type.Optional(Type.String()),
42+
type: Type.Optional(Type.Union([Type.Enum(LegacyWalletType)])),
43+
}),
44+
45+
// Schema for circle and smart:circle wallet types
46+
Type.Object({
47+
label: Type.Optional(Type.String()),
48+
type: Type.Union([Type.Enum(CircleWalletType)]),
49+
isTestnet: Type.Optional(
50+
Type.Boolean({
51+
description:
52+
"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",
53+
}),
54+
),
55+
credentialId: Type.String(),
56+
walletSetId: Type.Optional(Type.String()),
57+
}),
58+
]);
3859

3960
const responseSchema = Type.Object({
4061
result: Type.Object({
@@ -112,6 +133,64 @@ export const createBackendWallet = async (fastify: FastifyInstance) => {
112133
throw e;
113134
}
114135
break;
136+
case CircleWalletType.circle:
137+
{
138+
// we need this if here for typescript to statically type the credentialId and walletSetId
139+
assert(req.body.type === "circle", "Expected circle wallet type");
140+
const { credentialId, walletSetId, isTestnet } = req.body;
141+
142+
try {
143+
const wallet = await createCircleWalletDetails({
144+
label,
145+
isSmart: false,
146+
credentialId,
147+
walletSetId,
148+
isTestnet: isTestnet,
149+
});
150+
151+
walletAddress = getAddress(wallet.address);
152+
} catch (e) {
153+
if (e instanceof CircleWalletError) {
154+
throw createCustomError(
155+
e.message,
156+
StatusCodes.BAD_REQUEST,
157+
"CREATE_CIRCLE_WALLET_ERROR",
158+
);
159+
}
160+
throw e;
161+
}
162+
}
163+
break;
164+
165+
case CircleWalletType.smartCircle:
166+
{
167+
// we need this if here for typescript to statically type the credentialId and walletSetId
168+
assert(req.body.type === "smart:circle", "Expected circle wallet type");
169+
const { credentialId, walletSetId, isTestnet } = req.body;
170+
171+
try {
172+
const wallet = await createCircleWalletDetails({
173+
label,
174+
isSmart: true,
175+
credentialId,
176+
walletSetId,
177+
isTestnet: isTestnet,
178+
});
179+
180+
walletAddress = getAddress(wallet.address);
181+
} catch (e) {
182+
if (e instanceof CircleWalletError) {
183+
throw createCustomError(
184+
e.message,
185+
StatusCodes.BAD_REQUEST,
186+
"CREATE_CIRCLE_WALLET_ERROR",
187+
);
188+
}
189+
throw e;
190+
}
191+
}
192+
break;
193+
115194
case WalletType.smartAwsKms:
116195
try {
117196
const smartAwsWallet = await createSmartAwsWalletDetails({
@@ -161,12 +240,18 @@ export const createBackendWallet = async (fastify: FastifyInstance) => {
161240
walletAddress = getAddress(smartLocalWallet.address);
162241
}
163242
break;
243+
default:
244+
throw createCustomError(
245+
"Unkown wallet type",
246+
StatusCodes.BAD_REQUEST,
247+
"CREATE_WALLET_ERROR",
248+
);
164249
}
165250

166251
reply.status(StatusCodes.OK).send({
167252
result: {
168253
walletAddress,
169-
type: walletType,
254+
type: walletType as WalletType,
170255
status: "success",
171256
},
172257
});

src/server/routes/configuration/wallets/update.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const requestBodySchema = Type.Union([
2121
gcpApplicationCredentialEmail: Type.String(),
2222
gcpApplicationCredentialPrivateKey: Type.String(),
2323
}),
24+
Type.Object({
25+
circleApiKey: Type.String(),
26+
}),
2427
]);
2528

2629
requestBodySchema.examples = [
@@ -107,6 +110,16 @@ export async function updateWalletsConfiguration(fastify: FastifyInstance) {
107110
});
108111
}
109112

113+
if ("circleApiKey" in req.body) {
114+
await updateConfiguration({
115+
walletProviderConfigs: {
116+
circle: {
117+
apiKey: req.body.circleApiKey,
118+
},
119+
},
120+
});
121+
}
122+
110123
const config = await getConfig(false);
111124

112125
const { legacyWalletType_removeInNextBreakingChange, aws, gcp } =

src/server/routes/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ import { revokeWebhook } from "./webhooks/revoke";
113113
import { testWebhookRoute } from "./webhooks/test";
114114
import { readBatchRoute } from "./contract/read/read-batch";
115115
import { sendTransactionBatchAtomicRoute } from "./backend-wallet/send-transaction-batch-atomic";
116+
import { createWalletCredentialRoute } from "./wallet-credentials/create";
117+
import { getWalletCredentialRoute } from "./wallet-credentials/get";
118+
import { getAllWalletCredentialsRoute } from "./wallet-credentials/get-all";
116119

117120
export async function withRoutes(fastify: FastifyInstance) {
118121
// Backend Wallets
@@ -137,6 +140,11 @@ export async function withRoutes(fastify: FastifyInstance) {
137140
await fastify.register(getBackendWalletNonce);
138141
await fastify.register(simulateTransaction);
139142

143+
// Credentials
144+
await fastify.register(createWalletCredentialRoute);
145+
await fastify.register(getWalletCredentialRoute);
146+
await fastify.register(getAllWalletCredentialsRoute);
147+
140148
// Configuration
141149
await fastify.register(getWalletsConfiguration);
142150
await fastify.register(updateWalletsConfiguration);

0 commit comments

Comments
 (0)