diff --git a/apps/sdp-api/.env.local.example b/apps/sdp-api/.env.local.example index fe5b608691..93243d4914 100644 --- a/apps/sdp-api/.env.local.example +++ b/apps/sdp-api/.env.local.example @@ -174,6 +174,7 @@ KORA_DEVNET_RPC_URL=https://devnet.helius-rpc.com/?api-key= # FIREBLOCKS_API_KEY=YOUR_FIREBLOCKS_API_KEY # FIREBLOCKS_API_SECRET="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" # FIREBLOCKS_API_BASE_URL=https://api.fireblocks.io +# Import approved Fireblocks Solana address-book entries as SDP contacts (default: true). # FIREBLOCKS_ASSET_ID=SOL_TEST # FIREBLOCKS_VAULT_ID=YOUR_FIREBLOCKS_VAULT_ID diff --git a/apps/sdp-api/package.json b/apps/sdp-api/package.json index b592f2cc09..57f6b40253 100644 --- a/apps/sdp-api/package.json +++ b/apps/sdp-api/package.json @@ -106,6 +106,8 @@ "pino-pretty": "13.1.3", "tsx": "4.23.15", "typescript": "6.0.3", - "vitest": "4.1.11" + "vitest": "4.1.11", + "@solana/keychain-core": "1.4.0", + "@solana/keychain-fireblocks": "1.4.0" } } diff --git a/apps/sdp-api/src/app.ts b/apps/sdp-api/src/app.ts index 1806b2beee..68b7ff8a67 100644 --- a/apps/sdp-api/src/app.ts +++ b/apps/sdp-api/src/app.ts @@ -46,6 +46,7 @@ import { earnRoutes as earn } from "@/routes/earn"; import { health } from "@/routes/health"; import { heliusRings } from "@/routes/helius-rings"; import { internalCustody } from "@/routes/internal-custody"; +import { internalFireblocks } from "@/routes/internal-fireblocks"; import { internalHeliusRings } from "@/routes/internal-helius-rings"; import { internalNetwork } from "@/routes/internal-network"; import { issuance } from "@/routes/issuance"; @@ -392,6 +393,8 @@ export function createApp(deps: AppDeps): Hono<{ Bindings: Env }> { app.route("/internal/dashboard/custody", internalCustody); app.use("/internal/dashboard/wallets/*", requireModule("custody")); app.route("/internal/dashboard/wallets", internalWallets); + app.use("/internal/dashboard/fireblocks/*", requireModule("custody")); + app.route("/internal/dashboard/fireblocks", internalFireblocks); app.route("/internal/dashboard/helius-rings", internalHeliusRings); app.route("/internal/dashboard/network", internalNetwork); diff --git a/apps/sdp-api/src/cron/secret-retirements.ts b/apps/sdp-api/src/cron/secret-retirements.ts index 75b5767e05..6bf9e26fc6 100644 --- a/apps/sdp-api/src/cron/secret-retirements.ts +++ b/apps/sdp-api/src/cron/secret-retirements.ts @@ -1,3 +1,5 @@ +import { getDb } from "@/db/client"; +import { eraseExpiredFireblocksCsrs } from "@/services/custody/fireblocks-registration.service"; import { retireOrphanedSecrets } from "@/services/jobs/retire-orphaned-secrets"; import { alwaysEnabled, defineCron } from "./define-cron"; @@ -14,6 +16,7 @@ export const secretRetirementsCron = defineCron({ effect: "observes", enabled: alwaysEnabled, run: async (env) => { + await eraseExpiredFireblocksCsrs(getDb(env)); await retireOrphanedSecrets(env); }, }); diff --git a/apps/sdp-api/src/db/migrations/postgres/0153_fireblocks_csr_requests.sql b/apps/sdp-api/src/db/migrations/postgres/0153_fireblocks_csr_requests.sql new file mode 100644 index 0000000000..cf3ff9dd9a --- /dev/null +++ b/apps/sdp-api/src/db/migrations/postgres/0153_fireblocks_csr_requests.sql @@ -0,0 +1,28 @@ +-- Private RSA material exists here only during CSR onboarding. Once copied +-- into the credential lifecycle's secret store it is erased from this row. +CREATE TABLE fireblocks_csr_requests ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL, + csr_pem TEXT NOT NULL, + encrypted_private_key TEXT, + claim_fingerprint TEXT, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT sdp_iso_now(), + expires_at TEXT NOT NULL, + consumed_at TEXT, + UNIQUE (organization_id, project_id, idempotency_key), + CHECK (consumed_at IS NULL OR encrypted_private_key IS NULL) +); +CREATE INDEX idx_fireblocks_csr_pending + ON fireblocks_csr_requests (organization_id, project_id, created_at DESC) + WHERE encrypted_private_key IS NOT NULL; +CREATE INDEX idx_fireblocks_csr_expiry ON fireblocks_csr_requests (expires_at) + WHERE encrypted_private_key IS NOT NULL; +ALTER TABLE fireblocks_csr_requests ENABLE ROW LEVEL SECURITY; +ALTER TABLE fireblocks_csr_requests FORCE ROW LEVEL SECURITY; +CREATE POLICY sdp_tenant_isolation ON fireblocks_csr_requests + USING (sdp_tenant_isolation_allows(organization_id)) + WITH CHECK (sdp_tenant_isolation_allows(organization_id)); diff --git a/apps/sdp-api/src/db/migrations/postgres/0154_fireblocks_byok.sql b/apps/sdp-api/src/db/migrations/postgres/0154_fireblocks_byok.sql new file mode 100644 index 0000000000..ade4700b2d --- /dev/null +++ b/apps/sdp-api/src/db/migrations/postgres/0154_fireblocks_byok.sql @@ -0,0 +1,18 @@ +-- Fireblocks uses the existing stored-credential lifecycle, including durable +-- GCP creation and container cleanup. Apply before deploying its BYOK runtime. +ALTER TABLE provider_credentials + DROP CONSTRAINT provider_credentials_creating_location_check, + ADD CONSTRAINT provider_credentials_creating_location_check CHECK ( + status <> 'creating' OR ( + provider IN ('privy', 'fireblocks') AND source = 'stored' + AND storage_backend = 'gcp_secret_manager' AND secret_ref IS NOT NULL + AND secret_version_ref IS NULL AND encrypted_secret_payload IS NULL + ) + ) NOT VALID, + DROP CONSTRAINT provider_credentials_scan_owner_check, + ADD CONSTRAINT provider_credentials_scan_owner_check CHECK ( + secret_next_scan_at IS NULL OR ( + provider IN ('privy', 'fireblocks') AND source = 'stored' + AND storage_backend = 'gcp_secret_manager' AND secret_ref IS NOT NULL + ) + ) NOT VALID; diff --git a/apps/sdp-api/src/db/migrations/postgres/0156_fireblocks_unfinished_index.sql b/apps/sdp-api/src/db/migrations/postgres/0156_fireblocks_unfinished_index.sql new file mode 100644 index 0000000000..f1f826312f --- /dev/null +++ b/apps/sdp-api/src/db/migrations/postgres/0156_fireblocks_unfinished_index.sql @@ -0,0 +1,6 @@ +-- sdp:migration-mode: non-transactional +-- Keep the Privy index for older revisions; build this without blocking wallet setup. +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_custody_connections_fireblocks_unfinished + ON custody_connections(organization_id, project_id, provider) + WHERE provider = 'fireblocks' AND project_id IS NOT NULL + AND status IN ('pending', 'checking'); diff --git a/apps/sdp-api/src/db/migrations/postgres/0157_fireblocks_credential_checks.sql b/apps/sdp-api/src/db/migrations/postgres/0157_fireblocks_credential_checks.sql new file mode 100644 index 0000000000..7cc83884ca --- /dev/null +++ b/apps/sdp-api/src/db/migrations/postgres/0157_fireblocks_credential_checks.sql @@ -0,0 +1,2 @@ +ALTER TABLE provider_credentials VALIDATE CONSTRAINT provider_credentials_creating_location_check; +ALTER TABLE provider_credentials VALIDATE CONSTRAINT provider_credentials_scan_owner_check; diff --git a/apps/sdp-api/src/db/repositories/payments.repository.postgres.ts b/apps/sdp-api/src/db/repositories/payments.repository.postgres.ts index bbb1e1eed4..84926c5b70 100644 --- a/apps/sdp-api/src/db/repositories/payments.repository.postgres.ts +++ b/apps/sdp-api/src/db/repositories/payments.repository.postgres.ts @@ -303,6 +303,10 @@ function buildUpdateTransferWhere(input: UpdatePaymentTransferInput) { if (input.requireUnstarted) { clauses.push("submission_started_at IS NULL"); } + if (input.expectedProviderReference !== undefined) { + clauses.push("provider_reference IS NOT DISTINCT FROM ?"); + values.push(input.expectedProviderReference); + } // Ramp statuses move only through RAMP_TRANSFER_TRANSITIONS. if (input.status !== undefined) { clauses.push("type <> ALL(?)"); @@ -1252,9 +1256,64 @@ export function createPostgresPaymentsRepository( }; }, + async claimFireblocksTransferSubmission(input) { + assertScope(input); + const scope = buildTransferScopeWhere({ + organizationId: input.organizationId, + projectId: input.projectId, + includeAllOrganizationProjects: canAccessAllOrganizationProjects, + extraClauses: [ + "id = ?", + "status = 'processing'", + "provider_data->'fireblocksTransfer' IS NOT NULL", + // biome-ignore lint/security/noSecrets: SQL JSON field name, not a credential. + "provider_data->>'fireblocksSubmissionStartedAt' IS NULL", + ], + extraValues: [input.transferId], + }); + const row = await db + .prepare(`WITH pt AS ( + UPDATE payment_transfers SET provider_data = provider_data || ?::jsonb, updated_at = ? + WHERE ${scope.where} RETURNING * + ) SELECT pt.*, ${PAYMENT_TRANSACTION_KIND_SQL} AS kind FROM pt`) + .bind( + JSON.stringify({ fireblocksSubmissionStartedAt: input.now }), + input.now, + ...scope.values + ) + .first(); + return row ? mapTransferRow(row) : null; + }, + + async claimFireblocksTransferPoll(input) { + assertScope(input); + const scope = buildTransferScopeWhere({ + organizationId: input.organizationId, + projectId: input.projectId, + includeAllOrganizationProjects: canAccessAllOrganizationProjects, + extraClauses: [ + "id = ?", + "status = 'processing'", + "provider_data->'fireblocksTransfer' IS NOT NULL", + // biome-ignore lint/security/noSecrets: SQL JSON field names, not credentials. + "(provider_data->>'fireblocksLastPolledAt' IS NULL OR provider_data->>'fireblocksLastPolledAt' < ?)", + ], + extraValues: [input.transferId, input.polledBefore], + }); + const row = await db + .prepare(`WITH pt AS ( + UPDATE payment_transfers SET provider_data = provider_data || ?::jsonb, updated_at = ? + WHERE ${scope.where} RETURNING * + ) SELECT pt.*, ${PAYMENT_TRANSACTION_KIND_SQL} AS kind FROM pt`) + .bind(JSON.stringify({ fireblocksLastPolledAt: input.now }), input.now, ...scope.values) + .first(); + return row ? mapTransferRow(row) : null; + }, + async listTransfersByStatus({ environment, statuses, + fireblocks, types, hasSignature, createdBefore, @@ -1274,6 +1333,14 @@ export function createPostgresPaymentsRepository( const clauses = ["p.environment = ?", `pt.status IN (${buildInClause(statuses.length)})`]; const values: unknown[] = [environment, ...statuses]; + if (fireblocks !== undefined) { + clauses.push( + fireblocks + ? "pt.provider_data->'fireblocksTransfer' IS NOT NULL" + : "pt.provider_data->'fireblocksTransfer' IS NULL" + ); + } + if (types?.length) { clauses.push(`pt.type IN (${buildInClause(types.length)})`); values.push(...types); diff --git a/apps/sdp-api/src/db/repositories/payments.repository.ts b/apps/sdp-api/src/db/repositories/payments.repository.ts index 60ab715b26..23bf6c4e3a 100644 --- a/apps/sdp-api/src/db/repositories/payments.repository.ts +++ b/apps/sdp-api/src/db/repositories/payments.repository.ts @@ -110,6 +110,8 @@ export interface UpdatePaymentTransferInput { /** Timeout CAS: only an unsigned row with no submission start may match. */ requireUnsigned?: true; expectedStatus?: PaymentTransferStatus; + /** Guard a missing-provider verdict against a concurrent acknowledgement. */ + expectedProviderReference?: string | null; status?: PaymentTransferStatus; signature?: string | null; serializedTx?: string | null; @@ -186,6 +188,7 @@ export interface ListTransfersByStatusInput { /** Only transfers whose project is in this environment, so one job run reads one cluster. */ environment: SdpEnvironment; statuses: PaymentTransferStatus[]; + fireblocks?: boolean; types?: readonly PaymentTransferType[]; hasSignature?: boolean; createdBefore?: string; @@ -218,6 +221,21 @@ export interface PaymentsRepositoryContext { } export interface PaymentsRepository { + /** Exactly one provider submission attempt per durable Fireblocks intent. */ + claimFireblocksTransferSubmission(input: { + transferId: string; + organizationId: string; + projectId: string; + now: string; + }): Promise; + /** Throttle and serialize provider reads across dashboard requests and cron workers. */ + claimFireblocksTransferPoll(input: { + transferId: string; + organizationId: string; + projectId: string | null; + polledBefore: string; + now: string; + }): Promise; createTransfer(input: CreatePaymentTransferInput): Promise; /** * Claims an awaiting off-ramp row for its crypto deposit transaction through diff --git a/apps/sdp-api/src/lib/feature-flags.test.ts b/apps/sdp-api/src/lib/feature-flags.test.ts index 4262018780..2c339aa31f 100644 --- a/apps/sdp-api/src/lib/feature-flags.test.ts +++ b/apps/sdp-api/src/lib/feature-flags.test.ts @@ -4,6 +4,7 @@ import { isByokCustodyProvider, } from "@sdp/types/custody"; import { + isCustodyProviderInReleaseChannel, isModuleInReleaseChannel, isRampProviderInReleaseChannel, resolveSdpReleaseChannel, @@ -134,10 +135,10 @@ describe("isCustodyProviderAvailable", () => { ).toBe(true); }); - it.each(BYOK_CUSTODY_PROVIDERS)("offers BYOK %s", (provider) => { + it.each(BYOK_CUSTODY_PROVIDERS)("offers BYOK %s only within its channel", (provider) => { expect( isCustodyProviderAvailable({ SDP_RELEASE_CHANNEL: releaseChannel }, provider, "byok") - ).toBe(true); + ).toBe(provider !== "fireblocks" || releaseChannel !== "stable"); }); it.each(nonByokCustodyProviders)("never offers BYOK %s", (provider) => { @@ -295,7 +296,16 @@ describe("release channels", () => { anchorage: "beta", utila: "beta", }); - expect(SDP_BYOK_CUSTODY_PROVIDER_STAGES).toEqual({ privy: "stable" }); + expect(SDP_BYOK_CUSTODY_PROVIDER_STAGES).toEqual({ privy: "stable", fireblocks: "beta" }); + }); + + it.each(SDP_RELEASE_CHANNEL_NAMES)("gates Fireblocks custody modes on %s", (channel) => { + expect(isCustodyProviderInReleaseChannel(channel, "fireblocks", "byok")).toBe( + channel !== "stable" + ); + expect(isCustodyProviderInReleaseChannel(channel, "fireblocks", "managed")).toBe( + channel !== "stable" + ); }); it.each(SDP_RELEASE_CHANNEL_NAMES)( diff --git a/apps/sdp-api/src/openapi/route-actions.generated.json b/apps/sdp-api/src/openapi/route-actions.generated.json index 6bc6a3d259..92299a7b2f 100644 --- a/apps/sdp-api/src/openapi/route-actions.generated.json +++ b/apps/sdp-api/src/openapi/route-actions.generated.json @@ -17,7 +17,9 @@ "GET /internal/dashboard/custody/connections": "wallets.configure", "GET /internal/dashboard/custody/connections/:_": "wallets.configure", "GET /internal/dashboard/custody/connections/:_/provider-credential": "wallets.configure", + "GET /internal/dashboard/custody/fireblocks/csr": "wallets.configure", "GET /internal/dashboard/custody/providers": "wallets.configure", + "GET /internal/dashboard/fireblocks/destination": "wallets.read", "GET /internal/dashboard/helius-rings/connections": "helius_rings.configure", "GET /v1/api-keys": "api_keys.read", "GET /v1/api-keys/:_": "api_keys.read", @@ -149,6 +151,8 @@ "POST /internal/dashboard/custody/connections/:_/complete": "wallets.configure", "POST /internal/dashboard/custody/connections/:_/deactivate": "wallets.configure", "POST /internal/dashboard/custody/connections/:_/provider-credentials": "wallets.configure", + "POST /internal/dashboard/custody/fireblocks/csr": "wallets.configure", + "POST /internal/dashboard/custody/fireblocks/vaults": "wallets.configure", "POST /internal/dashboard/custody/provider-credentials": "wallets.configure", "POST /internal/dashboard/custody/provider-credentials/:_/complete-rotation": "wallets.configure", "POST /internal/dashboard/custody/provider-credentials/:_/deactivate": "wallets.configure", diff --git a/apps/sdp-api/src/openapi/schemas/payments.ts b/apps/sdp-api/src/openapi/schemas/payments.ts index 867f1f7ecd..300b05d0a8 100644 --- a/apps/sdp-api/src/openapi/schemas/payments.ts +++ b/apps/sdp-api/src/openapi/schemas/payments.ts @@ -306,6 +306,17 @@ const rampTransferSettlementSchema = moonpayRampSettlementSchema; export const transferSchema = z .object({ + fireblocks: z + .object({ + transactionId: z.string().nullable(), + status: z.string(), + subStatus: z.string().nullable(), + }) + .optional() + .openapi({ + description: + "Native Fireblocks transfer tracking. A processing payment may await approval in Fireblocks; network fees are paid by its source vault. A missing transaction ID means submission is still being reconciled. Do not create a replacement payment while its outcome is unknown.", + }), id: transferIdParamSchema, organizationId: orgIdParamSchema, custodyWalletId: z.string().nullable().openapi({ diff --git a/apps/sdp-api/src/openapi/spec.test.ts b/apps/sdp-api/src/openapi/spec.test.ts index fcd07f25a4..ce768a2a14 100644 --- a/apps/sdp-api/src/openapi/spec.test.ts +++ b/apps/sdp-api/src/openapi/spec.test.ts @@ -1,5 +1,8 @@ import { readFileSync } from "node:fs"; -import { IDEMPOTENCY_REQUIRED_ROUTES } from "@sdp/types/idempotency-required-routes"; +import { + IDEMPOTENCY_REQUIRED_ROUTES, + INTERNAL_IDEMPOTENCY_REQUIRED_ROUTES, +} from "@sdp/types/idempotency-required-routes"; import { SDP_RELEASE_CHANNEL_NAMES, SDP_RELEASE_CHANNELS } from "@sdp/types/release-channels"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -985,7 +988,11 @@ function operationFor(document: ReturnType, route: describe("OpenAPI Idempotency-Key headers", () => { // Declared routes with no public operation yet; adding one is deliberate. - const UNDOCUMENTED: readonly string[] = ["POST /v1/payments/requests"]; + const UNDOCUMENTED: readonly string[] = [ + "POST /v1/payments/requests", + // Dashboard-only CSR registration must not be promoted to a public API. + ...INTERNAL_IDEMPOTENCY_REQUIRED_ROUTES, + ]; function declaredModes() { const modes = new Map>(); @@ -1070,6 +1077,13 @@ describe("OpenAPI Idempotency-Key headers", () => { * to the list fails here until each surface says so. */ describe("Idempotency-Key required routes on public surfaces", () => { + it("keeps internal key-generation routes out of public API surfaces", () => { + const publicDocument = createPublicOpenApiDocument(EXPERIMENTAL); + for (const route of INTERNAL_IDEMPOTENCY_REQUIRED_ROUTES) { + expect(operationFor(publicDocument, route)).toBeUndefined(); + } + }); + const postmanItemSchema = z.object({ get item() { return z.array(postmanItemSchema).optional(); diff --git a/apps/sdp-api/src/routes/__snapshots__/route-actions.test.ts.snap b/apps/sdp-api/src/routes/__snapshots__/route-actions.test.ts.snap index 746bd6d115..53429b59ee 100644 --- a/apps/sdp-api/src/routes/__snapshots__/route-actions.test.ts.snap +++ b/apps/sdp-api/src/routes/__snapshots__/route-actions.test.ts.snap @@ -246,11 +246,14 @@ exports[`route actions > pins each route's action 1`] = ` "wallets.configure GET /internal/dashboard/custody/connections", "wallets.configure GET /internal/dashboard/custody/connections/:connectionId", "wallets.configure GET /internal/dashboard/custody/connections/:connectionId/provider-credential", + "wallets.configure GET /internal/dashboard/custody/fireblocks/csr", "wallets.configure GET /internal/dashboard/custody/providers", "wallets.configure POST /internal/dashboard/custody/connections/:connectionId/cancel", "wallets.configure POST /internal/dashboard/custody/connections/:connectionId/complete", "wallets.configure POST /internal/dashboard/custody/connections/:connectionId/deactivate", "wallets.configure POST /internal/dashboard/custody/connections/:connectionId/provider-credentials", + "wallets.configure POST /internal/dashboard/custody/fireblocks/csr", + "wallets.configure POST /internal/dashboard/custody/fireblocks/vaults", "wallets.configure POST /internal/dashboard/custody/provider-credentials", "wallets.configure POST /internal/dashboard/custody/provider-credentials/:credentialId/complete-rotation", "wallets.configure POST /internal/dashboard/custody/provider-credentials/:credentialId/deactivate", @@ -262,6 +265,7 @@ exports[`route actions > pins each route's action 1`] = ` "wallets.execute POST /v1/wallets/signer-check", "wallets.manage PATCH /v1/wallets/:walletId", "wallets.manage POST /v1/wallets", + "wallets.read GET /internal/dashboard/fireblocks/destination", "wallets.read GET /v1/payments/wallets/:walletId/balances", "wallets.read GET /v1/wallets", "wallets.read GET /v1/wallets/:walletId", diff --git a/apps/sdp-api/src/routes/__snapshots__/route-modules.test.ts.snap b/apps/sdp-api/src/routes/__snapshots__/route-modules.test.ts.snap index 55032b112d..8f325de7a8 100644 --- a/apps/sdp-api/src/routes/__snapshots__/route-modules.test.ts.snap +++ b/apps/sdp-api/src/routes/__snapshots__/route-modules.test.ts.snap @@ -48,7 +48,9 @@ exports[`API route ownership > lists every route with its owner 1`] = ` "custody GET /internal/dashboard/custody/connections", "custody GET /internal/dashboard/custody/connections/:connectionId", "custody GET /internal/dashboard/custody/connections/:connectionId/provider-credential", + "custody GET /internal/dashboard/custody/fireblocks/csr", "custody GET /internal/dashboard/custody/providers", + "custody GET /internal/dashboard/fireblocks/destination", "custody GET /v1/wallets", "custody GET /v1/wallets/:walletId", "custody GET /v1/wallets/aggregate", @@ -59,6 +61,8 @@ exports[`API route ownership > lists every route with its owner 1`] = ` "custody POST /internal/dashboard/custody/connections/:connectionId/complete", "custody POST /internal/dashboard/custody/connections/:connectionId/deactivate", "custody POST /internal/dashboard/custody/connections/:connectionId/provider-credentials", + "custody POST /internal/dashboard/custody/fireblocks/csr", + "custody POST /internal/dashboard/custody/fireblocks/vaults", "custody POST /internal/dashboard/custody/provider-credentials", "custody POST /internal/dashboard/custody/provider-credentials/:credentialId/complete-rotation", "custody POST /internal/dashboard/custody/provider-credentials/:credentialId/deactivate", diff --git a/apps/sdp-api/src/routes/earn.vault-share-reconciliation.test.ts b/apps/sdp-api/src/routes/earn.vault-share-reconciliation.test.ts index 0ed20effa6..8fbb350769 100644 --- a/apps/sdp-api/src/routes/earn.vault-share-reconciliation.test.ts +++ b/apps/sdp-api/src/routes/earn.vault-share-reconciliation.test.ts @@ -214,6 +214,8 @@ async function finalizeMovement(movementId: string): Promise { * solver queue escrows the claim's shares, a Hastra operator redemption burns * them for delegated wYLDS. Terminal rows stamp the metadata 0113 requires. */ +let lastWithdrawalTimestamp = 0; + async function seedCustodyWithdrawalRequest(params: { position: { id: string; @@ -230,6 +232,10 @@ async function seedCustodyWithdrawalRequest(params: { }): Promise { const requestId = generateEarnVaultWithdrawalRequestId(); const solverQueue = params.mechanism === "solver_queue"; + // These fixtures describe successive lifecycle events. Equal millisecond + // timestamps fall back to random IDs and can reverse a cancellation/fulfillment. + lastWithdrawalTimestamp = Math.max(Date.now(), lastWithdrawalTimestamp + 1); + const timestamp = new Date(lastWithdrawalTimestamp).toISOString(); await getDb(env) .prepare( `INSERT INTO earn_vault_withdrawal_requests ( @@ -264,10 +270,10 @@ async function seedCustodyWithdrawalRequest(params: { solverQueue ? null : (params.intermediateAmount ?? "1.01"), crypto.randomUUID(), `fingerprint_${crypto.randomUUID()}`, - params.status === "cancelled" ? (params.closedAt ?? new Date().toISOString()) : null, + params.status === "cancelled" ? (params.closedAt ?? timestamp) : null, params.status === "failed" ? "request never landed" : null, - params.status === "fulfilled" ? (params.closedAt ?? new Date().toISOString()) : null, - params.createdAt ?? new Date().toISOString() + params.status === "fulfilled" ? (params.closedAt ?? timestamp) : null, + params.createdAt ?? timestamp ) .run(); return requestId; @@ -295,6 +301,7 @@ interface ReportBody { } beforeEach(async () => { + lastWithdrawalTimestamp = 0; env.MARKETS_ENABLED = "true"; env.EARN_ENABLED = "true"; await seedTestDatabase(env); diff --git a/apps/sdp-api/src/routes/idempotency-route-inventory.test.ts b/apps/sdp-api/src/routes/idempotency-route-inventory.test.ts index fb618eac64..0841efc256 100644 --- a/apps/sdp-api/src/routes/idempotency-route-inventory.test.ts +++ b/apps/sdp-api/src/routes/idempotency-route-inventory.test.ts @@ -1,4 +1,7 @@ -import { IDEMPOTENCY_REQUIRED_ROUTES } from "@sdp/types/idempotency-required-routes"; +import { + IDEMPOTENCY_REQUIRED_ROUTES, + INTERNAL_IDEMPOTENCY_REQUIRED_ROUTES, +} from "@sdp/types/idempotency-required-routes"; import { describe, expect, it } from "vitest"; import { declaredAuthorization } from "@/middleware/authorize"; import { declaredIdempotency, declaredIdempotencyRecovery } from "@/middleware/idempotency"; @@ -34,6 +37,8 @@ const CONNECTION = "dashboard-internal custody connection lifecycle; not part of the public API contract yet"; const EXEMPT = { + "POST /internal/dashboard/custody/fireblocks/vaults": + "read-only vault discovery; uses POST to keep credential references out of URLs and never stores a response", "POST /internal/dashboard/custody/provider-credentials": CREDENTIALS, "POST /internal/dashboard/custody/connections/:connectionId/provider-credentials": CREDENTIALS, "POST /internal/dashboard/custody/provider-credentials/:credentialId/rotate": CREDENTIALS, @@ -131,12 +136,14 @@ describe("idempotency route inventory", () => { expect(undeclared).toEqual([]); }); - it("requires the key on exactly the shared list of routes that move money", () => { + it("requires the key on exactly the public money routes and internal key-generation routes", () => { const required = idempotent .filter(([, steps]) => steps.idempotency === "required") .map(([route]) => route) .sort(); - expect(required).toEqual([...IDEMPOTENCY_REQUIRED_ROUTES].sort()); + expect(required).toEqual( + [...IDEMPOTENCY_REQUIRED_ROUTES, ...INTERNAL_IDEMPOTENCY_REQUIRED_ROUTES].sort() + ); }); it("runs an access check before the idempotency step on every idempotent route", () => { diff --git a/apps/sdp-api/src/routes/internal-custody/byok-release-channel.test.ts b/apps/sdp-api/src/routes/internal-custody/byok-release-channel.test.ts index 190744bdeb..9727e54768 100644 --- a/apps/sdp-api/src/routes/internal-custody/byok-release-channel.test.ts +++ b/apps/sdp-api/src/routes/internal-custody/byok-release-channel.test.ts @@ -403,7 +403,7 @@ describe("BYOK Privy outside the release channel", () => { operation: "rotate", path: `/internal/dashboard/custody/provider-credentials/${CREDENTIAL_ID}/rotate`, idempotencyKey: "byok-out-rotate", - body: { fields: { appId: APP_ID, appSecret: "rotated secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "rotated secret" } }, }, { operation: "complete-rotation", @@ -447,7 +447,7 @@ describe("BYOK Privy outside the release channel", () => { seed: () => seedSuccessor({ status: "deactivated", deactivatedAt: SEEDED_AT }), path: `/internal/dashboard/custody/provider-credentials/${SUCCESSOR_CREDENTIAL_ID}/rotate`, idempotencyKey: "byok-out-rotate-deactivated", - body: { fields: { appId: APP_ID, appSecret: "rotated secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "rotated secret" } }, }, { operation: "complete-rotation", diff --git a/apps/sdp-api/src/routes/internal-custody/fireblocks-byok.test.ts b/apps/sdp-api/src/routes/internal-custody/fireblocks-byok.test.ts new file mode 100644 index 0000000000..1d5e1a833f --- /dev/null +++ b/apps/sdp-api/src/routes/internal-custody/fireblocks-byok.test.ts @@ -0,0 +1,1033 @@ +import { execFileSync } from "node:child_process"; +import { verify } from "node:crypto"; +import { type Context, Hono } from "hono"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { getDb } from "@/db/client"; +import { runWithTenantDatabaseIdentity } from "@/db/identity"; +import { verifyClerkJwt } from "@/lib/clerk-token"; +import { AppError } from "@/lib/errors"; +import { kvStoreMiddleware } from "@/middleware/kv-store"; +import { createCredentialSecretStore } from "@/services/credential-secret-store"; +import { eraseExpiredFireblocksCsrs } from "@/services/custody/fireblocks-registration.service"; +import { CustodyRuntimeTargets } from "@/services/domain/signing/custody-runtime-target"; +import { ProviderCredentialSecretCleanupStore } from "@/services/stores/provider-credential-secret-cleanup.store"; +import { signSeededClerkMember } from "@/test/helpers/clerk-member"; +import { env } from "@/test/helpers/env"; +import { seedDefaultProjects } from "@/test/helpers/projects"; +import { seedTestDatabase } from "@/test/mocks/db"; +import { clearKVStores } from "@/test/mocks/kv"; +import type { Env } from "@/types/env"; +import { internalCustody } from "./index"; + +const ORGANIZATION_ID = "org_fireblocks_byok"; +const USER_ID = "usr_fireblocks_byok"; +const PROJECT_ID = "prj_fireblocks_byok"; +const ADDRESS = "11111111111111111111111111111111"; +const OTHER_ADDRESS = "So11111111111111111111111111111111111111112"; +const fields = { + apiKey: "tenant-fireblocks-key", + signingKeyId: "", + vaultAccountId: "0", +}; +const body = { + provider: "fireblocks", + fields: { credentialLabel: "Fireblocks treasury", scope: "project", ...fields }, + walletLabel: "Treasury", +}; +let token: string; +let auditContext: Context<{ Bindings: Env }>; +let app: Hono<{ Bindings: Env }>; +const testEnv: Env = { + ...env, + SDP_DEPLOYMENT_MODE: "self_hosted", + CREDENTIAL_SECRET_STORE_BACKEND: "encrypted_db", + CUSTODY_ENCRYPTION_KEY: Buffer.alloc(32, 11).toString("base64"), + FIREBLOCKS_API_BASE_URL: "https://fireblocks.example.test", + FIREBLOCKS_API_KEY: "deployment-key-must-not-be-used", + FIREBLOCKS_API_SECRET: "deployment-secret-must-not-be-used", +}; + +function request( + path: string, + payload?: unknown, + projectId = PROJECT_ID, + key = crypto.randomUUID() +) { + return app.request( + `/internal/dashboard/custody${path}`, + { + method: payload === undefined ? "GET" : "POST", + headers: { + Authorization: `Bearer ${token}`, + "X-Project-ID": projectId, + "Content-Type": "application/json", + "Idempotency-Key": key, + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }, + testEnv + ); +} + +function providerResponse(address = ADDRESS) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => Response.json({ addresses: [{ address }] })); +} + +async function connect(projectId = PROJECT_ID) { + if (projectId !== PROJECT_ID) + body.fields.signingKeyId = (await newRegistration(projectId)).signingKeyId; + const submitted = await request("/provider-credentials", body, projectId); + expect(submitted.status, await submitted.clone().text()).toBe(201); + const { data } = await submitted.json(); + const completed = await request(`/connections/${data.connectionId}/complete`, {}, projectId); + expect(completed.status).toBe(200); + expect((await completed.json()).data.completion.status).toBe("success"); + return z + .object({ connectionId: z.string(), providerCredential: z.object({ id: z.string() }) }) + .parse(data); +} + +async function newRegistration(projectId = PROJECT_ID) { + const response = await request("/fireblocks/csr", {}, projectId); + expect(response.status, await response.clone().text()).toBe(201); + const { data } = await response.json(); + return { ...fields, signingKeyId: data.csr.id }; +} + +beforeEach(async () => { + testEnv.SDP_RELEASE_CHANNEL = "beta"; + testEnv.CREDENTIAL_SECRET_STORE_BACKEND = "encrypted_db"; + await seedTestDatabase(env); + await clearKVStores(env); + const db = getDb(env); + await db.execute( + "INSERT INTO organizations (id, name, slug, tier, status) VALUES (?, 'Fireblocks BYOK', 'fireblocks-byok', 'enterprise', 'active')", + [ORGANIZATION_ID] + ); + await db.execute( + "INSERT INTO users (id, email, email_verified, status) VALUES (?, 'fireblocks-byok@example.com', 1, 'active')", + [USER_ID] + ); + await db.execute( + "INSERT INTO organization_members (id, organization_id, user_id, role, status) VALUES ('mem_fireblocks_byok', ?, ?, 'admin', 'active')", + [ORGANIZATION_ID, USER_ID] + ); + await seedDefaultProjects(db, { + organizationId: ORGANIZATION_ID, + createdBy: USER_ID, + members: [USER_ID], + ids: { sandbox: PROJECT_ID, production: `${PROJECT_ID}_production` }, + }); + await db.execute( + "UPDATE organizations SET settings = (settings::jsonb || ?::jsonb)::text WHERE id = ?", + [JSON.stringify({ providerOverrides: { custody: { fireblocks: true } } }), ORGANIZATION_ID] + ); + token = await signSeededClerkMember(testEnv, db, USER_ID, ORGANIZATION_ID); + await verifyClerkJwt(token, testEnv); + app = new Hono<{ Bindings: Env }>(); + app.use("*", kvStoreMiddleware()); + app.use("*", async (c, next) => { + c.set("requestId", "req_fireblocks_byok"); + auditContext = c; + await next(); + }); + app.route("/internal/dashboard/custody", internalCustody); + app.onError((error) => { + if (error instanceof AppError) + return Response.json(error.toResponse(), { status: error.statusCode }); + throw error; + }); + fields.signingKeyId = (await newRegistration()).signingKeyId; + body.fields.signingKeyId = fields.signingKeyId; +}); +afterEach(() => vi.restoreAllMocks()); + +describe("Fireblocks BYOK lifecycle", () => { + it("refuses stable CSR, discovery, installation and rotation before provider calls or writes", async () => { + const fetch = providerResponse(); + const connected = await connect(); + const registration = await newRegistration(); + const pending = await request("/provider-credentials", { + ...body, + fields: { ...body.fields, ...registration }, + }); + expect(pending.status).toBe(201); + const pendingId = (await pending.json()).data.connectionId; + const before = await getDb(env).queryMany( + "SELECT id, status FROM provider_credentials WHERE organization_id = ? ORDER BY id", + [ORGANIZATION_ID] + ); + testEnv.SDP_RELEASE_CHANNEL = "stable"; + fetch.mockClear(); + const discoveryFields = { apiKey: fields.apiKey, signingKeyId: fields.signingKeyId }; + for (const [path, payload] of [ + ["/fireblocks/csr", undefined], + ["/fireblocks/csr", {}], + ["/fireblocks/vaults", discoveryFields], + ["/provider-credentials", body], + [`/connections/${pendingId}/complete`, {}], + [ + `/provider-credentials/${connected.providerCredential.id}/rotate`, + { provider: "fireblocks", fields: registration }, + ], + ] as const) { + const response = await request(path, payload); + expect(response.status, await response.clone().text()).toBe(403); + expect(await response.text()).toContain("custody_provider_not_in_release_channel"); + } + expect(fetch).not.toHaveBeenCalled(); + expect( + await getDb(env).queryMany( + "SELECT id, status FROM provider_credentials WHERE organization_id = ? ORDER BY id", + [ORGANIZATION_ID] + ) + ).toEqual(before); + // Channel changes must leave cancellation available for existing installations. + expect((await request(`/connections/${pendingId}/cancel`, {})).status).toBe(200); + }); + + it("encrypts credentials, attaches the vault with one read and replays completion", async () => { + const fetch = providerResponse(); + const connected = await connect(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + `${testEnv.FIREBLOCKS_API_BASE_URL}/v1/vault/accounts/0/SOL_TEST/addresses_paginated`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ "X-API-Key": fields.apiKey }), + }) + ); + const credential = await getDb(env).queryOne<{ encrypted_secret_payload: string }>( + "SELECT encrypted_secret_payload FROM provider_credentials WHERE id = ?", + [connected.providerCredential.id] + ); + expect(credential?.encrypted_secret_payload).not.toContain(fields.apiKey); + const payload = await createCredentialSecretStore(testEnv).read({ + orgId: ORGANIZATION_ID, + stored: { + storageBackend: "encrypted_db", + encryptedSecretPayload: credential?.encrypted_secret_payload, + }, + }); + expect(payload).toMatchObject({ + apiKey: fields.apiKey, + vaultAccountId: "0", + assetId: "SOL_TEST", + apiSecretPem: expect.stringContaining("BEGIN PRIVATE KEY"), + }); + expect(payload).not.toHaveProperty("signingKeyId"); + expect( + await getDb(env).queryOne( + "SELECT encrypted_private_key FROM fireblocks_csr_requests WHERE id = ?", + [fields.signingKeyId] + ) + ).toEqual({ encrypted_private_key: null }); + const replay = await request(`/connections/${connected.connectionId}/complete`, {}); + expect(replay.status).toBe(200); + expect(fetch).toHaveBeenCalledTimes(1); + const listed = await request("/connections?provider=fireblocks"); + const serialized = JSON.stringify(await listed.json()); + expect(serialized).toContain("fireblocks"); + expect(serialized).not.toContain(fields.apiKey); + expect(serialized).not.toContain("BEGIN PRIVATE KEY"); + const wallet = await getDb(env).queryOne( + "SELECT wallet_id, public_key FROM custody_wallets WHERE custody_connection_id = ?", + [connected.connectionId] + ); + expect(wallet).toEqual({ wallet_id: "fb_0", public_key: ADDRESS }); + }); + + it("pins production to SOL and rejects browser URL or asset overrides", async () => { + const fetch = providerResponse(); + await connect(`${PROJECT_ID}_production`); + expect(String(fetch.mock.calls[0]?.[0])).toContain("/SOL/addresses_paginated"); + for (const extra of [ + { apiBaseUrl: "https://attacker.invalid" }, + { assetId: "SOL" }, + { apiSecretPem: "client-supplied-key" }, + ]) { + expect( + (await request("/provider-credentials", { ...body, fields: { ...body.fields, ...extra } })) + .status + ).toBe(400); + } + }); + + it("keeps Privy and Fireblocks setup independent and refuses cross-provider replacement", async () => { + const first = await request("/provider-credentials", body); + const connectionId = (await first.json()).data.connectionId; + const privy = { + provider: "privy", + fields: { credentialLabel: "Privy", scope: "project", appId: "app", appSecret: "secret" }, + }; + expect((await request("/provider-credentials", privy)).status).toBe(201); + expect((await request(`/connections/${connectionId}/provider-credentials`, privy)).status).toBe( + 404 + ); + expect((await request("/provider-credentials", body)).status).toBe(409); + }); + + it("rejects invalid credentials without creating or signing a wallet", async () => { + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("denied", { status: 401 })); + const created = (await (await request("/provider-credentials", body)).json()).data; + const checked = await request(`/connections/${created.connectionId}/complete`, {}); + expect((await checked.json()).data.completion).toMatchObject({ + status: "failed", + code: "invalid_credentials", + }); + expect(fetch).toHaveBeenCalledTimes(1); + expect( + await getDb(env).queryMany("SELECT id FROM custody_wallets WHERE custody_connection_id = ?", [ + created.connectionId, + ]) + ).toEqual([]); + }); + + it("requires an explicit matching rotation provider before credential I/O", async () => { + const fetch = providerResponse(); + const connected = await connect(); + fetch.mockClear(); + for (const payload of [ + { fields: { appId: "app", appSecret: "secret" } }, + { provider: "fireblocks", fields: { appId: "app", appSecret: "secret" } }, + { provider: "privy", fields: { appId: "app", appSecret: "secret" } }, + ]) { + const response = await request( + `/provider-credentials/${connected.providerCredential.id}/rotate`, + payload + ); + expect(response.status).toBe(400); + } + expect(fetch).not.toHaveBeenCalled(); + const credentials = await getDb(env).queryMany( + "SELECT id FROM provider_credentials WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + expect(credentials).toHaveLength(1); + }); + + it("rotates only within the pinned vault and can roll back to the prior credential", async () => { + const fetch = providerResponse(); + const connected = await connect(); + fetch.mockImplementation(async () => + Response.json({ addresses: [{ address: OTHER_ADDRESS }] }) + ); + const rejected = await request( + `/provider-credentials/${connected.providerCredential.id}/rotate`, + { provider: "fireblocks", fields: { ...(await newRegistration()), apiKey: "foreign-key" } } + ); + expect((await rejected.json()).data.rotation).toEqual({ + status: "failed", + code: "provider_account_mismatch", + }); + fetch.mockImplementation(async () => Response.json({ addresses: [{ address: ADDRESS }] })); + const rotated = await request( + `/provider-credentials/${connected.providerCredential.id}/rotate`, + { + provider: "fireblocks", + fields: { ...(await newRegistration()), apiKey: "replacement-key" }, + } + ); + const rotation = (await rotated.json()).data; + expect(rotation.rotation.status).toBe("success"); + expect(rotation.providerCredential.provider).toBe("fireblocks"); + const rolledBack = await request( + `/provider-credentials/${rotation.providerCredential.id}/rollback`, + {} + ); + expect(rolledBack.status).toBe(200); + const connection = await getDb(env).queryOne<{ provider_credential_id: string }>( + "SELECT provider_credential_id FROM custody_connections WHERE id = ?", + [connected.connectionId] + ); + expect(connection?.provider_credential_id).toBe(connected.providerCredential.id); + }); + + it("resolves only the owned wallet through the keychain Fireblocks signer", async () => { + const fetch = providerResponse(); + const connected = await connect(); + const wallet = await getDb(env).queryOne<{ id: string }>( + "SELECT id FROM custody_wallets WHERE custody_connection_id = ?", + [connected.connectionId] + ); + if (!wallet) throw new Error("Missing wallet"); + const resolver = new CustodyRuntimeTargets(getDb(env), testEnv, new Map()); + const managedAdapter = vi.fn(); + const signer = await resolver.getTransactionSignerForWalletRecord( + ORGANIZATION_ID, + PROJECT_ID, + wallet.id, + "payments.transfer", + managedAdapter + ); + expect(signer.address).toBe(ADDRESS); + expect(managedAdapter).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(2); + await resolver.getTransactionSignerForWalletRecord( + ORGANIZATION_ID, + PROJECT_ID, + wallet.id, + "payments.transfer", + managedAdapter + ); + expect(fetch).toHaveBeenCalledTimes(2); + await expect( + resolver.getTransactionSignerForWalletRecord( + ORGANIZATION_ID, + `${PROJECT_ID}_production`, + wallet.id, + "payments.transfer", + managedAdapter + ) + ).rejects.toThrow(); + expect(fetch).toHaveBeenCalledTimes(2); + fetch.mockImplementation(async () => + Response.json({ addresses: [{ address: OTHER_ADDRESS }] }) + ); + const uncached = new CustodyRuntimeTargets(getDb(env), testEnv, new Map()); + await expect( + uncached.getTransactionSignerForWalletRecord( + ORGANIZATION_ID, + PROJECT_ID, + wallet.id, + "payments.transfer", + managedAdapter + ) + ).rejects.toThrow(); + }); + it("provisions an additional vault with tenant credentials and reuses its fetched address", async () => { + const fetch = providerResponse(); + const connected = await connect(); + fetch.mockReset(); + fetch + .mockResolvedValueOnce(Response.json({ id: "42" })) + .mockResolvedValueOnce(Response.json({})) + .mockResolvedValueOnce(Response.json({ addresses: [{ address: OTHER_ADDRESS }] })); + const resolver = new CustodyRuntimeTargets(getDb(env), testEnv, new Map()); + const wallet = await resolver.createConnectionWallet({ + auditContext, + creationReason: "wallet_api", + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + connectionId: connected.connectionId, + label: "Operations", + }); + expect(wallet).toMatchObject({ + walletId: "fb_42", + publicKey: OTHER_ADDRESS, + custodyConnectionId: connected.connectionId, + }); + expect(fetch).toHaveBeenCalledTimes(3); + for (const [, init] of fetch.mock.calls) + expect(new Headers(init?.headers).get("X-API-Key")).toBe(fields.apiKey); + }); + + it("reserves GCP credentials before writing and includes Fireblocks containers in cleanup", async () => { + testEnv.CREDENTIAL_SECRET_STORE_BACKEND = "gcp_secret_manager"; + testEnv.GCP_SECRET_MANAGER_PROJECT_ID = "fireblocks-test"; + testEnv.GCP_SECRET_MANAGER_SECRET_PREFIX = "sdp-provider-credentials"; + testEnv.GCP_SECRET_MANAGER_API_BASE_URL = "https://gcp-fireblocks.test"; + let sawReservation = false; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = new URL(String(input)); + if (url.hostname === "metadata.google.internal") + return url.pathname.endsWith("/numeric-project-id") + ? new Response("1234567890") + : Response.json({ access_token: "test-token", expires_in: 300 }); + if (url.searchParams.has("secretId")) return Response.json({}); + if (url.pathname.endsWith(":addVersion")) { + const creating = await getDb(env).queryOne( + "SELECT provider, status, secret_next_scan_at FROM provider_credentials WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + expect(creating).toMatchObject({ + provider: "fireblocks", + status: "creating", + secret_next_scan_at: expect.anything(), + }); + sawReservation = true; + return Response.json({ + name: `${url.pathname.slice(4, -":addVersion".length).replace("projects/fireblocks-test/", "projects/1234567890/")}/versions/1`, + }); + } + throw new Error("Unexpected GCP request"); + }); + const submitted = await request("/provider-credentials", body); + expect(submitted.status, await submitted.clone().text()).toBe(201); + expect(sawReservation).toBe(true); + const { data } = await submitted.json(); + const cleanup = new ProviderCredentialSecretCleanupStore(getDb(env)); + expect(await cleanup.listDueContainers(new Date(Date.now() + 60_000), 10)).toEqual([ + expect.objectContaining({ id: data.providerCredential.id }), + ]); + }); +}); + +describe("Fireblocks CSR security", () => { + it("returns only public material, encrypts the key, and isolates projects and tenants", async () => { + const response = await request("/fireblocks/csr"); + const { data } = await response.json(); + expect(Object.keys(data.csr).sort()).toEqual(["csrPem", "expiresAt", "id"]); + expect(data.csr.id).toBe(fields.signingKeyId); + expect(data.csr.csrPem).toContain("BEGIN CERTIFICATE REQUEST"); + expect(JSON.stringify(data)).not.toContain("PRIVATE KEY"); + const stored = await getDb(env).queryOne<{ encrypted_private_key: string }>( + "SELECT encrypted_private_key FROM fireblocks_csr_requests WHERE id = ?", + [fields.signingKeyId] + ); + expect(stored?.encrypted_private_key).toBeTruthy(); + expect(stored?.encrypted_private_key).not.toContain("PRIVATE KEY"); + expect( + (await (await request("/fireblocks/csr", undefined, `${PROJECT_ID}_production`)).json()).data + .csr + ).toBeNull(); + expect((await request("/provider-credentials", body, `${PROJECT_ID}_production`)).status).toBe( + 404 + ); + const hidden = await runWithTenantDatabaseIdentity({ organizationId: "org_another" }, () => + getDb(env).queryMany("SELECT * FROM fireblocks_csr_requests") + ); + expect(hidden).toEqual([]); + expect((await request("/fireblocks/csr")).status).toBe(200); + }); + + it("deduplicates concurrent CSR generation and preserves submission replay after key retirement", async () => { + const key = crypto.randomUUID(); + const responses = await Promise.all([ + request("/fireblocks/csr", {}, PROJECT_ID, key), + request("/fireblocks/csr", {}, PROJECT_ID, key), + ]); + const successful = responses.find((response) => response.status === 201); + if (!successful) throw new Error("Expected a successful CSR request"); + for (const response of responses) { + if (response.status === 409) { + expect((await response.json()).error.code).toBe("IDEMPOTENCY_KEY_IN_FLIGHT"); + } else expect(response.status).toBe(201); + } + const first = (await successful.json()).data.csr; + const csrReplay = await request("/fireblocks/csr", {}, PROJECT_ID, key); + expect(csrReplay.status).toBe(201); + expect((await csrReplay.json()).data.csr).toEqual(first); + expect(first.id).not.toBe(fields.signingKeyId); + const previous = (await (await request("/fireblocks/csr", undefined)).json()).data.csr; + expect(previous.id).toBe(first.id); + const payload = { ...body, fields: { ...body.fields, signingKeyId: first.id } }; + const submissionKey = crypto.randomUUID(); + const submitted = await request("/provider-credentials", payload, PROJECT_ID, submissionKey); + expect(submitted.status).toBe(201); + const submittedData = (await submitted.json()).data; + const replay = await request("/provider-credentials", payload, PROJECT_ID, submissionKey); + expect(replay.status).toBe(201); + expect((await replay.json()).data.providerCredential.id).toBe( + submittedData.providerCredential.id + ); + expect( + ( + await request( + "/provider-credentials", + { ...payload, fields: { ...payload.fields, apiKey: "changed" } }, + PROJECT_ID, + submissionKey + ) + ).status + ).toBe(409); + }); + + it("authenticates real keychain requests with the generated CSR's public key", async () => { + const { data } = await (await request("/fireblocks/csr")).json(); + const publicKey = execFileSync("openssl", ["req", "-pubkey", "-noout"], { + input: data.csr.csrPem, + encoding: "utf8", + }); + const fetch = providerResponse(); + await connect(); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + const jwt = headers.get("Authorization")?.replace(/^Bearer /, ""); + expect(jwt).toBeTruthy(); + const [header, payload, signature] = z.string().parse(jwt).split("."); + expect(JSON.parse(Buffer.from(header, "base64url").toString()).alg).toBe("RS256"); + expect( + verify( + "RSA-SHA256", + Buffer.from(`${header}.${payload}`), + publicKey, + Buffer.from(signature, "base64url") + ) + ).toBe(true); + expect(headers.get("X-API-Key")).toBe(fields.apiKey); + }); + + it("refuses expired registrations and erases their staging keys", async () => { + await getDb(env).execute( + "UPDATE fireblocks_csr_requests SET expires_at = '2020-01-01T00:00:00.000Z' WHERE id = ?", + [fields.signingKeyId] + ); + expect((await (await request("/fireblocks/csr")).json()).data.csr).toBeNull(); + expect((await request("/provider-credentials", body)).status).toBe(404); + await eraseExpiredFireblocksCsrs(getDb(env)); + expect( + await getDb(env).queryOne( + "SELECT encrypted_private_key FROM fireblocks_csr_requests WHERE id = ?", + [fields.signingKeyId] + ) + ).toEqual({ encrypted_private_key: null }); + }); + + it("requires an entitled credential administrator and bounds RSA generation", async () => { + const unauthenticated = await app.request( + "/internal/dashboard/custody/fireblocks/csr", + {}, + testEnv + ); + expect(unauthenticated.status).toBe(401); + await newRegistration(); + await newRegistration(); + expect((await request("/fireblocks/csr", {})).status).toBe(429); + await getDb(env).execute("UPDATE organizations SET settings = '{}' WHERE id = ?", [ + ORGANIZATION_ID, + ]); + expect((await request("/fireblocks/csr")).status).toBe(403); + }); +}); + +describe("Fireblocks vault discovery and selection", () => { + it("lists named Solana vaults with a CSR-authenticated request and keeps pagination on the configured host", async () => { + const csr = (await (await request("/fireblocks/csr")).json()).data.csr.csrPem; + const publicKey = execFileSync("openssl", ["req", "-pubkey", "-noout"], { + input: csr, + encoding: "utf8", + }); + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = new URL(String(input)); + expect(url.origin).toBe(testEnv.FIREBLOCKS_API_BASE_URL); + expect(url.pathname).toBe("/v1/vault/accounts_paged"); + expect(url.searchParams.get("assetId")).toBe("SOL_TEST"); + expect(url.searchParams.get("limit")).toBe("50"); + expect(init?.method).toBe("GET"); + expect(init?.redirect).toBe("error"); + const headers = new Headers(init?.headers); + expect(headers.get("X-API-Key")).toBe(fields.apiKey); + const jwt = headers.get("Authorization")?.slice(7).split("."); + if (jwt?.length !== 3) throw new Error("Missing JWT"); + expect( + verify( + "RSA-SHA256", + Buffer.from(`${jwt[0]}.${jwt[1]}`), + publicKey, + Buffer.from(jwt[2], "base64url") + ) + ).toBe(true); + expect(JSON.parse(Buffer.from(jwt[1], "base64url").toString())).toMatchObject({ + sub: fields.apiKey, + uri: url.pathname + url.search, + }); + return Response.json({ + accounts: [ + { id: "0", name: "Treasury", assets: [{ id: "SOL_TEST", total: "999" }] }, + { id: "1", name: "Other network", assets: [{ id: "SOL" }] }, + ], + paging: { after: "page&2" }, + nextUrl: "https://untrusted.example/vaults", + }); + }); + const input = { apiKey: fields.apiKey, signingKeyId: fields.signingKeyId }; + const result = await request("/fireblocks/vaults", input); + expect(result.status).toBe(200); + expect(result.headers.get("Cache-Control")).toBe("no-store"); + expect((await result.json()).data).toEqual({ + vaults: [{ id: "0", name: "Treasury", hasSolanaWallet: true }], + nextCursor: "page&2", + }); + expect((await request("/fireblocks/vaults", { ...input, after: "page&2" })).status).toBe(200); + expect(fetch).toHaveBeenCalledTimes(2); + expect(new URL(String(fetch.mock.calls[1]?.[0])).searchParams.get("after")).toBe("page&2"); + expect( + await getDb(env).queryOne( + "SELECT claim_fingerprint, consumed_at FROM fireblocks_csr_requests WHERE id = ?", + [fields.signingKeyId] + ) + ).toEqual({ claim_fingerprint: null, consumed_at: null }); + }); + + it("refuses cross-project and expired discovery before calling Fireblocks", async () => { + const fetch = providerResponse(); + const input = { apiKey: fields.apiKey, signingKeyId: fields.signingKeyId }; + expect((await request("/fireblocks/vaults", input, `${PROJECT_ID}_production`)).status).toBe( + 404 + ); + await getDb(env).execute( + "UPDATE fireblocks_csr_requests SET expires_at = '2020-01-01T00:00:00.000Z' WHERE id = ?", + [fields.signingKeyId] + ); + expect((await request("/fireblocks/vaults", input)).status).toBe(404); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("returns actionable authorization errors without echoing provider secrets", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("upstream secret body", { status: 401 }) + ); + const response = await request("/fireblocks/vaults", { + apiKey: fields.apiKey, + signingKeyId: fields.signingKeyId, + }); + expect(response.status).toBe(400); + const text = await response.text(); + expect(text).toContain("registration is approved"); + expect(text).not.toContain("upstream secret body"); + expect(text).not.toContain(fields.apiKey); + }); + + function selectedVaultResponses(secondStatus?: number) { + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + expect(init?.method).toBe("GET"); + const path = new URL(String(input)).pathname; + if (path.endsWith("/addresses_paginated")) + return Response.json({ + addresses: [{ address: path.includes("/7/") ? OTHER_ADDRESS : ADDRESS }], + }); + const id = path.split("/").at(-1); + if (id === "7" && secondStatus) return new Response(null, { status: secondStatus }); + return Response.json({ + id, + name: id === "7" ? "Operations" : "Treasury", + assets: [{ id: "SOL_TEST" }], + }); + }); + } + function selectedPayload(ids = ["0", "7"]) { + return { + provider: "fireblocks", + fields: { ...body.fields, vaultAccountId: ids[0] }, + fireblocksVaultAccountIds: ids, + }; + } + + async function connectSelectedVaults() { + const submitted = await request("/provider-credentials", selectedPayload()); + expect(submitted.status).toBe(201); + const data = z + .object({ + connectionId: z.string(), + providerCredential: z.object({ id: z.string() }), + }) + .parse((await submitted.json()).data); + const completed = await request(`/connections/${data.connectionId}/complete`, {}); + expect((await completed.json()).data.completion.status).toBe("success"); + return data; + } + + async function attachedState(connectionId: string) { + return getDb(env).queryMany( + `SELECT c.provider_credential_id, w.wallet_id, w.public_key, w.status + FROM custody_connections c JOIN custody_wallets w ON w.custody_connection_id = c.id + WHERE c.id = ? ORDER BY w.wallet_id`, + [connectionId] + ); + } + + const secondaryFailures = [ + { name: "forbidden", httpStatus: 403, code: "invalid_credentials" }, + { name: "missing", httpStatus: 404, code: "invalid_credentials" }, + { name: "changed address", httpStatus: 200, code: "provider_account_mismatch" }, + { name: "unavailable", httpStatus: 500, code: "provider_response_unknown" }, + ] as const; + + it.each(secondaryFailures)( + "keeps all wallets on the current credential when rotation's secondary vault is $name", + async ({ httpStatus, code }) => { + const fetch = selectedVaultResponses(); + const connected = await connectSelectedVaults(); + // Inactive wallets retain the connection and can be reactivated later. + await getDb(env).execute( + "UPDATE custody_wallets SET status = 'inactive' WHERE custody_connection_id = ? AND wallet_id = 'fb_7'", + [connected.connectionId] + ); + const before = await attachedState(connected.connectionId); + const registration = await newRegistration(); + fetch.mockClear().mockImplementation(async (input) => { + if (String(input).includes("/7/")) { + if (httpStatus !== 200) return new Response(null, { status: httpStatus }); + return Response.json({ addresses: [{ address: ADDRESS }] }); + } + return Response.json({ addresses: [{ address: ADDRESS }] }); + }); + const response = await request( + `/provider-credentials/${connected.providerCredential.id}/rotate`, + { + provider: "fireblocks", + fields: { ...registration, apiKey: "replacement-key" }, + } + ); + expect(response.status).toBe(200); + const result = (await response.json()).data; + expect(result.rotation).toEqual({ + status: httpStatus === 500 ? "retry_unknown" : "failed", + code, + }); + expect(await attachedState(connected.connectionId)).toEqual(before); + expect(fetch).toHaveBeenCalledTimes(2); + if (httpStatus === 500) { + fetch.mockImplementation(async (input) => + Response.json({ + addresses: [{ address: String(input).includes("/7/") ? OTHER_ADDRESS : ADDRESS }], + }) + ); + const retry = await request( + `/provider-credentials/${result.providerCredential.id}/complete-rotation`, + {} + ); + expect((await retry.json()).data.rotation.status).toBe("success"); + expect(fetch).toHaveBeenCalledTimes(4); + } + } + ); + + it.each(secondaryFailures)( + "keeps all wallets on the current credential when rollback's secondary vault is $name", + async ({ httpStatus }) => { + const fetch = selectedVaultResponses(); + const connected = await connectSelectedVaults(); + const response = await request( + `/provider-credentials/${connected.providerCredential.id}/rotate`, + { + provider: "fireblocks", + fields: { ...(await newRegistration()), apiKey: "replacement-key" }, + } + ); + const rotated = (await response.json()).data; + expect(rotated.rotation.status).toBe("success"); + const before = await attachedState(connected.connectionId); + fetch.mockClear().mockImplementation(async (input, init) => { + expect(new Headers(init?.headers).get("X-API-Key")).toBe(fields.apiKey); + if (String(input).includes("/7/") && httpStatus !== 200) + return new Response(null, { status: httpStatus }); + return Response.json({ addresses: [{ address: ADDRESS }] }); + }); + const failed = await request( + `/provider-credentials/${rotated.providerCredential.id}/rollback`, + {} + ); + expect(failed.status).toBe(httpStatus === 500 ? 503 : 409); + expect(await attachedState(connected.connectionId)).toEqual(before); + expect(fetch).toHaveBeenCalledTimes(2); + fetch.mockClear().mockImplementation(async (input) => + Response.json({ + addresses: [{ address: String(input).includes("/7/") ? OTHER_ADDRESS : ADDRESS }], + }) + ); + const retry = await request( + `/provider-credentials/${rotated.providerCredential.id}/rollback`, + {} + ); + expect(retry.status).toBe(200); + expect(fetch).toHaveBeenCalledTimes(2); + expect(await attachedState(connected.connectionId)).toEqual( + before.map((row) => ({ + ...row, + provider_credential_id: connected.providerCredential.id, + })) + ); + } + ); + + it.each([ + { action: "rotate", mutation: "address" }, + { action: "rollback", mutation: "address" }, + { action: "rotate", mutation: "new wallet" }, + { action: "rollback", mutation: "new wallet" }, + ])( + "rejects $action when an attached $mutation changes during verification", + async ({ action, mutation }) => { + const fetch = selectedVaultResponses(); + const connected = await connectSelectedVaults(); + const registration = await newRegistration(); + const replacement = { + provider: "fireblocks", + fields: { ...registration, apiKey: "replacement-key" }, + }; + let currentId = connected.providerCredential.id; + if (action === "rollback") { + const rotated = await request(`/provider-credentials/${currentId}/rotate`, replacement); + const data = (await rotated.json()).data; + expect(data.rotation.status).toBe("success"); + currentId = data.providerCredential.id; + } + fetch.mockClear().mockImplementation(async (input) => { + if (String(input).includes("/7/")) { + if (mutation === "address") { + await getDb(env).execute( + "UPDATE custody_wallets SET public_key = ? WHERE custody_connection_id = ? AND wallet_id = 'fb_7'", + [ADDRESS, connected.connectionId] + ); + } else { + await getDb(env).execute( + `INSERT INTO custody_wallets (id, custody_connection_id, wallet_id, public_key) + VALUES (?, ?, 'fb_8', ?)`, + [`cwlt_${crypto.randomUUID()}`, connected.connectionId, OTHER_ADDRESS] + ); + } + return Response.json({ addresses: [{ address: OTHER_ADDRESS }] }); + } + return Response.json({ addresses: [{ address: ADDRESS }] }); + }); + const response = await request( + `/provider-credentials/${currentId}/${action}`, + action === "rotate" ? replacement : {} + ); + expect(response.status).toBe(409); + const rows = await attachedState(connected.connectionId); + expect(rows).toHaveLength(mutation === "address" ? 2 : 3); + expect(rows.every((row) => row.provider_credential_id === currentId)).toBe(true); + expect(fetch).toHaveBeenCalledTimes(2); + } + ); + + it("imports selected vaults atomically with provider names and replays without extra provider reads", async () => { + const fetch = selectedVaultResponses(); + const idempotencyKey = crypto.randomUUID(); + const payload = selectedPayload(); + const submitted = await request("/provider-credentials", payload, PROJECT_ID, idempotencyKey); + expect(submitted.status).toBe(201); + const { connectionId } = (await submitted.json()).data; + const complete = await request(`/connections/${connectionId}/complete`, {}); + expect((await complete.json()).data.completion.status).toBe("success"); + expect(fetch).toHaveBeenCalledTimes(4); + const wallets = await getDb(env).queryMany( + "SELECT wallet_id, public_key, label FROM custody_wallets WHERE custody_connection_id = ? ORDER BY wallet_id", + [connectionId] + ); + expect(wallets).toEqual([ + { wallet_id: "fb_0", public_key: ADDRESS, label: "Treasury" }, + { wallet_id: "fb_7", public_key: OTHER_ADDRESS, label: "Operations" }, + ]); + expect((await request(`/connections/${connectionId}/complete`, {})).status).toBe(200); + expect( + (await request("/provider-credentials", payload, PROJECT_ID, idempotencyKey)).status + ).toBe(201); + expect(fetch).toHaveBeenCalledTimes(4); + expect( + (await request("/provider-credentials", selectedPayload(["0"]), PROJECT_ID, idempotencyKey)) + .status + ).toBe(409); + expect( + ( + await new CustodyRuntimeTargets(getDb(env), testEnv, new Map()).resolve({ + kind: "wallet", + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + walletId: "fb_7", + }) + )?.kind + ).toBe("connection"); + }); + + it.each([403, 404])( + "imports no wallets when a selected vault returns HTTP %s", + async (status) => { + selectedVaultResponses(status); + const submitted = await request("/provider-credentials", selectedPayload()); + const { connectionId } = (await submitted.json()).data; + const result = await request(`/connections/${connectionId}/complete`, {}); + expect((await result.json()).data.completion).toMatchObject({ + status: "failed", + code: "invalid_credentials", + }); + expect( + await getDb(env).queryMany( + "SELECT id FROM custody_wallets WHERE custody_connection_id = ?", + [connectionId] + ) + ).toEqual([]); + expect( + await getDb(env).queryOne( + "SELECT provider_account_fingerprint FROM custody_connections WHERE id = ?", + [connectionId] + ) + ).toEqual({ provider_account_fingerprint: null }); + } + ); + + it("rolls back every wallet when saving a later selected wallet fails", async () => { + selectedVaultResponses(); + const submitted = await request("/provider-credentials", selectedPayload()); + const { connectionId } = (await submitted.json()).data; + const db = getDb(env); + await db.execute( + "ALTER TABLE custody_wallets ADD CONSTRAINT sdp_test_fail_second_vault CHECK (wallet_id <> 'fb_7')" + ); + try { + const result = await request(`/connections/${connectionId}/complete`, {}); + expect(result.status, await result.clone().text()).toBe(409); + expect( + await db.queryMany("SELECT id FROM custody_wallets WHERE custody_connection_id = ?", [ + connectionId, + ]) + ).toEqual([]); + expect( + await db.queryOne("SELECT status FROM custody_connections WHERE id = ?", [connectionId]) + ).not.toEqual({ status: "active" }); + } finally { + await db.execute("ALTER TABLE custody_wallets DROP CONSTRAINT sdp_test_fail_second_vault"); + } + // After the unresolved persistence lease expires, completion can safely reconcile. + await db.execute("UPDATE custody_connections SET last_check_at = ? WHERE id = ?", [ + new Date(Date.now() - 10 * 60_000).toISOString(), + connectionId, + ]); + const retry = await request(`/connections/${connectionId}/complete`, {}); + expect(retry.status, await retry.clone().text()).toBe(200); + expect((await retry.json()).data.completion.status).toBe("success"); + expect( + await db.queryMany("SELECT wallet_id FROM custody_wallets WHERE custody_connection_id = ?", [ + connectionId, + ]) + ).toHaveLength(2); + }); + + it("refuses a vault already attached through another connection", async () => { + selectedVaultResponses(); + await connect(); + body.fields.signingKeyId = (await newRegistration()).signingKeyId; + const submitted = await request("/provider-credentials", selectedPayload(["7", "0"])); + expect(submitted.status).toBe(201); + const { connectionId } = (await submitted.json()).data; + const completed = await request(`/connections/${connectionId}/complete`, {}); + expect(completed.status, await completed.clone().text()).toBe(409); + expect( + await getDb(env).queryOne( + "SELECT status, last_check_failure_code FROM custody_connections WHERE id = ?", + [connectionId] + ) + ).toEqual({ + status: "failed", + last_check_failure_code: "provider_account_already_connected", + }); + expect( + await getDb(env).queryMany("SELECT id FROM custody_wallets WHERE custody_connection_id = ?", [ + connectionId, + ]) + ).toEqual([]); + }); + + it("rejects duplicate, oversized and mismatched selections before storing credentials", async () => { + const fetch = providerResponse(); + for (const ids of [["0", "0"], Array.from({ length: 21 }, (_, i) => String(i))]) { + expect((await request("/provider-credentials", selectedPayload(ids))).status).toBe(400); + } + expect( + ( + await request("/provider-credentials", { + ...selectedPayload(), + fireblocksVaultAccountIds: ["7"], + }) + ).status + ).toBe(400); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/sdp-api/src/routes/internal-custody/index.ts b/apps/sdp-api/src/routes/internal-custody/index.ts index f60d836d51..063c84973f 100644 --- a/apps/sdp-api/src/routes/internal-custody/index.ts +++ b/apps/sdp-api/src/routes/internal-custody/index.ts @@ -7,13 +7,26 @@ import { Hono } from "hono"; import { z } from "zod"; import { getDb } from "@/db/client"; import { getAuth, requireProjectId } from "@/lib/auth"; -import { badRequest, badRequestParams } from "@/lib/errors"; +import { badRequest, badRequestParams, internalError } from "@/lib/errors"; import { created, success } from "@/lib/response"; +import { authorize } from "@/middleware/authorize"; import { credentialAdminAuthMiddleware } from "@/middleware/credential-admin-auth"; +import { getIdempotencyRecordKey, idempotent } from "@/middleware/idempotency"; import { idempotencyKeyMiddleware } from "@/middleware/idempotency-key"; import { enforceMeteredQuota } from "@/middleware/metered-quota"; import { projectContextMiddleware } from "@/middleware/project-context"; import { requireAction } from "@/middleware/require-action"; +import { validateBody } from "@/middleware/validate"; +import { + byokRegistrationSchema, + fireblocksRegistrationSchema, +} from "@/services/custody/byok-credential"; +import { + admitFireblocksRegistration, + createFireblocksCsr, + discoverFireblocksVaults, + getPendingFireblocksCsr, +} from "@/services/custody/fireblocks-registration.service"; import { getCustodySetupStatus } from "@/services/custody-setup-status.service"; import { isCustodyConnectionRuntimeAvailable } from "@/services/domain/signing/custody-runtime-target"; import { @@ -21,6 +34,8 @@ import { isCustodyProviderEntitled, } from "@/services/provider-availability.service"; import { + cancelProviderCredentialInstallation, + completeProviderCredentialInstallation, deactivateCustodyConnection, getProviderCredentialInstallation, } from "@/services/provider-credential-installation.service"; @@ -31,7 +46,10 @@ import { rollbackProviderCredential, rotateProviderCredential, } from "@/services/provider-credential-lifecycle.service"; -import { getProviderSetupDefinition } from "@/services/provider-setup-registry"; +import { + storeCustodyCredentials, + validateCustodySetupPayload, +} from "@/services/provider-setup-registry"; import { getPendingWalletLabel, ProviderCredentialStore, @@ -40,17 +58,6 @@ import type { Env } from "@/types/env"; const connectionParamsSchema = z.object({ connectionId: z.string().trim().min(1) }).strict(); const credentialParamsSchema = z.object({ credentialId: z.string().trim().min(1) }).strict(); -const rotationBodySchema = z - .object({ - fields: z - .object({ - appId: z.string().trim().min(1).max(4096), - appSecret: z.string().min(1).max(4096), - }) - .strict(), - }) - .strict(); -const privySetup = getProviderSetupDefinition("custody", "privy"); const rotationQuota = { name: "credential-rotation", actorMax: 5, orgMax: 20 }; // Rotation retries must not consume the budget for rollback or candidate cancellation. const recoveryQuota = { name: "credential-recovery", actorMax: 5, orgMax: 20 }; @@ -61,6 +68,38 @@ internalCustody.use("*", credentialAdminAuthMiddleware()); internalCustody.use("*", projectContextMiddleware()); internalCustody.use("*", idempotencyKeyMiddleware()); +// Only public CSR data crosses this boundary. Private keys are generated by the API. +internalCustody.get("/fireblocks/csr", requireAction("wallets.configure"), async (c) => + success(c, { csr: await getPendingFireblocksCsr(c) }) +); +internalCustody.post( + "/fireblocks/csr", + requireAction("wallets.configure"), + validateBody(z.object({}).strict()), + authorize((c) => admitFireblocksRegistration(c)), + idempotent({ key: "required", serverErrors: "rerun" }), + async (c) => { + const idempotencyKey = getIdempotencyRecordKey(c); + if (!idempotencyKey) throw internalError(); + return created(c, { csr: await createFireblocksCsr(c, idempotencyKey) }); + } +); + +// Credentials are POSTed to avoid placing API user IDs in URLs or access logs. +internalCustody.post("/fireblocks/vaults", requireAction("wallets.configure"), async (c) => { + const body = fireblocksRegistrationSchema + .omit({ vaultAccountId: true }) + .extend({ + after: z.string().min(1).max(512).optional(), + namePrefix: z.string().trim().max(100).optional(), + }) + .strict() + .safeParse(await c.req.json().catch(() => null)); + if (!body.success) throw badRequest("Invalid vault discovery request"); + c.header("Cache-Control", "no-store"); + return success(c, await discoverFireblocksVaults(c, body.data)); +}); + // Reload-safe view of setup: which connections exist for this project, where // each sits in its lifecycle, and the safe credential columns. This is what // lets the dashboard resume a pending or failed install without ever asking @@ -138,7 +177,7 @@ internalCustody.get("/providers", requireAction("wallets.configure"), async (c) internalCustody.post("/provider-credentials", requireAction("wallets.configure"), async (c) => { const body = await c.req.json().catch(() => null); - const parsed = privySetup.validateSetupPayload(body); + const parsed = validateCustodySetupPayload(body); if (!parsed.success) { throw badRequest("Invalid request body", { errors: z.flattenError(parsed.error).fieldErrors, @@ -150,7 +189,7 @@ internalCustody.post("/provider-credentials", requireAction("wallets.configure") throw badRequest("Idempotency-Key is required"); } - const result = await privySetup.storeCredentials({ + const result = await storeCustodyCredentials({ context: c, payload: parsed.data, idempotencyKey, @@ -168,7 +207,7 @@ internalCustody.post( } const body = await c.req.json().catch(() => null); - const parsed = privySetup.validateSetupPayload(body); + const parsed = validateCustodySetupPayload(body); if (!parsed.success) { throw badRequest("Invalid request body", { errors: z.flattenError(parsed.error).fieldErrors, @@ -182,7 +221,7 @@ internalCustody.post( return created( c, - await privySetup.storeCredentials({ + await storeCustodyCredentials({ context: c, connectionId: params.data.connectionId, payload: parsed.data, @@ -233,7 +272,7 @@ internalCustody.post( if (!params.success) { throw badRequestParams({ errors: z.flattenError(params.error).fieldErrors }); } - const body = rotationBodySchema.safeParse(await c.req.json().catch(() => null)); + const body = byokRegistrationSchema.safeParse(await c.req.json().catch(() => null)); if (!body.success) { throw badRequest("Invalid request body", { errors: z.flattenError(body.error).fieldErrors, @@ -244,7 +283,7 @@ internalCustody.post( await enforceMeteredQuota(c, rotationQuota); return success( c, - await rotateProviderCredential(c, params.data.credentialId, body.data.fields, idempotencyKey) + await rotateProviderCredential(c, params.data.credentialId, body.data, idempotencyKey) ); } ); @@ -296,10 +335,7 @@ internalCustody.post( if (!params.success) { throw badRequestParams({ errors: z.flattenError(params.error).fieldErrors }); } - return success( - c, - await privySetup.activate({ context: c, connectionId: params.data.connectionId }) - ); + return success(c, await completeProviderCredentialInstallation(c, params.data.connectionId)); } ); @@ -311,9 +347,6 @@ internalCustody.post( if (!params.success) { throw badRequestParams({ errors: z.flattenError(params.error).fieldErrors }); } - return success( - c, - await privySetup.deactivate({ context: c, connectionId: params.data.connectionId }) - ); + return success(c, await cancelProviderCredentialInstallation(c, params.data.connectionId)); } ); diff --git a/apps/sdp-api/src/routes/internal-custody/provider-credential-lifecycle.test.ts b/apps/sdp-api/src/routes/internal-custody/provider-credential-lifecycle.test.ts index 89785c8406..9a882b220e 100644 --- a/apps/sdp-api/src/routes/internal-custody/provider-credential-lifecycle.test.ts +++ b/apps/sdp-api/src/routes/internal-custody/provider-credential-lifecycle.test.ts @@ -400,7 +400,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "shared-gcp-parent", - body: { fields: { appId: APP_ID, appSecret: "new-gcp-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-gcp-secret" } }, }); expect(response.status).toBe(200); const rows = await getDb(env).queryMany<{ secret_ref: string; secret_version_ref: string }>( @@ -423,7 +423,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "first-gcp-owner", - body: { fields: { appId: APP_ID, appSecret: "first-owner-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "first-owner-secret" } }, }); expect(response.status).toBe(200); expect(gcp.versions.size).toBe(1); @@ -439,7 +439,10 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${current}/rotate`, { method: "POST", key: `backend-change-${index}`, - body: { fields: { appId: APP_ID, appSecret: `backend-change-secret-${index}` } }, + body: { + provider: "privy", + fields: { appId: APP_ID, appSecret: `backend-change-secret-${index}` }, + }, }); expect(response.status).toBe(200); current = credentialResponseSchema.parse(await response.json()).data.providerCredential.id; @@ -472,7 +475,10 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: `rollback-backend-${previousBackend}`, - body: { fields: { appId: APP_ID, appSecret: "backend-rollback-secret" } }, + body: { + provider: "privy", + fields: { appId: APP_ID, appSecret: "backend-rollback-secret" }, + }, }); expect(rotated.status).toBe(200); const current = credentialResponseSchema.parse(await rotated.json()).data.providerCredential @@ -507,7 +513,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "prototype-shared-commit", - body: { fields: { appId: APP_ID, appSecret: "prototype-new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "prototype-new-secret" } }, }); expect(response.status).toBe(200); const { data } = credentialResponseSchema.parse(await response.json()); @@ -539,7 +545,7 @@ describe("provider credential lifecycle", () => { const rotation = lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "prototype-shared-cancel", - body: { fields: { appId: APP_ID, appSecret: "prototype-late-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "prototype-late-secret" } }, }); try { await started.promise; @@ -739,7 +745,7 @@ describe("provider credential lifecycle", () => { const pending = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-parent-deactivation", - body: { fields: { appId: APP_ID, appSecret: "pending-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "pending-secret" } }, }); expect(pending.status).toBe(200); const { data } = credentialResponseSchema.parse(await pending.json()); @@ -1121,7 +1127,7 @@ describe("provider credential lifecycle", () => { const rotation = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-gcp-root-deactivation", - body: { fields: { appId: APP_ID, appSecret: "live-child-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "live-child-secret" } }, }); expect(rotation.status).toBe(200); const childId = credentialResponseSchema.parse(await rotation.json()).data.providerCredential @@ -1210,7 +1216,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-quota-exhausted", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); expect(response.status).toBe(429); @@ -1235,7 +1241,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-quota-unavailable", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); expect(response.status).toBe(503); @@ -1265,7 +1271,7 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-quota-test", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); expect(rotated.status).toBe(200); const { data } = await rotated.json(); @@ -1346,7 +1352,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-shared-credential", - body: { fields: { appId: ` ${APP_ID} `, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: ` ${APP_ID} `, appSecret: "new-secret" } }, }); expect(response.status).toBe(200); @@ -1439,7 +1445,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-retirement-write-failure", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); expect(response.status).toBe(409); @@ -1546,7 +1552,7 @@ describe("provider credential lifecycle", () => { lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-gcp-insert-rollback", - body: { fields: { appId: APP_ID, appSecret: "new-gcp-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-gcp-secret" } }, }) ).rejects.toMatchObject({ code: "23514" }); expect(versionRef).toBeUndefined(); @@ -1589,7 +1595,7 @@ describe("provider credential lifecycle", () => { const request = { method: "POST" as const, key: "rotate-gcp-in-flight", - body: { fields: { appId: APP_ID, appSecret: "in-flight-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "in-flight-secret" } }, }; const rotation = lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, request); try { @@ -1640,7 +1646,10 @@ describe("provider credential lifecycle", () => { const request = { method: "POST" as const, key: `rotate-gcp-${writeFailure}`, - body: { fields: { appId: APP_ID, appSecret: "failed-gcp-write-secret" } }, + body: { + provider: "privy", + fields: { appId: APP_ID, appSecret: "failed-gcp-write-secret" }, + }, }; const response = await lifecycleRequest( `/provider-credentials/${CREDENTIAL_ID}/rotate`, @@ -1712,7 +1721,10 @@ describe("provider credential lifecycle", () => { lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-gcp-finalization-sql-failure", - body: { fields: { appId: APP_ID, appSecret: "sql-rejected-gcp-secret" } }, + body: { + provider: "privy", + fields: { appId: APP_ID, appSecret: "sql-rejected-gcp-secret" }, + }, }) ).rejects.toMatchObject({ code: "23514" }); expect(gcp.versions.size).toBe(1); @@ -1760,7 +1772,7 @@ describe("provider credential lifecycle", () => { const request = { method: "POST" as const, key: "rotate-gcp-cancel-creating", - body: { fields: { appId: APP_ID, appSecret: "late-gcp-write-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "late-gcp-write-secret" } }, }; const rotation = lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, request); let candidateId: string | undefined; @@ -1828,7 +1840,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "legacy-absence-ack", - body: { fields: { appId: APP_ID, appSecret: "late-known-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "late-known-secret" } }, }); expect(response.status).toBe(409); expect( @@ -1873,7 +1885,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: `rotate-gcp-adopted-${status}`, - body: { fields: { appId: APP_ID, appSecret: "adopted-gcp-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "adopted-gcp-secret" } }, }); expect(response.status).toBe(200); const { data } = credentialResponseSchema.parse(await response.json()); @@ -1949,7 +1961,7 @@ describe("provider credential lifecycle", () => { const request = { method: "POST" as const, key: `rotate-gcp-unknown-${committed}`, - body: { fields: { appId: APP_ID, appSecret: "uncertain-gcp-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "uncertain-gcp-secret" } }, }; expect( (await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, request)).status @@ -2003,7 +2015,7 @@ describe("provider credential lifecycle", () => { const request = { method: "POST" as const, key: "rotate-gcp-unknown-reservation", - body: { fields: { appId: APP_ID, appSecret: "unwritten-gcp-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "unwritten-gcp-secret" } }, }; expect( (await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, request)).status @@ -2049,7 +2061,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-invalid-credential", - body: { fields: { appId: APP_ID, appSecret: "invalid-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "invalid-secret" } }, }); expect(response.status).toBe(200); @@ -2080,7 +2092,7 @@ describe("provider credential lifecycle", () => { const request = { method: "POST" as const, key: "rotate-rejected-audit", - body: { fields: { appId: APP_ID, appSecret: "invalid-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "invalid-secret" } }, }; const response = await lifecycleRequest( `/provider-credentials/${CREDENTIAL_ID}/rotate`, @@ -2109,7 +2121,10 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-different-privy-account", - body: { fields: { appId: "different-privy-app", appSecret: "valid-other-secret" } }, + body: { + provider: "privy", + fields: { appId: "different-privy-app", appSecret: "valid-other-secret" }, + }, }); expect(response.status).toBe(200); @@ -2165,7 +2180,7 @@ describe("provider credential lifecycle", () => { const pending = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-concurrent-rejection", - body: { fields: { appId: APP_ID, appSecret: "rejected-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "rejected-secret" } }, }); const { data } = credentialResponseSchema.parse(await pending.json()); const candidateId = data.providerCredential.id; @@ -2214,7 +2229,7 @@ describe("provider credential lifecycle", () => { const pending = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-rejection-commit-unknown", - body: { fields: { appId: APP_ID, appSecret: "rejected-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "rejected-secret" } }, }); const { data } = credentialResponseSchema.parse(await pending.json()); const candidateId = data.providerCredential.id; @@ -2305,7 +2320,7 @@ describe("provider credential lifecycle", () => { const initial = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-commit-recovery", - body: { fields: { appId: APP_ID, appSecret: secret } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: secret } }, }); expect(initial.status).toBe(200); const { data } = credentialResponseSchema.parse(await initial.json()); @@ -2397,7 +2412,7 @@ describe("provider credential lifecycle", () => { lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-audit-unavailable", - body: { fields: { appId: APP_ID, appSecret: "rejected-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "rejected-secret" } }, }) ).rejects.toThrow("Required audit record could not be persisted"); expect( @@ -2427,7 +2442,7 @@ describe("provider credential lifecycle", () => { const created = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: `before-${operation}-audit-outage`, - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); const candidateId = credentialResponseSchema.parse(await created.json()).data .providerCredential.id; @@ -2464,7 +2479,7 @@ describe("provider credential lifecycle", () => { const request = { method: "POST" as const, key: `outcome-outage-${action}`, - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }; let currentId = CREDENTIAL_ID; if (action === "rollback") { @@ -2513,7 +2528,7 @@ describe("provider credential lifecycle", () => { const request = { method: "POST" as const, key: "audit-outage-terminal-replay", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }; const response = await lifecycleRequest( `/provider-credentials/${CREDENTIAL_ID}/rotate`, @@ -2552,7 +2567,7 @@ describe("provider credential lifecycle", () => { const pending = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-unreadable-secret", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); const { data } = credentialResponseSchema.parse(await pending.json()); const candidateId = data.providerCredential.id; @@ -2610,7 +2625,7 @@ describe("provider credential lifecycle", () => { const first = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-retry-unknown", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); expect(first.status).toBe(200); const firstBody = (await first.json()) as { @@ -2641,7 +2656,7 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-secret-access-cancel", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); const rotatedBody = (await rotated.json()) as { data: { providerCredential: { id: string } }; @@ -2689,7 +2704,7 @@ describe("provider credential lifecycle", () => { const request = { method: "POST" as const, key: "rotate-replay", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }; const first = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, request); @@ -2716,7 +2731,7 @@ describe("provider credential lifecycle", () => { const firstRequest = { method: "POST" as const, key: "rotate-historical-replay", - body: { fields: { appId: APP_ID, appSecret: "first-new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "first-new-secret" } }, }; const first = await lifecycleRequest( `/provider-credentials/${CREDENTIAL_ID}/rotate`, @@ -2730,7 +2745,7 @@ describe("provider credential lifecycle", () => { const second = await lifecycleRequest(`/provider-credentials/${firstCandidateId}/rotate`, { method: "POST", key: "rotate-after-historical-replay", - body: { fields: { appId: APP_ID, appSecret: "second-new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "second-new-secret" } }, }); expect(second.status).toBe(200); expect(await second.json()).toMatchObject({ @@ -2766,7 +2781,7 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-rollback", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); const rotatedBody = (await rotated.json()) as { data: { providerCredential: { id: string } }; @@ -2812,7 +2827,7 @@ describe("provider credential lifecycle", () => { const rejected = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "lineage-version-rejected", - body: { fields: { appId: APP_ID, appSecret: "rejected-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "rejected-secret" } }, }); expect(rejected.status).toBe(200); expect(await rejected.json()).toMatchObject({ @@ -2823,7 +2838,7 @@ describe("provider credential lifecycle", () => { await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "lineage-version-after-rejection", - body: { fields: { appId: APP_ID, appSecret: "replacement-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "replacement-secret" } }, }) ).toMatchObject({ status: 200 }); expect( @@ -2843,7 +2858,7 @@ describe("provider credential lifecycle", () => { const secondResponse = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "lineage-version-two", - body: { fields: { appId: APP_ID, appSecret: "second-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "second-secret" } }, }); expect(secondResponse.status).toBe(200); const second = credentialResponseSchema.parse(await secondResponse.json()).data @@ -2851,7 +2866,7 @@ describe("provider credential lifecycle", () => { const thirdResponse = await lifecycleRequest(`/provider-credentials/${second.id}/rotate`, { method: "POST", key: "lineage-version-three", - body: { fields: { appId: APP_ID, appSecret: "third-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "third-secret" } }, }); expect(thirdResponse.status).toBe(200); const third = credentialResponseSchema.parse(await thirdResponse.json()).data @@ -2863,7 +2878,7 @@ describe("provider credential lifecycle", () => { await lifecycleRequest(`/provider-credentials/${second.id}/rotate`, { method: "POST", key: "lineage-version-four", - body: { fields: { appId: APP_ID, appSecret: "fourth-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "fourth-secret" } }, }) ).toMatchObject({ status: 200 }); expect( @@ -2886,7 +2901,7 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-rollback-cleanup", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); const rotatedBody = (await rotated.json()) as { data: { providerCredential: { id: string } }; @@ -2938,7 +2953,7 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-cancel", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); const rotatedBody = (await rotated.json()) as { data: { providerCredential: { id: string } }; @@ -2994,7 +3009,7 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-gcp-cancel", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); const rotatedBody = (await rotated.json()) as { data: { providerCredential: { id: string } }; @@ -3033,7 +3048,7 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-invariant-breach", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); const rotatedBody = (await rotated.json()) as { data: { providerCredential: { id: string } }; @@ -3099,7 +3114,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-unpersisted-outcome", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); expect(response.status).toBe(503); @@ -3114,7 +3129,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-max-length-fields", - body: { fields: { appId: ` ${appId} `, appSecret } }, + body: { provider: "privy", fields: { appId: ` ${appId} `, appSecret } }, }); expect(response.status).toBe(200); @@ -3139,7 +3154,10 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-oversized-field", - body: { fields: { appId: APP_ID, appSecret: "new-secret", [field]: "x".repeat(4097) } }, + body: { + provider: "privy", + fields: { appId: APP_ID, appSecret: "new-secret", [field]: "x".repeat(4097) }, + }, }); expect(response.status).toBe(400); @@ -3151,6 +3169,7 @@ describe("provider credential lifecycle", () => { method: "POST", key: "rotate-unknown-field", body: { + provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" }, connectionIds: [CONNECTION_A_ID], }, @@ -3179,7 +3198,7 @@ describe("provider credential lifecycle", () => { const response = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-orphan", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); expect(response.status).toBe(409); @@ -3195,7 +3214,7 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: "rotate-before-pending-child", - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); expect(rotated.status).toBe(200); const rotatedBody = (await rotated.json()) as { @@ -3210,7 +3229,7 @@ describe("provider credential lifecycle", () => { const pending = await lifecycleRequest(`/provider-credentials/${currentId}/rotate`, { method: "POST", key: "rotate-pending-before-rollback", - body: { fields: { appId: APP_ID, appSecret: "pending-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "pending-secret" } }, }); expect(pending.status).toBe(200); const pendingBody = (await pending.json()) as { @@ -3288,7 +3307,7 @@ describe("provider credential lifecycle", () => { const rotated = await lifecycleRequest(`/provider-credentials/${CREDENTIAL_ID}/rotate`, { method: "POST", key: `rotate-before-rollback-${failureCode}`, - body: { fields: { appId: APP_ID, appSecret: "new-secret" } }, + body: { provider: "privy", fields: { appId: APP_ID, appSecret: "new-secret" } }, }); expect(rotated.status).toBe(200); const currentId = credentialResponseSchema.parse(await rotated.json()).data.providerCredential diff --git a/apps/sdp-api/src/routes/internal-custody/provider-credentials.test.ts b/apps/sdp-api/src/routes/internal-custody/provider-credentials.test.ts index f32a774911..306bcf90b9 100644 --- a/apps/sdp-api/src/routes/internal-custody/provider-credentials.test.ts +++ b/apps/sdp-api/src/routes/internal-custody/provider-credentials.test.ts @@ -1278,7 +1278,7 @@ describe("POST /internal/dashboard/custody/provider-credentials", () => { expect(await response.json()).toEqual({ error: { code: "CONFLICT", - message: "A Privy custody installation is already in progress for this project", + message: "A custody installation is already in progress for this project", details: { reason: "unfinished_installation_exists" }, }, meta: { requestId: "req_provider_credential_submit" }, diff --git a/apps/sdp-api/src/routes/internal-fireblocks/index.ts b/apps/sdp-api/src/routes/internal-fireblocks/index.ts new file mode 100644 index 0000000000..f632e56e08 --- /dev/null +++ b/apps/sdp-api/src/routes/internal-fireblocks/index.ts @@ -0,0 +1,39 @@ +import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { z } from "zod"; +import { badRequest } from "@/lib/errors"; +import { success } from "@/lib/response"; +import { unifiedAuthMiddleware } from "@/middleware/auth"; +import { requireHumanSession } from "@/middleware/human-session"; +import { enforceMeteredQuota } from "@/middleware/metered-quota"; +import { projectContextMiddleware } from "@/middleware/project-context"; +import { assertCallerMayPerform, requireAction } from "@/middleware/require-action"; +import { checkFireblocksDestination } from "@/services/custody/fireblocks-address-book.service"; +import type { Env } from "@/types/env"; + +const input = z + .object({ + walletId: z.string().min(1).max(200), + counterpartyId: z.string().min(1).max(200), + accountId: z.string().min(1).max(200), + address: z.string().min(32).max(44), + token: z.string().min(1).max(64), + }) + .strict(); +export const internalFireblocks = new Hono<{ Bindings: Env }>(); +internalFireblocks.use("*", bodyLimit({ maxSize: 4096 })); +internalFireblocks.use("*", unifiedAuthMiddleware()); +internalFireblocks.use("*", requireHumanSession()); +internalFireblocks.use("*", projectContextMiddleware()); + +internalFireblocks.use("*", async (c, next) => { + c.header("Cache-Control", "no-store"); + await next(); +}); +internalFireblocks.get("/destination", requireAction("wallets.read"), async (c) => { + assertCallerMayPerform(c, "counterparties.read"); + const parsed = input.safeParse(c.req.query()); + if (!parsed.success) throw badRequest("Invalid Fireblocks destination"); + await enforceMeteredQuota(c, { name: "fireblocks-destination", actorMax: 60, orgMax: 300 }); + return success(c, await checkFireblocksDestination(c, parsed.data)); +}); diff --git a/apps/sdp-api/src/routes/payments/mappers.ts b/apps/sdp-api/src/routes/payments/mappers.ts index 0b526475be..a4e117bb81 100644 --- a/apps/sdp-api/src/routes/payments/mappers.ts +++ b/apps/sdp-api/src/routes/payments/mappers.ts @@ -80,6 +80,19 @@ export function mapTransferRow(row: TransferRow) { confirmedAt: row.confirmed_at ?? undefined, fee: row.fee, error: row.error, + fireblocks: row.provider_data.fireblocksTransfer + ? { + transactionId: row.provider_reference, + status: + typeof row.provider_data.fireblocksStatus === "string" + ? row.provider_data.fireblocksStatus + : "SUBMISSION_PENDING", + subStatus: + typeof row.provider_data.fireblocksSubStatus === "string" + ? row.provider_data.fireblocksSubStatus + : null, + } + : undefined, ...(row.initiated_by_key_id ? { initiatedBy: { diff --git a/apps/sdp-api/src/routes/payments/transfer-batches/execute.ts b/apps/sdp-api/src/routes/payments/transfer-batches/execute.ts index 91d8598b9a..bcfaee2b83 100644 --- a/apps/sdp-api/src/routes/payments/transfer-batches/execute.ts +++ b/apps/sdp-api/src/routes/payments/transfer-batches/execute.ts @@ -13,6 +13,7 @@ import { internalError, transactionFailed } from "@/lib/errors"; import { createTenantScope } from "@/lib/tenant-scope"; import { type AppContext, getPaymentsRepository } from "@/routes/payments/context"; import { logEvent } from "@/runtime/money-path-events"; +import { signWithFreshBlockhash } from "@/services/payments/sign-with-fresh-blockhash"; import { createTransferSignedSubmissionStore } from "@/services/payments/signed-submission"; import type { SponsorshipFeePayment } from "@/services/sponsorship.service"; import { @@ -120,7 +121,11 @@ export async function executeChunk(params: { preflight: boolean; }): Promise { const { c, resolved, chunk } = params; - const partiallySigned = await partiallySignTransactionMessageWithSigners(chunk.message); + const partiallySigned = await signWithFreshBlockhash({ + rpc: resolved.rpc, + blockhash: chunk.message.lifetimeConstraint.blockhash, + sign: () => partiallySignTransactionMessageWithSigners(chunk.message), + }); const serializedTx = getBase64EncodedWireTransaction(partiallySigned); const txBytes = new Uint8Array(getTransactionEncoder().encode(partiallySigned)); const recipientRows = chunk.recipientIndexes.map((index) => { diff --git a/apps/sdp-api/src/routes/payments/transfers/handlers.ts b/apps/sdp-api/src/routes/payments/transfers/handlers.ts index db2a302dc8..9d2bc7ed4b 100644 --- a/apps/sdp-api/src/routes/payments/transfers/handlers.ts +++ b/apps/sdp-api/src/routes/payments/transfers/handlers.ts @@ -103,6 +103,13 @@ import { type OutboundPaymentOperation, resolveOutboundPaymentOperation, } from "@/services/payment-operation.service"; +import { + isFireblocksTransfer, + prepareFireblocksTransfer, + refreshFireblocksTransfer, + submitFireblocksTransfer, +} from "@/services/payments/fireblocks-transfer"; +import { signWithFreshBlockhash } from "@/services/payments/sign-with-fresh-blockhash"; import { createTransferSignedSubmissionStore } from "@/services/payments/signed-submission"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { @@ -681,7 +688,6 @@ async function executeSponsoredTransfer( } const rpc = createClusterRpc(); - const { blockhash, lastValidBlockHeight } = await getRecentBlockhash(rpc, "confirmed"); const feePayment = createSponsorshipFeePayment( c.env, resolveRequestSponsorshipScope(c), @@ -689,6 +695,7 @@ async function executeSponsoredTransfer( ); const feePayer = await feePayment.getFeePayer(); const instructions = await params.buildInstructions({ signer, feePayer, rpc }); + const { blockhash, lastValidBlockHeight } = await getRecentBlockhash(rpc, "confirmed"); const message = pipe( createTransactionMessage({ version: 0 }), @@ -698,7 +705,11 @@ async function executeSponsoredTransfer( (m) => addSignersToTransactionMessage([signer], m) ); - const partiallySigned = await partiallySignTransactionMessageWithSigners(message); + const partiallySigned = await signWithFreshBlockhash({ + rpc, + blockhash, + sign: () => partiallySignTransactionMessageWithSigners(message), + }); const txEncoder = getTransactionEncoder(); const txBytes = new Uint8Array(txEncoder.encode(partiallySigned)); const signature = await submitSponsoredTransaction({ @@ -865,6 +876,35 @@ function compareTransferRows( return createdAtComparison || right.id.localeCompare(left.id); } +async function executeFireblocksTransfer( + c: AppContext, + transfer: TransferRow, + auditService: AuditService, + auditIntent: Awaited> +) { + // Once claimed, an ambiguous provider response must never enter the local + // transaction failure/rebroadcast path. The durable provider intent owns recovery. + let updated: TransferRow; + try { + updated = await submitFireblocksTransfer(c.env, getPaymentsRepository(c), transfer); + } catch (error) { + await auditService.completeCritical(c, auditIntent, { + status: "failure", + metadata: { status: "failed" }, + }); + throw error; + } + if (updated.provider_reference || updated.status === "failed") { + await auditService.completeCritical(c, auditIntent, { + status: updated.status === "failed" ? "failure" : "success", + metadata: { fireblocksTransactionId: updated.provider_reference, status: updated.status }, + }); + } + if (updated.status === "failed") + throw transactionFailed(updated.error ?? "Fireblocks refused the transfer"); + return success(c, { transfer: mapTransferRow(updated) }); +} + export async function createTransfer(c: ValidatedBodyContext) { const body = c.req.valid("json"); const { scope, operation, rampTransfer } = getAuthorized(c); @@ -873,6 +913,14 @@ export async function createTransfer(c: ValidatedBodyContext isFireblocksTransfer(row) && row.status === "processing") + .slice(0, 4); + const refreshed = await mapSettledWithConcurrency(pending, 4, (row) => + refreshFireblocksTransfer(c.env, repo, row) + ); + const byId = new Map( + refreshed.flatMap((result) => + result.status === "fulfilled" ? [[result.value.id, result.value] as const] : [] + ) + ); + transferRows = transferRows.map((row) => byId.get(row.id) ?? row); const transfers = transferRows.map(mapTransferRow); return paginated(c, transfers, { total, page, pageSize }); } @@ -1377,5 +1440,6 @@ export async function getTransfer(c: AppContext) { throw notFound("Transfer"); } - return success(c, { transfer: mapTransferRow(row) }); + const refreshed = await refreshFireblocksTransfer(c.env, repo, row); + return success(c, { transfer: mapTransferRow(refreshed) }); } diff --git a/apps/sdp-api/src/routes/project-provider-availability.test.ts b/apps/sdp-api/src/routes/project-provider-availability.test.ts index 91e69ccd69..cc707fd96a 100644 --- a/apps/sdp-api/src/routes/project-provider-availability.test.ts +++ b/apps/sdp-api/src/routes/project-provider-availability.test.ts @@ -158,7 +158,10 @@ const SANDBOX_AVAILABILITY: ProjectProviderAvailability = { family: "custody", provider: "fireblocks", modes: [], - unavailableModes: SANDBOX_UNENTITLED_CUSTODY_MODES, + unavailableModes: [ + { mode: "managed", reason: "provider_not_entitled" }, + { mode: "byok", reason: "provider_not_entitled" }, + ], }, { family: "custody", provider: "privy", modes: ["managed", "byok"], unavailableModes: [] }, { @@ -223,7 +226,10 @@ const PRODUCTION_AVAILABILITY: ProjectProviderAvailability = { family: "custody", provider: "fireblocks", modes: [], - unavailableModes: PRODUCTION_UNOFFERED_CUSTODY_MODES, + unavailableModes: [ + { mode: "managed", reason: "custody_mode_not_allowed" }, + { mode: "byok", reason: "provider_not_entitled" }, + ], }, { family: "custody", @@ -482,6 +488,28 @@ describe("GET /v1/projects/:projectId/provider-availability", () => { expect(availability.providers.filter((entry) => entry.family === "earn")).toEqual([]); }); + it.each(["stable", "beta", "experimental"] as const)( + "advertises the actual Fireblocks BYOK stage to the dashboard in %s", + async (channel) => { + await setProviderOverrides({ custody: { fireblocks: true } }); + const availability = await readAvailability( + "sandbox", + deploymentEnv({ SDP_RELEASE_CHANNEL: channel }) + ); + const fireblocks = availability.providers.find( + (entry) => entry.family === "custody" && entry.provider === "fireblocks" + ); + expect(fireblocks).toMatchObject({ + modes: channel === "stable" ? [] : ["byok"], + unavailableModes: expect.arrayContaining( + channel === "stable" + ? [{ mode: "byok", reason: "custody_provider_not_in_release_channel" }] + : [] + ), + }); + } + ); + it.for(EARN_FLAG_OFF_CASES)( "reports every Earn provider as outside the release channel while $flag is off", async ({ flags }) => { diff --git a/apps/sdp-api/src/routes/route-actions.test.ts b/apps/sdp-api/src/routes/route-actions.test.ts index c66adb76e5..7701370b37 100644 --- a/apps/sdp-api/src/routes/route-actions.test.ts +++ b/apps/sdp-api/src/routes/route-actions.test.ts @@ -216,7 +216,8 @@ const REFUSED_BEFORE_THE_ACTION = /^(\/v1\/(api-keys|members|organizations)|\/v1\/projects\/:projectId\/(api-keys|members)|\/internal\/(playground|dashboard\/custody|dashboard\/helius-rings|dashboard\/wallets))(\/|$)/; /** The mounts above that refuse a key on every route, whatever its action. */ -const KEYLESS_MOUNTS = /^\/internal\/(playground|dashboard\/(custody|helius-rings|wallets))(\/|$)/; +const KEYLESS_MOUNTS = + /^\/internal\/(playground|dashboard\/(custody|fireblocks|helius-rings|wallets))(\/|$)/; describe("actions no API key may hold", () => { const forbiddenRoutes = routes.filter((route) => { diff --git a/apps/sdp-api/src/routes/route-modules.ts b/apps/sdp-api/src/routes/route-modules.ts index b64d99b250..a362e48334 100644 --- a/apps/sdp-api/src/routes/route-modules.ts +++ b/apps/sdp-api/src/routes/route-modules.ts @@ -23,7 +23,7 @@ export const ROUTE_OWNERS: readonly (readonly [RegExp, RouteOwner])[] = [ "recurring_payments", ], [/^\/(v1\/payments|v1\/counterparties|v1\/transactions|pay)(\/|$)/, "payments"], - [/^\/(v1\/wallets|internal\/dashboard\/(custody|wallets))(\/|$)/, "custody"], + [/^\/(v1\/wallets|internal\/dashboard\/(custody|wallets|fireblocks))(\/|$)/, "custody"], [/^\/v1\/compliance(\/|$)/, "compliance"], [ /^\/(health|docs|openapi\.json|llms\.txt|admin|webhooks\/clerk|internal\/dashboard\/network)(\/|$)/, diff --git a/apps/sdp-api/src/services/custody/byok-credential.test.ts b/apps/sdp-api/src/services/custody/byok-credential.test.ts new file mode 100644 index 0000000000..9afcdc3b80 --- /dev/null +++ b/apps/sdp-api/src/services/custody/byok-credential.test.ts @@ -0,0 +1,108 @@ +import { generateKeyPairSync } from "node:crypto"; +import { SignerError, SignerErrorCode } from "@solana/keychain-core"; +import { FireblocksSigner } from "@solana/keychain-fireblocks"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { env as testEnv } from "@/test/helpers/env"; +import { checkByokCredential, credentialPayload, parseByokCredential } from "./byok-credential"; + +const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const fields = { + apiKey: "tenant-api-key", + apiSecretPem: privateKey.export({ type: "pkcs1", format: "pem" }).toString(), + vaultAccountId: "0", +}; +const env = { ...testEnv, FIREBLOCKS_API_BASE_URL: "https://fireblocks.example.test" }; +const address = "11111111111111111111111111111111"; + +afterEach(() => vi.restoreAllMocks()); + +describe("Fireblocks BYOK credentials", () => { + it("normalizes RSA PEM and pins the asset to the project environment", () => { + const sandbox = credentialPayload("fireblocks", fields, "sandbox"); + expect(sandbox.assetId).toBe("SOL_TEST"); + expect(sandbox.apiSecretPem).toContain("BEGIN PRIVATE KEY"); + expect(credentialPayload("fireblocks", fields, "production").assetId).toBe("SOL"); + expect(() => credentialPayload("fireblocks", fields, undefined)).toThrow(); + expect(() => + credentialPayload("fireblocks", { ...fields, apiSecretPem: "invalid" }, "sandbox") + ).toThrow(); + expect(() => + credentialPayload("fireblocks", { appId: "app", appSecret: "secret" }, "sandbox") + ).toThrow(); + expect(() => + parseByokCredential("fireblocks", { appId: "app", appSecret: "secret" }) + ).toThrow(); + expect(() => parseByokCredential("fireblocks", fields)).toThrow(); + }); + + it("uses one authenticated keychain read without proposing or broadcasting a transaction", async () => { + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json({ addresses: [{ address }] })); + const credential = parseByokCredential( + "fireblocks", + credentialPayload("fireblocks", fields, "sandbox") + ); + const result = await checkByokCredential(env, credential); + expect(result).toMatchObject({ status: "success", wallet: { walletId: "fb_0", address } }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + `${env.FIREBLOCKS_API_BASE_URL}/v1/vault/accounts/0/SOL_TEST/addresses_paginated`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "X-API-Key": fields.apiKey, + Authorization: expect.stringMatching(/^Bearer /), + }), + }) + ); + }); + + it("allows API-user rotation but binds continuity to the vault address, ID and asset", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => + Response.json({ addresses: [{ address }] }) + ); + const auth = (overrides = {}) => + parseByokCredential("fireblocks", { + ...credentialPayload("fireblocks", fields, "sandbox"), + ...overrides, + }); + const first = await checkByokCredential(env, auth()); + expect(first.status).toBe("success"); + expect(await checkByokCredential(env, auth({ apiKey: "new-api-user" }))).toEqual(first); + expect(await checkByokCredential(env, auth({ vaultAccountId: "1" }))).not.toEqual(first); + expect(await checkByokCredential(env, auth({ assetId: "SOL" }))).not.toEqual(first); + vi.mocked(globalThis.fetch).mockResolvedValue( + Response.json({ addresses: [{ address: "So11111111111111111111111111111111111111112" }] }) + ); + expect(await checkByokCredential(env, auth())).not.toEqual(first); + }); + + it.each([401, 403, 404, 429, 500])( + "classifies HTTP %s without exposing provider response bodies", + async (status) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("sensitive upstream body", { status }) + ); + const result = await checkByokCredential( + env, + parseByokCredential("fireblocks", credentialPayload("fireblocks", fields, "sandbox")) + ); + expect(result).toEqual({ + status: [401, 403, 404].includes(status) ? "failed" : "retry_unknown", + }); + } + ); + + it("keeps malformed or transient responses retryable", async () => { + vi.spyOn(FireblocksSigner, "create").mockRejectedValue( + new SignerError(SignerErrorCode.IO_ERROR) + ); + expect( + await checkByokCredential( + env, + parseByokCredential("fireblocks", credentialPayload("fireblocks", fields, "sandbox")) + ) + ).toEqual({ status: "retry_unknown" }); + }); +}); diff --git a/apps/sdp-api/src/services/custody/byok-credential.ts b/apps/sdp-api/src/services/custody/byok-credential.ts new file mode 100644 index 0000000000..7c43a71a56 --- /dev/null +++ b/apps/sdp-api/src/services/custody/byok-credential.ts @@ -0,0 +1,139 @@ +import { createPrivateKey } from "node:crypto"; +import { checkFireblocksCredential } from "@sdp/custody/provisioning/fireblocks-credential"; +import { hashString } from "@sdp/payments/hash"; +import type { SdpEnvironment } from "@sdp/types/api-keys"; +import type { ByokCustodyProvider } from "@sdp/types/custody"; +import { z } from "zod"; +import { badRequest, internalError } from "@/lib/errors"; +import { + checkPrivyCredential, + getPrivyProviderAccountFingerprint, +} from "@/services/custody/privy-credential"; +import type { Env } from "@/types/env"; + +export const privyAuthenticationSchema = z + .object({ + appId: z.string().trim().min(1).max(4096), + appSecret: z.string().min(1).max(4096), + }) + .strict(); + +export const fireblocksAuthenticationSchema = z + .object({ + apiKey: z.string().trim().min(1).max(4096), + apiSecretPem: z.string().trim().min(1).max(16384), + vaultAccountId: z.string().trim().regex(/^\d+$/).max(100), + }) + .strict(); + +export const fireblocksRegistrationSchema = fireblocksAuthenticationSchema + .omit({ apiSecretPem: true }) + .extend({ signingKeyId: z.string().regex(/^fbcsr_[a-f0-9-]{36}$/) }) + .strict(); + +export const byokRegistrationSchema = z.discriminatedUnion("provider", [ + z.object({ provider: z.literal("privy"), fields: privyAuthenticationSchema }).strict(), + z.object({ provider: z.literal("fireblocks"), fields: fireblocksRegistrationSchema }).strict(), +]); +export type ByokRegistration = z.infer; +export type ByokCredentialFields = ByokRegistration["fields"]; + +export type ByokCredentialSecretFields = + | z.infer + | z.infer; +export type ByokCredentialAuthentication = + | ({ provider: "privy" } & z.infer) + | ({ provider: "fireblocks"; assetId: "SOL" | "SOL_TEST" } & z.infer< + typeof fireblocksAuthenticationSchema + >); + +/** Accept PKCS#1 and PKCS#8 exports, then store the PKCS#8 form keychain consumes. */ +export function credentialPayload( + provider: ByokCustodyProvider, + fields: ByokCredentialSecretFields, + environment: SdpEnvironment | undefined +): Record { + const parsed = ( + provider === "privy" ? privyAuthenticationSchema : fireblocksAuthenticationSchema + ).safeParse(fields); + if (!parsed.success) throw badRequest("Invalid provider credential fields"); + if ("appId" in parsed.data) return parsed.data; + if (!environment) throw internalError("Project environment is required"); + let apiSecretPem: string; + try { + const key = createPrivateKey(parsed.data.apiSecretPem.replace(/\\n/g, "\n")); + if (key.asymmetricKeyType !== "rsa" || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) + throw new Error("Invalid RSA key"); + apiSecretPem = key.export({ type: "pkcs8", format: "pem" }).toString(); + } catch { + throw badRequest("Fireblocks requires an unencrypted RSA private key in PEM format"); + } + return { + ...parsed.data, + apiSecretPem, + assetId: environment === "production" ? "SOL" : "SOL_TEST", + }; +} + +/** Never infer a provider from fields or fall back to deployment credentials. */ +export function parseByokCredential( + provider: string, + payload: Record +): ByokCredentialAuthentication { + if (provider === "privy") { + const parsed = privyAuthenticationSchema.safeParse(payload); + if (parsed.success) return { provider, ...parsed.data }; + } + if (provider === "fireblocks") { + const parsed = fireblocksAuthenticationSchema + .extend({ assetId: z.enum(["SOL", "SOL_TEST"]) }) + .safeParse(payload); + if (parsed.success) return { provider, ...parsed.data }; + } + throw internalError("Custody credential payload is unavailable"); +} + +export function credentialDisplayMetadata(fields: ByokCredentialFields): { + appIdSuffix?: string; + apiKeySuffix?: string; + primaryVaultId?: string; +} { + if ("appId" in fields) + return { appIdSuffix: fields.appId.length > 4 ? fields.appId.slice(-4) : undefined }; + return { + apiKeySuffix: fields.apiKey.length > 4 ? fields.apiKey.slice(-4) : undefined, + primaryVaultId: fields.vaultAccountId, + }; +} + +export type ByokCredentialCheck = + | { status: "success"; fingerprint: string; wallet?: { walletId: string; address: string } } + | { status: "failed" | "retry_unknown" }; + +/** One authenticated provider read; checking a Fireblocks connection never proposes a signature. */ +export async function checkByokCredential( + env: Env, + credential: ByokCredentialAuthentication +): Promise { + if (credential.provider === "privy") { + const status = await checkPrivyCredential(env, credential); + return status === "success" + ? { status, fingerprint: await getPrivyProviderAccountFingerprint(credential.appId) } + : { status }; + } + const result = await checkFireblocksCredential({ + apiKey: credential.apiKey, + privateKeyPem: credential.apiSecretPem, + vaultAccountId: credential.vaultAccountId, + assetId: credential.assetId, + apiBaseUrl: env.FIREBLOCKS_API_BASE_URL, + }); + if (result.status !== "success") return result; + // Vault numbers repeat across workspaces; bind rotation to the actual address. + const fingerprint = `sha256:${await hashString(JSON.stringify(["fireblocks", credential.assetId, credential.vaultAccountId, result.address]))}`; + return { + status: "success", + fingerprint, + wallet: { walletId: `fb_${credential.vaultAccountId}`, address: result.address }, + }; +} diff --git a/apps/sdp-api/src/services/custody/fireblocks-address-book.service.ts b/apps/sdp-api/src/services/custody/fireblocks-address-book.service.ts new file mode 100644 index 0000000000..a11fa1639c --- /dev/null +++ b/apps/sdp-api/src/services/custody/fireblocks-address-book.service.ts @@ -0,0 +1,145 @@ +import { + type FireblocksAddressEntry, + readFireblocksAddressBook, +} from "@sdp/custody/provisioning/fireblocks-address-book"; +import { resolveFireblocksTransferAsset } from "@sdp/custody/provisioning/fireblocks-transfers"; +import type { CustodyProvisioningRuntime } from "@sdp/custody/provisioning/runtime"; +import { SOL_MINT, wellKnownMint } from "@sdp/types/well-known-tokens"; +import { isAddress } from "@solana/addresses"; +import { getDb } from "@/db/client"; +import { + createCounterpartiesRepository, + createCounterpartyAccountsRepository, +} from "@/db/repositories/repository-factory"; +import { getAuth, requireProjectId, walletAccessAllows } from "@/lib/auth"; +import { badRequest, conflict, notFound, serviceUnavailable } from "@/lib/errors"; +import { getRequestTenantScope } from "@/lib/tenant-scope"; +import type { AppContext } from "@/routes/payments/context"; +import { CustodyRuntimeTargets } from "@/services/domain/signing/custody-runtime-target"; + +export const fireblocksRuntime: CustodyProvisioningRuntime = { + fetch: (input, init) => globalThis.fetch(input, init), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now: () => Date.now(), + randomUUID: () => crypto.randomUUID(), + getRandomValues: (data) => crypto.getRandomValues(new Uint8Array(data)), + sha256: (data) => crypto.subtle.digest("SHA-256", new Uint8Array(data)), +}; +type Access = NonNullable< + Awaited> +>; +export interface FireblocksDestinationInput { + walletId: string; + counterpartyId: string; + accountId: string; + token: string; + address: string; +} +export type FireblocksDestinationState = "approved" | "pending" | "not_whitelisted"; +export interface FireblocksDestinationResult { + state: FireblocksDestinationState; + assetId: string; +} +function nativeAsset(access: Access): "SOL" | "SOL_TEST" { + if (access.assetId !== "SOL" && access.assetId !== "SOL_TEST") + throw badRequest("Unsupported Fireblocks network"); + return access.assetId; +} +function entryState( + entries: FireblocksAddressEntry[], + assetId: string, + address: string +): FireblocksDestinationState { + const matches = entries.filter((entry) => entry.assetId === assetId && entry.address === address); + const approved = matches.filter((entry) => entry.status === "APPROVED"); + if ( + approved.some( + (entry) => + !entry.activationTime || + (Number.isFinite(Date.parse(entry.activationTime)) && + Date.parse(entry.activationTime) <= Date.now()) + ) + ) + return "approved"; + if (approved.length || matches.some((entry) => entry.status === "WAITING_FOR_APPROVAL")) + return "pending"; + return "not_whitelisted"; +} +export async function resolveFireblocksDestinationContext( + c: AppContext, + input: FireblocksDestinationInput +) { + const { organizationId } = getAuth(c); + const projectId = requireProjectId(c); + if (!walletAccessAllows(getAuth(c).walletAccess, input.walletId)) + throw notFound("Custody wallet"); + const db = getDb(c.env); + const access = await new CustodyRuntimeTargets(db, c.env, new Map()).getFireblocksTransferAccess({ + organizationId, + projectId, + custodyWalletId: input.walletId, + forSubmission: true, + }); + if (!access?.accountFingerprint) throw badRequest("An active Fireblocks BYOK wallet is required"); + const [account, contact] = await Promise.all([ + createCounterpartyAccountsRepository( + c.env, + getRequestTenantScope(c) + ).getCounterpartyAccountById({ + counterpartyAccountId: input.accountId, + counterpartyId: input.counterpartyId, + organizationId, + projectId, + }), + createCounterpartiesRepository(c.env, getRequestTenantScope(c)).getCounterpartyById({ + counterpartyId: input.counterpartyId, + organizationId, + projectId, + }), + ]); + if (!account || !contact || account.status !== "active" || contact.status !== "active") + throw notFound("Contact address"); + const address = account.details.address; + if ( + account.account_kind !== "crypto_wallet" || + account.details.network !== "solana" || + typeof address !== "string" || + !isAddress(address) + ) + throw badRequest("A Solana contact address is required"); + if (address !== input.address) + throw conflict("Contact address changed. Select the destination again."); + const native = nativeAsset(access); + const mint = wellKnownMint("USDC", native === "SOL" ? "mainnet-beta" : "devnet"); + if (!mint) throw badRequest("Unsupported Fireblocks network"); + const isSol = input.token === "SOL" || input.token === SOL_MINT; + if (!isSol && input.token !== mint) + throw badRequest("Fireblocks payments support SOL and USDC only"); + const asset = await resolveFireblocksTransferAsset( + fireblocksRuntime, + access, + native, + isSol ? null : mint + ); + return { + access, + address, + assetId: asset.id, + }; +} +async function loadAddressBook(access: Access, fresh = false) { + try { + return await readFireblocksAddressBook(fireblocksRuntime, access, fresh); + } catch { + throw serviceUnavailable("Couldn’t check the Fireblocks address book. Try again later."); + } +} +export async function checkFireblocksDestination( + c: AppContext, + input: FireblocksDestinationInput +): Promise { + const ctx = await resolveFireblocksDestinationContext(c, input); + const entries = await loadAddressBook(ctx.access); + const state = entryState(entries, ctx.assetId, ctx.address); + return { state, assetId: ctx.assetId }; +} diff --git a/apps/sdp-api/src/services/custody/fireblocks-address-book.test.ts b/apps/sdp-api/src/services/custody/fireblocks-address-book.test.ts new file mode 100644 index 0000000000..d06ce3f622 --- /dev/null +++ b/apps/sdp-api/src/services/custody/fireblocks-address-book.test.ts @@ -0,0 +1,323 @@ +import { invalidateFireblocksAddressBook } from "@sdp/custody/provisioning/fireblocks-address-book"; +import { SDP_RAMP_PROVIDER_STAGES } from "@sdp/types/release-channels"; +import { wellKnownMint } from "@sdp/types/well-known-tokens"; +import { Hono } from "hono"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { createApp } from "@/app"; +import { getDb } from "@/db/client"; +import { verifyClerkJwt } from "@/lib/clerk-token"; +import { AppError } from "@/lib/errors"; +import { kvStoreMiddleware } from "@/middleware/kv-store"; +import { internalCustody } from "@/routes/internal-custody"; +import { seedProjectApiKey } from "@/test/helpers/api-keys"; +import { signSeededClerkMember } from "@/test/helpers/clerk-member"; +import { env } from "@/test/helpers/env"; +import { seedDefaultProjects } from "@/test/helpers/projects"; +import { seedTestDatabase } from "@/test/mocks/db"; +import { clearKVStores } from "@/test/mocks/kv"; +import type { Env } from "@/types/env"; + +const ORGANIZATION_ID = "org_fireblocks_byok"; +const USER_ID = "usr_fireblocks_byok"; +const PROJECT_ID = "prj_fireblocks_byok"; +const ADDRESS = "11111111111111111111111111111111"; +const OTHER_ADDRESS = "So11111111111111111111111111111111111111112"; +const fields = { + apiKey: "tenant-fireblocks-key", + signingKeyId: "", + vaultAccountId: "0", +}; +const body = { + provider: "fireblocks", + fields: { credentialLabel: "Fireblocks treasury", scope: "project", ...fields }, + walletLabel: "Treasury", +}; +let token: string; +let app: Hono<{ Bindings: Env }>; +const testEnv: Env = { + ...env, + SDP_DEPLOYMENT_MODE: "self_hosted", + CREDENTIAL_SECRET_STORE_BACKEND: "encrypted_db", + CUSTODY_ENCRYPTION_KEY: Buffer.alloc(32, 11).toString("base64"), + FIREBLOCKS_API_BASE_URL: "https://fireblocks.example.test", + FIREBLOCKS_API_KEY: "deployment-key-must-not-be-used", + FIREBLOCKS_API_SECRET: "deployment-secret-must-not-be-used", +}; + +function request( + path: string, + payload?: unknown, + projectId = PROJECT_ID, + key = crypto.randomUUID() +) { + return app.request( + `/internal/dashboard/custody${path}`, + { + method: payload === undefined ? "GET" : "POST", + headers: { + Authorization: `Bearer ${token}`, + "X-Project-ID": projectId, + "Content-Type": "application/json", + "Idempotency-Key": key, + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }, + testEnv + ); +} + +function providerResponse(address = ADDRESS) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => Response.json({ addresses: [{ address }] })); +} + +async function connect(projectId = PROJECT_ID) { + if (projectId !== PROJECT_ID) + body.fields.signingKeyId = (await newRegistration(projectId)).signingKeyId; + const submitted = await request("/provider-credentials", body, projectId); + expect(submitted.status, await submitted.clone().text()).toBe(201); + const { data } = await submitted.json(); + const completed = await request(`/connections/${data.connectionId}/complete`, {}, projectId); + expect(completed.status).toBe(200); + expect((await completed.json()).data.completion.status).toBe("success"); + return z + .object({ connectionId: z.string(), providerCredential: z.object({ id: z.string() }) }) + .parse(data); +} + +async function newRegistration(projectId = PROJECT_ID) { + const response = await request("/fireblocks/csr", {}, projectId); + expect(response.status, await response.clone().text()).toBe(201); + const { data } = await response.json(); + return { ...fields, signingKeyId: data.csr.id }; +} + +beforeEach(async () => { + testEnv.CREDENTIAL_SECRET_STORE_BACKEND = "encrypted_db"; + fields.apiKey = `test-user-${crypto.randomUUID()}`; + body.fields.apiKey = fields.apiKey; + await seedTestDatabase(env); + await clearKVStores(env); + const db = getDb(env); + await db.execute( + "INSERT INTO organizations (id, name, slug, tier, status) VALUES (?, 'Fireblocks BYOK', 'fireblocks-byok', 'enterprise', 'active')", + [ORGANIZATION_ID] + ); + await db.execute( + "INSERT INTO users (id, email, email_verified, status) VALUES (?, 'fireblocks-byok@example.com', 1, 'active')", + [USER_ID] + ); + await db.execute( + "INSERT INTO organization_members (id, organization_id, user_id, role, status) VALUES ('mem_fireblocks_byok', ?, ?, 'admin', 'active')", + [ORGANIZATION_ID, USER_ID] + ); + await seedDefaultProjects(db, { + organizationId: ORGANIZATION_ID, + createdBy: USER_ID, + members: [USER_ID], + ids: { sandbox: PROJECT_ID, production: `${PROJECT_ID}_production` }, + }); + await db.execute( + "UPDATE organizations SET settings = (settings::jsonb || ?::jsonb)::text WHERE id = ?", + [JSON.stringify({ providerOverrides: { custody: { fireblocks: true } } }), ORGANIZATION_ID] + ); + token = await signSeededClerkMember(testEnv, db, USER_ID, ORGANIZATION_ID); + await verifyClerkJwt(token, testEnv); + app = new Hono<{ Bindings: Env }>(); + app.use("*", kvStoreMiddleware()); + app.use("*", async (c, next) => { + c.set("requestId", "req_fireblocks_byok"); + await next(); + }); + app.route("/internal/dashboard/custody", internalCustody); + app.onError((error) => { + if (error instanceof AppError) + return Response.json(error.toResponse(), { status: error.statusCode }); + throw error; + }); + fields.signingKeyId = (await newRegistration()).signingKeyId; + body.fields.signingKeyId = fields.signingKeyId; +}); +afterEach(() => vi.restoreAllMocks()); + +const api = createApp({ + rampProviderStages: SDP_RAMP_PROVIDER_STAGES, +}); +const contactId = "cpty_book"; +const accountId = "counterparty_account_book"; +let walletId: string; +let connectionId: string; +let external: unknown[]; +let internal: unknown[]; +let calls: { path: string; method: string; body: unknown; key: string | null }[]; +let failRead = false; +function entry(status = "APPROVED", assetId = "SOL_TEST", address = OTHER_ADDRESS) { + return { + id: "external-wallet", + name: "Fireblocks contact", + assets: [{ id: assetId, address, status }], + }; +} +async function ready() { + const mock = providerResponse(); + connectionId = (await connect()).connectionId; + const db = getDb(env); + walletId = + ( + await db.queryOne<{ id: string }>( + "SELECT id FROM custody_wallets WHERE custody_connection_id = ?", + [connectionId] + ) + )?.id ?? ""; + await db.execute( + "INSERT INTO counterparties (id, organization_id, project_id, entity_type, display_name, provider_data) VALUES (?, ?, ?, 'business', 'My SDP name', '{}'::jsonb)", + [contactId, ORGANIZATION_ID, PROJECT_ID] + ); + await db.execute( + "INSERT INTO counterparty_accounts (id, organization_id, project_id, counterparty_id, account_kind, label, details, network, address) VALUES (?, ?, ?, ?, 'crypto_wallet', 'Saved address', ?::jsonb, 'solana', ?)", + [ + accountId, + ORGANIZATION_ID, + PROJECT_ID, + contactId, + JSON.stringify({ network: "solana", address: OTHER_ADDRESS }), + OTHER_ADDRESS, + ] + ); + external = []; + internal = []; + calls = []; + failRead = false; + mock.mockImplementation(async (url, init) => { + const path = new URL(String(url)).pathname; + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + expect(headers.get("X-API-Key")).toBe(fields.apiKey); + expect(headers.get("Authorization")).toMatch(/^Bearer /); + calls.push({ + path, + method, + body: init?.body ? JSON.parse(String(init.body)) : null, + key: headers.get("Idempotency-Key"), + }); + if (method === "GET") { + if (failRead) return Response.json({ message: "sensitive provider detail" }, { status: 503 }); + if (path === "/v1/external_wallets") return Response.json(external); + if (path === "/v1/internal_wallets") return Response.json(internal); + if (path === "/v1/supported_assets") + return Response.json([ + { + id: "USDC_SOL", + nativeAsset: "SOL_TEST", + issuerAddress: wellKnownMint("USDC", "devnet"), + type: "SOL_ASSET", + decimals: 6, + }, + ]); + } + throw new Error(`Unexpected provider call: ${method} ${path}`); + }); +} +function selection() { + return { walletId, counterpartyId: contactId, accountId, token: "SOL", address: OTHER_ADDRESS }; +} +async function bookRequest(method = "GET", overrides = {}, projectId = PROJECT_ID) { + const input = { ...selection(), ...overrides }; + const response = await api.request( + `/internal/dashboard/fireblocks/${method === "GET" ? `destination?${new URLSearchParams(input)}` : "whitelist"}`, + { + method, + headers: { + Authorization: `Bearer ${token}`, + "X-Project-ID": projectId, + "Content-Type": "application/json", + }, + body: method === "POST" ? JSON.stringify(input) : undefined, + }, + testEnv + ); + return response; +} +function expireBook() { + invalidateFireblocksAddressBook({ + apiKey: fields.apiKey, + apiSecretPem: "unused", + apiBaseUrl: testEnv.FIREBLOCKS_API_BASE_URL, + }); +} +describe("Fireblocks read-only address book", () => { + it("shares two reads, recognizes internal whitelist entries and matches the exact network", async () => { + await ready(); + internal = [entry()]; + const replies = await Promise.all([bookRequest(), bookRequest()]); + for (const reply of replies) expect((await reply.json()).data.state).toBe("approved"); + expect(calls).toHaveLength(2); + expireBook(); + internal = [entry("APPROVED", "SOL")]; + expect((await (await bookRequest()).json()).data.state).toBe("not_whitelisted"); + expect(calls.every((call) => call.method === "GET")).toBe(true); + }); + it("recognizes pending approvals and activation delays", async () => { + await ready(); + external = [entry("WAITING_FOR_APPROVAL")]; + expect((await (await bookRequest()).json()).data.state).toBe("pending"); + expireBook(); + const future = entry(); + Object.assign(future.assets[0], { activationTime: "2099-01-01T00:00:00Z" }); + external = [future]; + expect((await (await bookRequest()).json()).data.state).toBe("pending"); + }); + it("does not turn provider failures or malformed books into an absent address or cache failures", async () => { + await ready(); + failRead = true; + const failed = await bookRequest(); + expect(failed.status).toBe(503); + expect(await failed.text()).not.toContain("sensitive provider detail"); + failRead = false; + external = [{ id: "bad-wallet", assets: [] }]; + expect((await bookRequest()).status).toBe(503); + external = []; + expect((await (await bookRequest()).json()).data.state).toBe("not_whitelisted"); + }); + it("rejects changed addresses and cross-project access before provider calls", async () => { + await ready(); + expect((await bookRequest("GET", { address: ADDRESS })).status).toBe(409); + expect((await bookRequest("GET", {}, `${PROJECT_ID}_production`)).status).toBe(404); + expect(calls).toHaveLength(0); + }); + it("rejects API keys on the internal dashboard book endpoint", async () => { + await ready(); + const raw = `sk_test_${crypto.randomUUID().replaceAll("-", "")}`; + await seedProjectApiKey(getDb(env), testEnv, { + key: { id: "key_book_read", raw, prefix: raw.slice(0, 16) }, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + createdBy: USER_ID, + grants: ["wallets.read", "counterparties.read"], + }); + token = raw; + expect((await bookRequest()).status).toBe(403); + expect(calls).toEqual([]); + }); + it("removes the whitelist endpoint even for an administrator", async () => { + await ready(); + expect((await bookRequest("POST")).status).toBe(404); + expect(calls).toEqual([]); + }); + it("listing contacts no longer triggers provider calls or automatic imports", async () => { + await ready(); + external = [entry("APPROVED", "SOL_TEST", ADDRESS)]; + const response = await api.request( + "/v1/counterparties", + { + headers: { Authorization: `Bearer ${token}`, "X-Project-ID": PROJECT_ID }, + }, + testEnv + ); + expect(response.status).toBe(200); + expect((await response.json()).data.counterparties).toHaveLength(1); + expect(calls).toEqual([]); + }); +}); diff --git a/apps/sdp-api/src/services/custody/fireblocks-csr.test.ts b/apps/sdp-api/src/services/custody/fireblocks-csr.test.ts new file mode 100644 index 0000000000..cde2287a57 --- /dev/null +++ b/apps/sdp-api/src/services/custody/fireblocks-csr.test.ts @@ -0,0 +1,26 @@ +import { execFileSync } from "node:child_process"; +import { createPublicKey } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { generateFireblocksCsr } from "./fireblocks-csr"; + +describe("Fireblocks CSR generation", () => { + it("creates a valid RSA-4096 PKCS#10 request, independently verified by OpenSSL", async () => { + const id = `fbcsr_${crypto.randomUUID()}`; + const { csrPem, privateKeyPem } = await generateFireblocksCsr(id); + const verification = execFileSync("openssl", ["req", "-verify", "-noout", "-text"], { + input: csrPem, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + expect(verification).toContain(id); + expect(verification).toMatch(/4096 bit/); + const csrPublicKey = execFileSync("openssl", ["req", "-pubkey", "-noout"], { + input: csrPem, + encoding: "utf8", + }); + expect(csrPublicKey).toBe( + createPublicKey(privateKeyPem).export({ type: "spki", format: "pem" }) + ); + expect(csrPem).not.toContain("PRIVATE KEY"); + }); +}); diff --git a/apps/sdp-api/src/services/custody/fireblocks-csr.ts b/apps/sdp-api/src/services/custody/fireblocks-csr.ts new file mode 100644 index 0000000000..7a43bf9813 --- /dev/null +++ b/apps/sdp-api/src/services/custody/fireblocks-csr.ts @@ -0,0 +1,68 @@ +import { createPublicKey, generateKeyPair, sign } from "node:crypto"; + +/** PKCS#10 (RFC 2986) using Node's RSA implementation; no private key leaves the API. */ +export async function generateFireblocksCsr(keyId: string): Promise<{ + csrPem: string; + privateKeyPem: string; +}> { + const privateKey = await new Promise((resolve, reject) => { + generateKeyPair( + "rsa", + { modulusLength: 4096, publicExponent: 0x10001 }, + (error, _publicKey, key) => { + if (error) reject(error); + else resolve(key); + } + ); + }); + const publicKey = createPublicKey(privateKey).export({ type: "spki", format: "der" }); + // Subject CN is an opaque generated identifier, never user-provided ASN.1 or personal data. + const subject = der( + 0x30, + der( + 0x31, + der( + 0x30, + Buffer.concat([ + Buffer.from("0603550403", "hex"), + der(0x0c, Buffer.from(`SDP Fireblocks ${keyId}`)), + ]) + ) + ) + ); + const info = der( + 0x30, + Buffer.concat([Buffer.from("020100", "hex"), subject, publicKey, Buffer.from("a000", "hex")]) + ); + // sha256WithRSAEncryption OID, NULL parameters; BIT STRING starts with zero unused bits. + const csr = der( + 0x30, + Buffer.concat([ + info, + // biome-ignore lint/security/noSecrets: DER algorithm identifier for SHA256 with RSA, not key material. + Buffer.from("300d06092a864886f70d01010b0500", "hex"), + der(0x03, Buffer.concat([Buffer.from([0]), sign("RSA-SHA256", info, privateKey)])), + ]) + ); + return { + csrPem: `-----BEGIN CERTIFICATE REQUEST-----\n${csr + .toString("base64") + .match(/.{1,64}/g) + ?.join("\n")}\n-----END CERTIFICATE REQUEST-----\n`, + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; +} + +function der(tag: number, content: Buffer): Buffer { + const size = content.length; + const length = + size < 128 + ? Buffer.from([size]) + : (() => { + let hex = size.toString(16); + if (hex.length % 2) hex = `0${hex}`; + const bytes = Buffer.from(hex, "hex"); + return Buffer.concat([Buffer.from([0x80 | bytes.length]), bytes]); + })(); + return Buffer.concat([Buffer.from([tag]), length, content]); +} diff --git a/apps/sdp-api/src/services/custody/fireblocks-registration.service.ts b/apps/sdp-api/src/services/custody/fireblocks-registration.service.ts new file mode 100644 index 0000000000..ad6236f96b --- /dev/null +++ b/apps/sdp-api/src/services/custody/fireblocks-registration.service.ts @@ -0,0 +1,293 @@ +import { FireblocksApiError } from "@sdp/custody/provisioning/fireblocks-http"; +import { + type ByokCustodyProvider, + isProviderCredentialCreationInProgress, +} from "@sdp/types/custody"; +import type { Context } from "hono"; +import { type DatabaseClient, getDb } from "@/db/client"; +import { getAuth, requireProjectId } from "@/lib/auth"; +import { + badRequest, + conflict, + forbidden, + internalError, + notFound, + providerUnavailable, +} from "@/lib/errors"; +import { enforceMeteredQuota } from "@/middleware/metered-quota"; +import { AuditService } from "@/services/audit.service"; +import { + assertCustodyEncryptionScheme, + createCustodyCipher, +} from "@/services/custody-cipher/cipher-router"; +import { + admitByokCustodySetup, + refuseCustodySetup, +} from "@/services/provider-availability.service"; +import { ProviderCredentialStore } from "@/services/stores/provider-credential.store"; +import type { Env } from "@/types/env"; +import { + type ByokCredentialFields, + credentialPayload, + fireblocksRegistrationSchema, +} from "./byok-credential"; +import { generateFireblocksCsr } from "./fireblocks-csr"; +import { listFireblocksVaults } from "./provisioning"; + +type AppContext = Context<{ Bindings: Env }>; +interface CsrRow { + id: string; + csr_pem: string; + expires_at: string; + encrypted_private_key: string | null; + claim_fingerprint: string | null; +} +export interface FireblocksCsr { + id: string; + csrPem: string; + expiresAt: string; +} +function publicCsr(row: CsrRow): FireblocksCsr { + return { id: row.id, csrPem: row.csr_pem, expiresAt: row.expires_at }; +} +export async function admitFireblocksRegistration(c: AppContext) { + const auth = getAuth(c); + if (!auth.userId || !c.get("clerk")) + throw forbidden("Credential administration requires a signed-in user"); + const scope = { organizationId: auth.organizationId, projectId: requireProjectId(c) }; + const admission = await admitByokCustodySetup(c.env, getDb(c.env), scope, "fireblocks"); + if (!admission.admitted) throw refuseCustodySetup(admission); + return { ...scope, userId: auth.userId }; +} + +/** Reloads public onboarding material only; this endpoint never decrypts a key. */ +export async function getPendingFireblocksCsr(c: AppContext): Promise { + const scope = await admitFireblocksRegistration(c); + const row = await getDb(c.env).queryOne( + `SELECT id, csr_pem, expires_at FROM fireblocks_csr_requests + WHERE organization_id = ? AND project_id = ? AND status = 'active' AND encrypted_private_key IS NOT NULL + AND claim_fingerprint IS NULL AND expires_at > sdp_iso_now() + ORDER BY created_at DESC LIMIT 1`, + [scope.organizationId, scope.projectId] + ); + return row ? publicCsr(row) : null; +} + +/** Dedicated RSA-4096 key per setup attempt, scoped to one organization and project. */ +export async function createFireblocksCsr( + c: AppContext, + idempotencyKey: string +): Promise { + const scope = await admitFireblocksRegistration(c); + assertCustodyEncryptionScheme(c.env); + const db = getDb(c.env); + const existing = await db.queryOne( + `SELECT id, csr_pem, expires_at, encrypted_private_key, claim_fingerprint + FROM fireblocks_csr_requests WHERE organization_id = ? AND project_id = ? AND idempotency_key = ?`, + [scope.organizationId, scope.projectId, idempotencyKey] + ); + if (existing) return publicCsr(existing); + await enforceMeteredQuota(c, { name: "fireblocks-csr", actorMax: 3, orgMax: 10 }); + const id = `fbcsr_${crypto.randomUUID()}`; + const audit = new AuditService(db); + const intent = await audit.beginCritical(c, { + organizationId: scope.organizationId, + userId: scope.userId, + action: "create", + resourceType: "provider_credential", + resourceId: id, + metadata: { event: "fireblocks_csr_generation_started", projectId: scope.projectId }, + }); + const { csrPem, privateKeyPem } = await generateFireblocksCsr(id); + const encrypted = await createCustodyCipher(c.env).encrypt( + scope.organizationId, + JSON.stringify({ id, projectId: scope.projectId, privateKeyPem }) + ); + const row = await db.transaction(async (tx) => { + if ( + !(await new ProviderCredentialStore(tx).lockAuthorizedProjects( + scope.organizationId, + scope.userId, + [scope.projectId] + )) + ) + throw forbidden("Requested project is not accessible"); + // Recheck after generation under the project lock to serialize concurrent retries. + const replay = await tx.queryOne( + `SELECT id, csr_pem, expires_at FROM fireblocks_csr_requests + WHERE organization_id = ? AND project_id = ? AND idempotency_key = ?`, + [scope.organizationId, scope.projectId, idempotencyKey] + ); + if (replay) return replay; + const pending = await tx.queryOne<{ count: string }>( + `SELECT count(*)::text AS count FROM fireblocks_csr_requests + WHERE organization_id = ? AND project_id = ? AND status = 'active' AND encrypted_private_key IS NOT NULL + AND expires_at > sdp_iso_now()`, + [scope.organizationId, scope.projectId] + ); + if (Number(pending?.count) >= 5) + throw conflict("Finish an existing Fireblocks registration before generating another CSR"); + const inserted = await tx.queryOne( + `INSERT INTO fireblocks_csr_requests + (id, organization_id, project_id, idempotency_key, csr_pem, encrypted_private_key, created_by, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, to_char(timezone('UTC', now()) + interval '7 days', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) + RETURNING id, csr_pem, expires_at`, + [id, scope.organizationId, scope.projectId, idempotencyKey, csrPem, encrypted, scope.userId] + ); + if (!inserted) throw internalError(); + return inserted; + }); + await audit.completeCritical(c, intent, { + metadata: { event: "fireblocks_csr_generated", signingKeyId: row.id }, + }); + return publicCsr(row); +} + +// Request-local deduplication avoids repeated key reads/decrypts during validation and storage. +const requestPayloads = new WeakMap>>>(); +export async function resolveRegistrationPayload( + c: AppContext, + provider: ByokCustodyProvider, + fields: ByokCredentialFields, + idempotencyKey: string +): Promise> { + if (provider === "privy") { + if (!("appId" in fields)) throw badRequest("Invalid provider credential fields"); + return credentialPayload(provider, fields, c.get("projectEnvironment")); + } + const parsed = fireblocksRegistrationSchema.safeParse(fields); + if (!parsed.success) throw badRequest("Use a generated Fireblocks CSR to register an API user"); + const input = parsed.data; + // These are public identifiers, not a password. Store their canonical identity + // directly: exact replay equality needs neither a password hash nor a digest. + const fingerprint = JSON.stringify([ + c.req.path, + idempotencyKey, + input.signingKeyId, + input.apiKey, + input.vaultAccountId, + ]); + let cache = requestPayloads.get(c); + if (!cache) { + cache = new Map(); + requestPayloads.set(c, cache); + } + const cached = cache.get(fingerprint); + if (cached) return cached; + const payload = (async () => { + const scope = await admitFireblocksRegistration(c); + const row = await getDb(c.env).queryOne( + `UPDATE fireblocks_csr_requests SET claim_fingerprint = ? + WHERE id = ? AND organization_id = ? AND project_id = ? + AND status = 'active' AND encrypted_private_key IS NOT NULL AND expires_at > sdp_iso_now() + AND (claim_fingerprint IS NULL OR claim_fingerprint = ?) + RETURNING id, csr_pem, expires_at, encrypted_private_key, claim_fingerprint`, + [fingerprint, input.signingKeyId, scope.organizationId, scope.projectId, fingerprint] + ); + if (!row?.encrypted_private_key) throw notFound("Available Fireblocks CSR"); + const secret = JSON.parse( + await createCustodyCipher(c.env).decrypt(scope.organizationId, row.encrypted_private_key) + ); + if ( + secret.id !== row.id || + secret.projectId !== scope.projectId || + typeof secret.privateKeyPem !== "string" + ) + throw internalError("Fireblocks registration key is unavailable"); + return credentialPayload( + "fireblocks", + { + apiKey: input.apiKey, + vaultAccountId: input.vaultAccountId, + apiSecretPem: secret.privateKeyPem, + }, + c.get("projectEnvironment") + ); + })(); + cache.set(fingerprint, payload); + return payload; +} + +/** Erase the staging copy only after the credential lifecycle durably owns the key. */ +export async function retireFireblocksCsr( + c: AppContext, + fields: ByokCredentialFields, + credential: { id: string; status: import("@sdp/types/custody").ProviderCredentialStatus } +): Promise { + if (!("signingKeyId" in fields) || isProviderCredentialCreationInProgress(credential.status)) + return; + const auth = getAuth(c); + await getDb(c.env).execute( + `UPDATE fireblocks_csr_requests SET status = 'archived', encrypted_private_key = NULL, consumed_at = sdp_iso_now() + WHERE id = ? AND organization_id = ? AND project_id = ? + AND EXISTS (SELECT 1 FROM provider_credentials WHERE id = ? AND organization_id = ? + AND (encrypted_secret_payload IS NOT NULL OR secret_version_ref IS NOT NULL))`, + [ + fields.signingKeyId, + auth.organizationId, + requireProjectId(c), + credential.id, + auth.organizationId, + ] + ); +} + +/** Called by the existing secret-retirement worker; bounded and idempotent. */ +export async function eraseExpiredFireblocksCsrs(db: DatabaseClient): Promise { + await db.execute(`UPDATE fireblocks_csr_requests SET status = 'archived', encrypted_private_key = NULL + WHERE id IN (SELECT id FROM fireblocks_csr_requests + WHERE encrypted_private_key IS NOT NULL AND expires_at <= sdp_iso_now() + ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`); +} + +/** Authenticated discovery uses only this project's unconsumed registration key. */ +export async function discoverFireblocksVaults( + c: AppContext, + input: { + signingKeyId: string; + apiKey: string; + after?: string; + namePrefix?: string; + } +) { + const scope = await admitFireblocksRegistration(c); + await enforceMeteredQuota(c, { name: "fireblocks-vault-discovery", actorMax: 30, orgMax: 100 }); + const row = await getDb(c.env).queryOne( + `SELECT id, encrypted_private_key FROM fireblocks_csr_requests + WHERE id = ? AND organization_id = ? AND project_id = ? + AND status = 'active' AND encrypted_private_key IS NOT NULL AND claim_fingerprint IS NULL + AND expires_at > sdp_iso_now()`, + [input.signingKeyId, scope.organizationId, scope.projectId] + ); + if (!row?.encrypted_private_key) throw notFound("Available Fireblocks CSR"); + const secret = JSON.parse( + await createCustodyCipher(c.env).decrypt(scope.organizationId, row.encrypted_private_key) + ); + if ( + secret.id !== row.id || + secret.projectId !== scope.projectId || + typeof secret.privateKeyPem !== "string" + ) + throw internalError("Fireblocks registration key is unavailable"); + const environment = c.get("projectEnvironment"); + if (!environment) throw internalError("Project environment is required"); + try { + return await listFireblocksVaults( + c.env, + { + apiKey: input.apiKey, + apiSecretPem: secret.privateKeyPem, + assetId: environment === "production" ? "SOL" : "SOL_TEST", + }, + { after: input.after, namePrefix: input.namePrefix } + ); + } catch (error) { + if (error instanceof FireblocksApiError && [401, 403].includes(error.status)) + throw badRequest( + "Fireblocks could not authorize this API user. Confirm the CSR registration is approved, the API user ID is correct, and this server's IP is allowed." + ); + throw providerUnavailable( + "Could not load Fireblocks vaults. Check your workspace connection and try again." + ); + } +} diff --git a/apps/sdp-api/src/services/custody/provisioning.ts b/apps/sdp-api/src/services/custody/provisioning.ts index 15b1d7b27a..3e1124b689 100644 --- a/apps/sdp-api/src/services/custody/provisioning.ts +++ b/apps/sdp-api/src/services/custody/provisioning.ts @@ -26,6 +26,10 @@ import { type ProvisionAnchorageResult, provisionAnchorageWallet as provisionAnchorageWalletInCustody, } from "@sdp/custody/provisioning/anchorage"; +import { + getFireblocksVault as getFireblocksVaultInCustody, + listFireblocksVaults as listFireblocksVaultsInCustody, +} from "@sdp/custody/provisioning/fireblocks-vaults"; import type { CustodyProvisioningRuntime } from "@sdp/custody/provisioning/runtime"; import type { PrivyCredentialAuthentication } from "@/services/custody/privy-credential"; import type { Env } from "@/types/env"; @@ -216,3 +220,26 @@ function createRuntime(): CustodyProvisioningRuntime { sha256: (data) => crypto.subtle.digest("SHA-256", new Uint8Array(data)), }; } + +export function listFireblocksVaults( + env: Env, + credential: { apiKey: string; apiSecretPem: string; assetId: "SOL" | "SOL_TEST" }, + options: { after?: string; namePrefix?: string } +) { + return listFireblocksVaultsInCustody( + createRuntime(), + { ...credential, apiBaseUrl: env.FIREBLOCKS_API_BASE_URL }, + options + ); +} +export function getFireblocksVault( + env: Env, + credential: { apiKey: string; apiSecretPem: string; assetId: "SOL" | "SOL_TEST" }, + id: string +) { + return getFireblocksVaultInCustody( + createRuntime(), + { ...credential, apiBaseUrl: env.FIREBLOCKS_API_BASE_URL }, + id + ); +} diff --git a/apps/sdp-api/src/services/domain/signing/custody-runtime-target.test.ts b/apps/sdp-api/src/services/domain/signing/custody-runtime-target.test.ts index 6ee36b7a22..89dbb146b2 100644 --- a/apps/sdp-api/src/services/domain/signing/custody-runtime-target.test.ts +++ b/apps/sdp-api/src/services/domain/signing/custody-runtime-target.test.ts @@ -97,6 +97,22 @@ describe("CustodyRuntimeTargets", () => { requestDelayMs: env.PRIVY_REQUEST_DELAY_MS, }; + it("leaves managed Fireblocks wallets on the existing signing path", async () => { + const wallet = await seedConfig({ provider: "fireblocks", projectId: PROJECT_ID }); + const read = mockStoredCredentialRead(); + const targets = new CustodyRuntimeTargets(getDb(env), env, new Map()); + await expect( + targets.getFireblocksTransferAccess({ + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + custodyWalletId: `cwlt_${wallet.id}`, + forSubmission: true, + }) + ).resolves.toBeNull(); + expect(read).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + beforeEach(async () => { await seedTestDatabase(env); custodyReleaseChannel.outOfChannelMode = null; diff --git a/apps/sdp-api/src/services/domain/signing/custody-runtime-target.ts b/apps/sdp-api/src/services/domain/signing/custody-runtime-target.ts index a8aaa9c3e9..5b28ab7f9c 100644 --- a/apps/sdp-api/src/services/domain/signing/custody-runtime-target.ts +++ b/apps/sdp-api/src/services/domain/signing/custody-runtime-target.ts @@ -9,6 +9,7 @@ import { type CustodyProvider, type CustodyWalletPurpose, type CustodyWalletStatus, + isByokCustodyProvider, type ProviderCredentialStatus, } from "@sdp/types/custody"; import type { MovementId } from "@sdp/types/movements"; @@ -29,10 +30,20 @@ import { createCredentialSecretStore, type StoredCredentialSecret, } from "@/services/credential-secret-store"; -import { provisionPrivyWallet } from "@/services/custody/provisioning"; +import { + type ByokCredentialAuthentication, + parseByokCredential, +} from "@/services/custody/byok-credential"; +import { + provisionFireblocksVaultAccount, + provisionPrivyWallet, +} from "@/services/custody/provisioning"; import { assertCustodyProviderCanCreateWallet } from "@/services/custody-provider-lifecycle.service"; import { admittedSigner } from "@/services/domain/signing/admitted-signer"; -import { createPrivyAdapterFromCredential } from "@/services/domain/signing/provider-adapter-factory"; +import { + createFireblocksAdapterFromCredential, + createPrivyAdapterFromCredential, +} from "@/services/domain/signing/provider-adapter-factory"; import { assertCustodyProviderEntitled, assertManagedCustodyUseAllowed, @@ -545,11 +556,11 @@ export class CustodyRuntimeTargets { if (!credential || !isUsableCredentialOwner(credential)) { throw conflict("Custody Connection is unavailable"); } - if (target.provider !== "privy") { + if (!isByokCustodyProvider(target.provider)) { throw internalError("Custody Connection provider is unsupported"); } - const authentication = await this.readPrivyCredential(target, credential); + const authentication = await this.readByokCredential(target, credential); const custodyWalletId = `cwlt_${crypto.randomUUID()}`; const audit = new AuditService(this.db); const intent = await audit.beginCritical(params.auditContext, { @@ -567,18 +578,32 @@ export class CustodyRuntimeTargets { }); let provisioned: { walletId: string; address: string }; try { - const requestId = params.idempotency?.requestId; - provisioned = await provisionPrivyWallet( - this.env, - requestId - ? { - credentialRequest: true, - externalId: privyWalletExternalId(requestId), - idempotencyKey: requestId, - } - : { credentialRequest: true }, - authentication - ); + if (authentication.provider === "fireblocks") { + provisioned = await provisionFireblocksVaultAccount(this.env, { + orgId: target.organizationId, + orgSlug: target.projectId, + idempotencyKey: params.idempotency?.requestId, + apiKey: authentication.apiKey, + apiSecretPem: authentication.apiSecretPem, + assetId: authentication.assetId, + }).then((wallet) => ({ + walletId: `fb_${wallet.vaultAccountId}`, + address: wallet.publicKey, + })); + } else { + const requestId = params.idempotency?.requestId; + provisioned = await provisionPrivyWallet( + this.env, + requestId + ? { + credentialRequest: true, + externalId: privyWalletExternalId(requestId), + idempotencyKey: requestId, + } + : { credentialRequest: true }, + { appId: authentication.appId, appSecret: authentication.appSecret } + ); + } } catch (error) { if (!(error instanceof SigningError) || error.code === "NETWORK_ERROR") { // Keep the intent unresolved: the Provider may have created the wallet. @@ -592,7 +617,10 @@ export class CustodyRuntimeTargets { throw providerUnavailable("Custody provider is temporarily unavailable"); } - const providerWalletId = normalizePrivyWalletId(provisioned.walletId); + const providerWalletId = + target.provider === "privy" + ? normalizePrivyWalletId(provisioned.walletId) + : provisioned.walletId; let persisted: CreatedCustodyConnectionWallet; try { persisted = await this.persistConnectionWallet(target, credential, { @@ -705,6 +733,75 @@ export class CustodyRuntimeTargets { ); } + /** Exact BYOK owner resolution for native Fireblocks payments; never deployment credentials. */ + async getFireblocksTransferAccess( + params: { + organizationId: string; + projectId: string; + custodyWalletId: string; + } & ( + | { forSubmission: true } + | { + forSubmission: false; + intent: { + connectionId: string; + accountFingerprint: string; + vaultAccountId: string; + sourceAddress: string | null; + }; + } + ) + ) { + const target = await this.resolveRetainedWalletRecord( + params.organizationId, + params.projectId, + params.custodyWalletId + ); + if (!target) throw notFound("Custody wallet"); + // Managed Fireblocks wallets keep the existing sponsored-signing path. + if (target.provider !== "fireblocks" || target.kind !== "connection") return null; + if (!target.wallet) throw conflict("Fireblocks wallet is unavailable"); + if (params.forSubmission) { + this.assertRuntimeExecutionAllowed(target, params.custodyWalletId); + await assertCustodyProviderEntitled( + this.env, + this.db, + params.organizationId, + target.provider + ); + } + const row = await this.loadConnectionCredential(target); + if (row?.credential_status !== "active" || row.provider_account_fingerprint === null) + throw conflict("Custody Connection is unavailable"); + if (params.forSubmission) { + if (!isUsableCredentialOwner(row)) throw conflict("Custody Connection is unavailable"); + } else { + // Disabling a connection or failing a health check cannot cancel an accepted + // Fireblocks transfer. Recovery may read its retained credential, but only + // for the same account and wallet recorded in the pending payment intent. + // Check this binding before decrypting; credential revocation still applies. + const { intent } = params; + if ( + target.connectionId !== intent.connectionId || + row.provider_account_fingerprint !== intent.accountFingerprint || + target.wallet.walletId !== `fb_${intent.vaultAccountId}` || + target.wallet.publicKey !== intent.sourceAddress + ) + throw conflict("Fireblocks connection does not match the payment intent"); + } + const secret = await this.readByokCredential(target, row); + if (secret.provider !== "fireblocks" || !/^fb_\d+$/.test(target.wallet.walletId)) + throw conflict("Invalid Fireblocks wallet"); + return { + ...secret, + vaultAccountId: target.wallet.walletId.slice(3), + publicKey: target.wallet.publicKey, + connectionId: target.connectionId, + accountFingerprint: row.provider_account_fingerprint, + apiBaseUrl: this.env.FIREBLOCKS_API_BASE_URL, + }; + } + private async resolveConnection( organizationId: string, projectId: string, @@ -841,7 +938,7 @@ export class CustodyRuntimeTargets { }); } - if (row.provider !== "privy") { + if (!isByokCustodyProvider(row.provider)) { getLogger().error( { organizationId: target.organizationId, @@ -869,12 +966,19 @@ export class CustodyRuntimeTargets { return cached; } - const secret = await this.readPrivyCredential(target, row); - const adapter = createPrivyAdapterFromCredential(this.env, { - ...secret, - walletId: wallet.walletId, - requestDelayMs: row.request_delay_ms ?? undefined, - }); + const secret = await this.readByokCredential(target, row); + const adapter = + secret.provider === "fireblocks" + ? createFireblocksAdapterFromCredential(this.env, { + ...secret, + walletId: wallet.walletId, + requestDelayMs: row.request_delay_ms ?? undefined, + }) + : createPrivyAdapterFromCredential(this.env, { + ...secret, + walletId: wallet.walletId, + requestDelayMs: row.request_delay_ms ?? undefined, + }); this.adapterCache.set(cacheKey, adapter); return adapter; } @@ -938,7 +1042,7 @@ export class CustodyRuntimeTargets { throw conflict("Custody Connection changed during wallet creation"); } - // A Privy wallet belongs to the Provider account, not one Credential + // A custody wallet belongs to the Provider account, not one Credential // version. Rotation and rollback may change the pointer while the // Provider request is in flight, so re-lock the current scoped // Credential and rely on the unchanged account fingerprint above. @@ -1024,10 +1128,10 @@ export class CustodyRuntimeTargets { ); } - private async readPrivyCredential( + private async readByokCredential( target: ConnectionRuntimeTarget, row: ConnectionCredentialRow - ): Promise<{ appId: string; appSecret: string }> { + ): Promise { const stored: StoredCredentialSecret = { storageBackend: row.storage_backend, secretRef: row.secret_ref ?? undefined, @@ -1040,12 +1144,7 @@ export class CustodyRuntimeTargets { orgId: target.organizationId, stored, }); - const appId = typeof payload.appId === "string" ? payload.appId.trim() : ""; - const appSecret = typeof payload.appSecret === "string" ? payload.appSecret : ""; - if (!appId || !appSecret) { - throw new Error("incomplete credential payload"); - } - return { appId, appSecret }; + return parseByokCredential(row.provider, payload); } catch { this.logUnavailable(target, "credential_secret_unavailable"); throw providerUnavailable("Custody credential is temporarily unavailable"); diff --git a/apps/sdp-api/src/services/domain/signing/fireblocks-byok-signing.test.ts b/apps/sdp-api/src/services/domain/signing/fireblocks-byok-signing.test.ts new file mode 100644 index 0000000000..b86142a363 --- /dev/null +++ b/apps/sdp-api/src/services/domain/signing/fireblocks-byok-signing.test.ts @@ -0,0 +1,97 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { isFullSigningPort } from "@sdp/custody/signing"; +import { FireblocksSigner } from "@solana/keychain-fireblocks"; +import { + address, + blockhash, + compileTransaction, + createTransactionMessage, + getBase58Decoder, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, +} from "@solana/kit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { env as testEnv } from "@/test/helpers/env"; +import { createFireblocksAdapterFromCredential } from "./provider-adapter-factory"; + +const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const ed25519 = generateKeyPairSync("ed25519"); +const publicKey = address( + getBase58Decoder().decode( + new Uint8Array(ed25519.publicKey.export({ format: "der", type: "spki" }).subarray(-32)) + ) +); +const apiSecretPem = rsa.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); +const env = { + ...testEnv, + SDP_RELEASE_CHANNEL: "beta", + FIREBLOCKS_API_BASE_URL: "https://fireblocks.example.test", + FIREBLOCKS_API_KEY: "deployment-key", + FIREBLOCKS_API_SECRET: "deployment-secret", +}; + +afterEach(() => vi.restoreAllMocks()); + +describe("Fireblocks BYOK transaction proposals", () => { + it("uses solana-keychain RAW signing, waits for approval, verifies the signature and never broadcasts", async () => { + const transaction = compileTransaction( + pipe( + createTransactionMessage({ version: 0 }), + (message) => setTransactionMessageFeePayer(publicKey, message), + (message) => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: blockhash("11111111111111111111111111111111"), + lastValidBlockHeight: 100n, + }, + message + ) + ) + ); + const signature = sign(null, Buffer.from(transaction.messageBytes), ed25519.privateKey); + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ addresses: [{ address: publicKey }] })) + .mockResolvedValueOnce(Response.json({ id: "proposal-1", status: "PENDING_AUTHORIZATION" })) + .mockResolvedValueOnce(Response.json({ id: "proposal-1", status: "PENDING_AUTHORIZATION" })) + .mockResolvedValueOnce( + Response.json({ + id: "proposal-1", + status: "COMPLETED", + signedMessages: [{ signature: { fullSig: signature.toString("hex") } }], + }) + ); + const adapter = createFireblocksAdapterFromCredential(env, { + apiKey: "tenant-key", + apiSecretPem, + assetId: "SOL_TEST", + walletId: "fb_42", + }); + if (!isFullSigningPort(adapter)) throw new Error("Expected signing adapter"); + const signer = await adapter.getTransactionSigner("fb_42", publicKey); + expect(signer).toBeInstanceOf(FireblocksSigner); + if (!("signTransactions" in signer)) throw new Error("Expected partial signer"); + const signatures = await signer.signTransactions([transaction]); + expect(Buffer.from(signatures[0]?.[publicKey] ?? [])).toEqual(signature); + expect(fetch).toHaveBeenCalledTimes(4); + const proposal = fetch.mock.calls[1]; + expect(proposal?.[0]).toBe(`${env.FIREBLOCKS_API_BASE_URL}/v1/transactions`); + expect(JSON.parse(String(proposal?.[1]?.body))).toEqual({ + operation: "RAW", + assetId: "SOL_TEST", + source: { type: "VAULT_ACCOUNT", id: "42" }, + extraParameters: { + rawMessageData: { + messages: [{ content: Buffer.from(transaction.messageBytes).toString("hex") }], + }, + }, + }); + for (const [url, init] of fetch.mock.calls) { + expect(String(url).startsWith(env.FIREBLOCKS_API_BASE_URL)).toBe(true); + expect(new Headers(init?.headers).get("X-API-Key")).toBe("tenant-key"); + } + expect(await adapter.getTransactionSigner("fb_42", publicKey)).toBe(signer); + expect(fetch).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/sdp-api/src/services/domain/signing/provider-adapter-factory.release-channel.node.test.ts b/apps/sdp-api/src/services/domain/signing/provider-adapter-factory.release-channel.node.test.ts index 558a623992..cd9876dd3b 100644 --- a/apps/sdp-api/src/services/domain/signing/provider-adapter-factory.release-channel.node.test.ts +++ b/apps/sdp-api/src/services/domain/signing/provider-adapter-factory.release-channel.node.test.ts @@ -4,6 +4,7 @@ import type { SigningConfigRecord } from "@/services/adapters/signing"; import type { CustodyCipher } from "@/services/custody-cipher/cipher-router"; import { createAdapterFromEncryptedConfig, + createFireblocksAdapterFromCredential, createPrivyAdapterFromCredential, type PrivyCredentialAdapterInput, } from "@/services/domain/signing/provider-adapter-factory"; @@ -109,6 +110,28 @@ describe("custody adapter factories and the release channel", () => { expect(read).not.toHaveBeenCalled(); }); + it("refuses a BYOK Fireblocks credential out of channel before reading it", () => { + custodyReleaseChannel.outOfChannelMode = "byok"; + const read = vi.fn(() => "must not be read"); + const input = { + get apiKey() { + return read(); + }, + get apiSecretPem() { + return read(); + }, + get walletId() { + return read(); + }, + assetId: "SOL_TEST" as const, + }; + + expect(() => createFireblocksAdapterFromCredential(createEnv(), input)).toThrow( + custodyProviderNotInReleaseChannel("fireblocks", "byok") + ); + expect(read).not.toHaveBeenCalled(); + }); + it("builds a BYOK Privy adapter while only Managed is out of channel", () => { custodyReleaseChannel.outOfChannelMode = "managed"; const read = vi.fn(); diff --git a/apps/sdp-api/src/services/domain/signing/provider-adapter-factory.ts b/apps/sdp-api/src/services/domain/signing/provider-adapter-factory.ts index 806b9b1667..a6e4e39b0f 100644 --- a/apps/sdp-api/src/services/domain/signing/provider-adapter-factory.ts +++ b/apps/sdp-api/src/services/domain/signing/provider-adapter-factory.ts @@ -262,3 +262,36 @@ export function createPrivyAdapterFromCredential( }), }); } + +/** Resolve only this connection's credentials and its exact stored vault wallet. */ +export function createFireblocksAdapterFromCredential( + env: Env, + input: { + apiKey: string; + apiSecretPem: string; + assetId: "SOL" | "SOL_TEST"; + walletId: string; + requestDelayMs?: number; + } +): SigningPort { + assertCustodyProviderAvailable(env, "fireblocks", "byok"); + if ( + !input.apiKey || + !input.apiSecretPem || + !input.walletId || + !["SOL", "SOL_TEST"].includes(input.assetId) + ) { + throw new SigningError( + "Fireblocks credential or wallet is unavailable", + "PROVIDER_NOT_CONFIGURED" + ); + } + return new KeychainFireblocksAdapter({ + apiKey: input.apiKey, + apiSecretPem: input.apiSecretPem, + vaultAccountId: input.walletId.startsWith("fb_") ? input.walletId.slice(3) : input.walletId, + assetId: input.assetId, + apiBaseUrl: env.FIREBLOCKS_API_BASE_URL, + requestDelayMs: input.requestDelayMs, + }); +} diff --git a/apps/sdp-api/src/services/jobs/cleanup-provider-credential-secrets.test.ts b/apps/sdp-api/src/services/jobs/cleanup-provider-credential-secrets.test.ts index 81f91fae9e..79d5cf2a06 100644 --- a/apps/sdp-api/src/services/jobs/cleanup-provider-credential-secrets.test.ts +++ b/apps/sdp-api/src/services/jobs/cleanup-provider-credential-secrets.test.ts @@ -388,6 +388,7 @@ describe("cleanupRetiredProviderCredentialSecrets", () => { cleanupStore.listPendingDestructions( { id: predecessorId, + provider: "privy", organization_id: ORGANIZATION_ID, secret_ref: `projects/p/secrets/sdp-provider-credentials-${predecessorId}`, }, diff --git a/apps/sdp-api/src/services/jobs/provider-credential-container-cleanup.test.ts b/apps/sdp-api/src/services/jobs/provider-credential-container-cleanup.test.ts index c4484b1279..5077eda48a 100644 --- a/apps/sdp-api/src/services/jobs/provider-credential-container-cleanup.test.ts +++ b/apps/sdp-api/src/services/jobs/provider-credential-container-cleanup.test.ts @@ -46,12 +46,17 @@ function fakeGcp() { return { versions, listVersions, destroyVersion, secrets }; } -async function reserve(id: string, version: number, predecessor: string | null = null) { +async function reserve( + id: string, + version: number, + predecessor: string | null = null, + provider: "privy" | "fireblocks" = "privy" +) { const row = await new ProviderCredentialStore(getDb(env)).insertCredential({ id, organizationId: ORG, projectId: PROJECT, - provider: "privy", + provider, label: "Prototype", scope: "project", source: "stored", @@ -103,38 +108,41 @@ describe("shared-container orphan cleanup", () => { }); }); - it("destroys an unreferenced version while preserving active and retained rollback versions", async () => { - const log = vi.spyOn(rootLogger, "info").mockImplementation(() => rootLogger); - const gcp = fakeGcp(); - await reserve("pcred_proto_previous", 1); - await finalize("pcred_proto_previous", 1); - await getDb(env).execute( - "UPDATE provider_credentials SET status = 'retired', secret_retention_expires_at = '2099-01-01T00:00:00.000Z' WHERE id = ?", - ["pcred_proto_previous"] - ); - await reserve("pcred_proto_current", 2, "pcred_proto_previous"); - await finalize("pcred_proto_current", 2); - await getDb(env).execute("UPDATE provider_credentials SET status = 'active' WHERE id = ?", [ - "pcred_proto_current", - ]); - for (const version of [1, 2, 3]) gcp.versions.set(ref(version), "ENABLED"); - - await scanGcpCredentialContainers(env, () => gcp.secrets); - - expect([...gcp.versions.values()]).toEqual(["ENABLED", "ENABLED", "DESTROYED"]); - expect(log).toHaveBeenCalledWith( - { - organizationId: ORG, - containerOwnerCredentialId: "pcred_proto_previous", - providerCredentialId: undefined, - secretRef: PARENT, - secretVersionRef: ref(3), - outcome: "confirmed_destroyed", - kind: "unreferenced_version", - }, - "provider_credential_secret_destroyed" - ); - }); + it.each(["privy", "fireblocks"] as const)( + "cleans %s orphans while preserving active and retained rollback versions", + async (provider) => { + const log = vi.spyOn(rootLogger, "info").mockImplementation(() => rootLogger); + const gcp = fakeGcp(); + await reserve("pcred_proto_previous", 1, null, provider); + await finalize("pcred_proto_previous", 1); + await getDb(env).execute( + "UPDATE provider_credentials SET status = 'retired', secret_retention_expires_at = '2099-01-01T00:00:00.000Z' WHERE id = ?", + ["pcred_proto_previous"] + ); + await reserve("pcred_proto_current", 2, "pcred_proto_previous", provider); + await finalize("pcred_proto_current", 2); + await getDb(env).execute("UPDATE provider_credentials SET status = 'active' WHERE id = ?", [ + "pcred_proto_current", + ]); + for (const version of [1, 2, 3]) gcp.versions.set(ref(version), "ENABLED"); + + await scanGcpCredentialContainers(env, () => gcp.secrets); + + expect([...gcp.versions.values()]).toEqual(["ENABLED", "ENABLED", "DESTROYED"]); + expect(log).toHaveBeenCalledWith( + { + organizationId: ORG, + containerOwnerCredentialId: "pcred_proto_previous", + providerCredentialId: undefined, + secretRef: PARENT, + secretVersionRef: ref(3), + outcome: "confirmed_destroyed", + kind: "unreferenced_version", + }, + "provider_credential_secret_destroyed" + ); + } + ); it("preserves an active project-ID version when GCP inventory uses the project number", async () => { const gcp = fakeGcp(); diff --git a/apps/sdp-api/src/services/jobs/provider-credential-container-cleanup.ts b/apps/sdp-api/src/services/jobs/provider-credential-container-cleanup.ts index da2d4ef595..1d63ec40be 100644 --- a/apps/sdp-api/src/services/jobs/provider-credential-container-cleanup.ts +++ b/apps/sdp-api/src/services/jobs/provider-credential-container-cleanup.ts @@ -48,7 +48,7 @@ export async function scanGcpCredentialContainers( rows.some( (row) => row.organization_id !== owner.organization_id || - row.provider !== "privy" || + row.provider !== owner.provider || row.storage_backend !== "gcp_secret_manager" ) ) { @@ -67,7 +67,7 @@ export async function scanGcpCredentialContainers( containerOwnerCredentialId: owner.id, secretRef: owner.secret_ref, secretVersionRef: versionRef, - provider: "privy", + provider: owner.provider, storageBackend: "gcp_secret_manager", reason: "secret_cleanup_failed", errorName: error instanceof Error ? error.name : "UnknownError", @@ -255,7 +255,7 @@ export async function scanGcpCredentialContainers( { organizationId: owner.organization_id, containerOwnerCredentialId: owner.id, - provider: "privy", + provider: owner.provider, storageBackend: "gcp_secret_manager", reason: "cleanup_deadline_reached", outcome: "scan_incomplete", diff --git a/apps/sdp-api/src/services/jobs/track-pending-transfers.ts b/apps/sdp-api/src/services/jobs/track-pending-transfers.ts index 5716da009f..c04405aff7 100644 --- a/apps/sdp-api/src/services/jobs/track-pending-transfers.ts +++ b/apps/sdp-api/src/services/jobs/track-pending-transfers.ts @@ -37,9 +37,11 @@ import { createSystemPaymentsRepository, createSystemPaymentTransferBatchesRepository, } from "@/db/repositories/repository-factory"; +import { mapSettledWithConcurrency } from "@/lib/concurrency"; import { internalError } from "@/lib/errors"; import { getLogger } from "@/runtime/logger"; import { logEvent } from "@/runtime/money-path-events"; +import { refreshFireblocksTransfer } from "@/services/payments/fireblocks-transfer"; import type { Env } from "@/types/env"; // Allow 5 minutes before treating a signature-less "processing" transfer as stuck. @@ -215,6 +217,18 @@ export async function trackPendingTransfers(env: Env): Promise { await syncProcessingTransfersOnChain(env, repo, nowIso); await finalizeConfirmedTransfers(repo, now, nowIso); await failStrandedTransferBatches(env, now); + + // Human approvals have no five-minute signing timeout. This path only reads + // the provider; a missing/ambiguous create response can never trigger a POST. + const fireblocks = await repo.listTransfersByStatus({ + environment: currentClusterScope().environment, + statuses: ["processing"], + fireblocks: true, + limit: 50, + }); + await mapSettledWithConcurrency(fireblocks, 5, (transfer) => + refreshFireblocksTransfer(env, repo, transfer) + ); } /** Past any request's lifetime (renewal cap plus lease), with room to spare. */ @@ -404,6 +418,7 @@ async function recoverStuckProcessingTransfers( statuses: ["processing"], types: WALLET_TRANSFER_TYPES, hasSignature: false, + fireblocks: false, updatedBefore: cutoff, limit: MAX_SIGNATURES_PER_BATCH, }); @@ -444,6 +459,7 @@ async function syncProcessingTransfersOnChain( statuses: ["processing"], types: WALLET_TRANSFER_TYPES, hasSignature: true, + fireblocks: false, limit: MAX_SIGNATURES_PER_BATCH, }) ); diff --git a/apps/sdp-api/src/services/payments/fireblocks-transfer-client.test.ts b/apps/sdp-api/src/services/payments/fireblocks-transfer-client.test.ts new file mode 100644 index 0000000000..c4069fa9cc --- /dev/null +++ b/apps/sdp-api/src/services/payments/fireblocks-transfer-client.test.ts @@ -0,0 +1,167 @@ +import { createHash, generateKeyPairSync } from "node:crypto"; +import { + createFireblocksTransfer, + type FireblocksTransferRequest, + readFireblocksTransfer, + resolveFireblocksTransferAsset, +} from "@sdp/custody/provisioning/fireblocks-transfers"; +import type { CustodyProvisioningRuntime } from "@sdp/custody/provisioning/runtime"; +import { jwtVerify } from "jose"; +import { describe, expect, it, vi } from "vitest"; + +const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const access = () => ({ + apiKey: crypto.randomUUID(), + apiSecretPem: rsa.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + apiBaseUrl: "https://fireblocks.example.test", +}); +function runtime(fetch: CustodyProvisioningRuntime["fetch"]): CustodyProvisioningRuntime { + return { + fetch, + now: Date.now, + randomUUID: () => crypto.randomUUID(), + sleep: async () => {}, + getRandomValues: (data) => crypto.getRandomValues(new Uint8Array(data)), + sha256: (data) => crypto.subtle.digest("SHA-256", new Uint8Array(data)), + }; +} + +describe("native Fireblocks HTTP protocol", () => { + it.each([ + { source: { type: "VAULT_ACCOUNT", id: 0 } }, + { destinationAddress: {} }, + { amountInfo: { amount: 0.01 } }, + { amountInfo: { netAmount: "-0.01" } }, + { amountInfo: { requestedAmount: "1e-2" } }, + ])("rejects malformed provider settlement fields: %j", async (patch) => { + const fetch = vi.fn(async () => + Response.json({ id: "proposal", status: "COMPLETED", ...patch }) + ); + await expect(readFireblocksTransfer(runtime(fetch), access(), "xfr_1")).rejects.toThrow( + "Invalid Fireblocks transfer response" + ); + }); + it("signs the exact TRANSFER body and URI with the customer's RSA key and permanent idempotency ID", async () => { + const credentials = access(); + const body: FireblocksTransferRequest = { + operation: "TRANSFER", + externalTxId: "xfr_intent", + assetId: "SOL", + amount: "0.01", + treatAsGrossAmount: false, + source: { type: "VAULT_ACCOUNT", id: "42" }, + destination: { + type: "ONE_TIME_ADDRESS", + oneTimeAddress: { address: "11111111111111111111111111111111" }, + }, + note: "SDP payment", + }; + const fetch = vi.fn(async (url, init) => { + expect(url).toBe("https://fireblocks.example.test/v1/transactions"); + expect(init.method).toBe("POST"); + expect(init.redirect).toBe("error"); + expect(init.body).toBe(JSON.stringify(body)); + const headers = new Headers(init.headers); + expect(headers.get("X-API-Key")).toBe(credentials.apiKey); + expect(headers.has("Idempotency-Key")).toBe(false); + const authorization = headers.get("Authorization"); + if (!authorization) throw new Error("Missing authorization header"); + const { payload } = await jwtVerify(authorization.slice(7), rsa.publicKey); + expect(payload).toMatchObject({ + sub: credentials.apiKey, + uri: "/v1/transactions", + bodyHash: createHash("sha256").update(JSON.stringify(body)).digest("hex"), + }); + return Response.json({ id: "proposal", status: "SUBMITTED" }); + }); + expect(await createFireblocksTransfer(runtime(fetch), credentials, body)).toMatchObject({ + id: "proposal", + }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("shares catalogue reads and resolves the exact Solana issuer mint, never a similarly named asset", async () => { + const fetch = vi.fn(async () => + Response.json([ + { + id: "wrong-chain", + type: "SOL_ASSET", + nativeAsset: "SOL_TEST", + issuerAddress: "mint", + decimals: 6, + }, + { + id: "wrong-mint", + type: "SOL_ASSET", + nativeAsset: "SOL", + issuerAddress: "different", + decimals: 6, + }, + { + id: "SOL_USDC_PTHX", + type: "SOL_ASSET", + nativeAsset: "SOL", + issuerAddress: "mint", + decimals: 6, + }, + ]) + ); + const credentials = access(); + const rt = runtime(fetch); + const results = await Promise.all([ + resolveFireblocksTransferAsset(rt, credentials, "SOL", "mint"), + resolveFireblocksTransferAsset(rt, credentials, "SOL", "mint"), + ]); + expect(results).toEqual([ + { id: "SOL_USDC_PTHX", decimals: 6 }, + { id: "SOL_USDC_PTHX", decimals: 6 }, + ]); + await expect(resolveFireblocksTransferAsset(rt, credentials, "SOL", "unknown")).rejects.toThrow( + "not uniquely supported" + ); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("does not cache catalogue failures", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({}, { status: 503 })) + .mockResolvedValueOnce( + Response.json([ + { id: "usdc", type: "SOL_ASSET", nativeAsset: "SOL", issuerAddress: "mint", decimals: 6 }, + ]) + ); + const credentials = access(); + const rt = runtime(fetch); + await expect(resolveFireblocksTransferAsset(rt, credentials, "SOL", "mint")).rejects.toThrow(); + expect(await resolveFireblocksTransferAsset(rt, credentials, "SOL", "mint")).toEqual({ + id: "usdc", + decimals: 6, + }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("recovers by GET only, tolerates null optional provider fields, and treats 404 as unresolved", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + id: "proposal", + status: "PENDING_SIGNATURE", + subStatus: null, + txHash: null, + }) + ) + .mockResolvedValueOnce(Response.json({}, { status: 404 })); + const credentials = access(); + const rt = runtime(fetch); + expect(await readFireblocksTransfer(rt, credentials, "xfr_1")).toMatchObject({ + status: "PENDING_SIGNATURE", + }); + expect(await readFireblocksTransfer(rt, credentials, "xfr_1")).toBeNull(); + for (const [url, init] of fetch.mock.calls) { + expect(url).toBe("https://fireblocks.example.test/v1/transactions/external_tx_id/xfr_1"); + expect(init.method).toBe("GET"); + } + }); +}); diff --git a/apps/sdp-api/src/services/payments/fireblocks-transfer.test.ts b/apps/sdp-api/src/services/payments/fireblocks-transfer.test.ts new file mode 100644 index 0000000000..6f09c8785d --- /dev/null +++ b/apps/sdp-api/src/services/payments/fireblocks-transfer.test.ts @@ -0,0 +1,880 @@ +import { runInClusterScope } from "@sdp/rpc/scope"; +import { SDP_RAMP_PROVIDER_STAGES } from "@sdp/types/release-channels"; +import { Hono } from "hono"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { createApp } from "@/app"; +import { getDb } from "@/db/client"; +import { createSystemPaymentsRepository } from "@/db/repositories/repository-factory"; +import { verifyClerkJwt } from "@/lib/clerk-token"; +import { AppError } from "@/lib/errors"; +import { kvStoreMiddleware } from "@/middleware/kv-store"; +import { internalCustody } from "@/routes/internal-custody"; +import { AuditService } from "@/services/audit.service"; +import * as credentialSecretStore from "@/services/credential-secret-store"; +import { trackPendingTransfers } from "@/services/jobs/track-pending-transfers"; +import { seedProjectApiKey } from "@/test/helpers/api-keys"; +import { signSeededClerkMember } from "@/test/helpers/clerk-member"; +import { env } from "@/test/helpers/env"; +import { seedDefaultProjects } from "@/test/helpers/projects"; +import { seedTestDatabase } from "@/test/mocks/db"; +import { clearKVStores } from "@/test/mocks/kv"; +import type { Env } from "@/types/env"; + +const ORGANIZATION_ID = "org_fireblocks_byok"; +const USER_ID = "usr_fireblocks_byok"; +const PROJECT_ID = "prj_fireblocks_byok"; +const ADDRESS = "11111111111111111111111111111111"; +const OTHER_ADDRESS = "So11111111111111111111111111111111111111112"; +const fields = { + apiKey: "tenant-fireblocks-key", + signingKeyId: "", + vaultAccountId: "0", +}; +const body = { + provider: "fireblocks", + fields: { credentialLabel: "Fireblocks treasury", scope: "project", ...fields }, + walletLabel: "Treasury", +}; +let token: string; +let app: Hono<{ Bindings: Env }>; +const testEnv: Env = { + ...env, + SDP_DEPLOYMENT_MODE: "self_hosted", + CREDENTIAL_SECRET_STORE_BACKEND: "encrypted_db", + CUSTODY_ENCRYPTION_KEY: Buffer.alloc(32, 11).toString("base64"), + FIREBLOCKS_API_BASE_URL: "https://fireblocks.example.test", + FIREBLOCKS_API_KEY: "deployment-key-must-not-be-used", + FIREBLOCKS_API_SECRET: "deployment-secret-must-not-be-used", +}; + +function request( + path: string, + payload?: unknown, + projectId = PROJECT_ID, + key = crypto.randomUUID() +) { + return app.request( + `/internal/dashboard/custody${path}`, + { + method: payload === undefined ? "GET" : "POST", + headers: { + Authorization: `Bearer ${token}`, + "X-Project-ID": projectId, + "Content-Type": "application/json", + "Idempotency-Key": key, + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }, + testEnv + ); +} + +function providerResponse(address = ADDRESS) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => Response.json({ addresses: [{ address }] })); +} + +async function connect(projectId = PROJECT_ID) { + if (projectId !== PROJECT_ID) + body.fields.signingKeyId = (await newRegistration(projectId)).signingKeyId; + const submitted = await request("/provider-credentials", body, projectId); + expect(submitted.status, await submitted.clone().text()).toBe(201); + const { data } = await submitted.json(); + const completed = await request(`/connections/${data.connectionId}/complete`, {}, projectId); + expect(completed.status).toBe(200); + expect((await completed.json()).data.completion.status).toBe("success"); + return z + .object({ connectionId: z.string(), providerCredential: z.object({ id: z.string() }) }) + .parse(data); +} + +async function newRegistration(projectId = PROJECT_ID) { + const response = await request("/fireblocks/csr", {}, projectId); + expect(response.status, await response.clone().text()).toBe(201); + const { data } = await response.json(); + return { ...fields, signingKeyId: data.csr.id }; +} + +beforeEach(async () => { + testEnv.SDP_RELEASE_CHANNEL = "beta"; + testEnv.SOLANA_NETWORK = "devnet"; + testEnv.CREDENTIAL_SECRET_STORE_BACKEND = "encrypted_db"; + await seedTestDatabase(env); + await clearKVStores(env); + const db = getDb(env); + await db.execute( + "INSERT INTO organizations (id, name, slug, tier, status) VALUES (?, 'Fireblocks BYOK', 'fireblocks-byok', 'enterprise', 'active')", + [ORGANIZATION_ID] + ); + await db.execute( + "INSERT INTO users (id, email, email_verified, status) VALUES (?, 'fireblocks-byok@example.com', 1, 'active')", + [USER_ID] + ); + await db.execute( + "INSERT INTO organization_members (id, organization_id, user_id, role, status) VALUES ('mem_fireblocks_byok', ?, ?, 'admin', 'active')", + [ORGANIZATION_ID, USER_ID] + ); + await seedDefaultProjects(db, { + organizationId: ORGANIZATION_ID, + createdBy: USER_ID, + members: [USER_ID], + ids: { sandbox: PROJECT_ID, production: `${PROJECT_ID}_production` }, + }); + await db.execute( + "UPDATE organizations SET settings = (settings::jsonb || ?::jsonb)::text WHERE id = ?", + [JSON.stringify({ providerOverrides: { custody: { fireblocks: true } } }), ORGANIZATION_ID] + ); + token = await signSeededClerkMember(testEnv, db, USER_ID, ORGANIZATION_ID); + await verifyClerkJwt(token, testEnv); + app = new Hono<{ Bindings: Env }>(); + app.use("*", kvStoreMiddleware()); + app.use("*", async (c, next) => { + c.set("requestId", "req_fireblocks_byok"); + await next(); + }); + app.route("/internal/dashboard/custody", internalCustody); + app.onError((error) => { + if (error instanceof AppError) + return Response.json(error.toResponse(), { status: error.statusCode }); + throw error; + }); + fields.signingKeyId = (await newRegistration()).signingKeyId; + body.fields.signingKeyId = fields.signingKeyId; +}); +afterEach(() => vi.restoreAllMocks()); + +const SIG = + "4hXTCkRzt9WyecNzV1XPgCDfGAZzQKNxLXgynz5QDuWJ5NFkqjAvuA3P73N5MtZ7e8KQLD6tPBm53RsNkUqJZiy"; +const USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; +const paymentApp = createApp({ + rampProviderStages: SDP_RAMP_PROVIDER_STAGES, +}); +let walletId: string; +let createBody: import("@sdp/custody/provisioning/fireblocks-transfers").FireblocksTransferRequest; +let providerStatus: string; +let lostResponse = false; +let readMissing = false; +let createStatus = 200; +let createResultPatch: Record = {}; +let settlementPatch: Record = {}; +async function ready(projectId = PROJECT_ID) { + const mock = providerResponse(); + const connection = await connect(projectId); + const wallet = await getDb(env).queryOne<{ id: string }>( + "SELECT id FROM custody_wallets WHERE custody_connection_id = ?", + [connection.connectionId] + ); + if (!wallet) throw new Error("Expected imported wallet"); + walletId = wallet.id; + providerStatus = "PENDING_AUTHORIZATION"; + lostResponse = false; + readMissing = false; + createStatus = 200; + createResultPatch = {}; + settlementPatch = {}; + mock.mockImplementation(async (url, init) => { + const path = String(url).replace(testEnv.FIREBLOCKS_API_BASE_URL ?? "", ""); + expect(new Headers(init?.headers).get("X-API-Key")).toBe(fields.apiKey); + if (path === "/v1/supported_assets") + return Response.json([ + { + id: "USDC_WRONG_CHAIN", + type: "ERC20", + nativeAsset: "ETH", + issuerAddress: USDC, + decimals: 6, + }, + { + id: "SOL_USDC_JKVK", + type: "SOL_ASSET", + nativeAsset: "SOL_TEST", + issuerAddress: USDC, + decimals: 6, + }, + ]); + if (path === "/v1/transactions" && init?.method === "POST") { + expect(new Headers(init.headers).has("Idempotency-Key")).toBe(false); + createBody = JSON.parse(String(init.body)); + if (lostResponse) throw new Error("Timeout after provider accepted payment"); + if (createStatus !== 200) + return Response.json({ message: "secret must not be echoed" }, { status: createStatus }); + return Response.json({ id: "fb-proposal", status: providerStatus, ...createResultPatch }); + } + if (path.startsWith("/v1/transactions/external_tx_id/") && init?.method === "GET") { + if (readMissing) return Response.json({}, { status: 404 }); + return Response.json({ + id: "fb-proposal", + externalTxId: createBody.externalTxId, + operation: "TRANSFER", + assetId: createBody.assetId, + source: createBody.source, + destinationAddress: createBody.destination.oneTimeAddress.address, + amountInfo: { + amount: createBody.amount, + requestedAmount: createBody.amount, + netAmount: createBody.amount, + }, + status: providerStatus, + subStatus: providerStatus === "BLOCKED" ? "BLOCKED_BY_POLICY" : undefined, + txHash: providerStatus === "COMPLETED" ? SIG : undefined, + ...settlementPatch, + }); + } + throw new Error(`Unexpected provider request ${init?.method} ${path}`); + }); + mock.mockClear(); + return mock; +} +async function send( + key = "fireblocks-test", + patch: Record = {}, + projectId = PROJECT_ID +) { + return paymentApp.request( + "/v1/payments/transfers", + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "X-Project-ID": projectId, + "Content-Type": "application/json", + "Idempotency-Key": key, + }, + body: JSON.stringify({ + sourceCustodyWalletId: walletId, + destination: OTHER_ADDRESS, + token: "SOL", + amount: "0.01", + ...patch, + }), + }, + testEnv + ); +} +async function refresh(id: string) { + await getDb(env).execute( + "UPDATE payment_transfers SET provider_data = provider_data - 'fireblocksLastPolledAt' WHERE id = ?", + [id] + ); + const response = await paymentApp.request( + `/v1/payments/transfers/${id}`, + { headers: { Authorization: `Bearer ${token}`, "X-Project-ID": PROJECT_ID } }, + testEnv + ); + expect(response.status, await response.clone().text()).toBe(200); + return (await response.json()).data.transfer; +} + +describe("native Fireblocks BYOK payment routes", () => { + it.each([undefined, SIG])( + "keeps a COMPLETED create response pending until a verified detail read (hash: %s)", + async (txHash) => { + const mock = await ready(); + providerStatus = "COMPLETED"; + createResultPatch = { txHash }; + const response = await send(); + expect(response.status).toBe(200); + const transfer = (await response.json()).data.transfer; + expect(transfer).toMatchObject({ status: "processing", signature: null }); + expect(await refresh(transfer.id)).toMatchObject({ status: "confirmed", signature: SIG }); + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + } + ); + + it.each([undefined, null])( + "keeps COMPLETED details without a signature recoverable (hash: %s)", + async (txHash) => { + const mock = await ready(); + const response = await send(); + const transfer = (await response.json()).data.transfer; + providerStatus = "COMPLETED"; + settlementPatch = { txHash }; + expect(await refresh(transfer.id)).toMatchObject({ status: "processing", signature: null }); + // A later detailed response still has to pass settlement checks before confirmation. + settlementPatch = { destinationAddress: ADDRESS }; + expect(await refresh(transfer.id)).toMatchObject({ status: "processing", signature: null }); + settlementPatch = {}; + expect(await refresh(transfer.id)).toMatchObject({ status: "confirmed", signature: SIG }); + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + } + ); + + it("blocks new payments in stable but still reconciles a payment submitted in beta", async () => { + const mock = await ready(); + const response = await send(); + expect(response.status).toBe(200); + const transfer = (await response.json()).data.transfer; + testEnv.SDP_RELEASE_CHANNEL = "stable"; + mock.mockClear(); + const refused = await send("stable-new"); + expect(refused.status).toBe(403); + expect(await refused.text()).toContain("custody_provider_not_in_release_channel"); + expect(mock).not.toHaveBeenCalled(); + providerStatus = "COMPLETED"; + expect(await refresh(transfer.id)).toMatchObject({ status: "confirmed", signature: SIG }); + expect(mock.mock.calls.every(([, init]) => init?.method === "GET")).toBe(true); + }); + + it.each([ + { change: "deactivated", recovery: "dashboard" }, + { change: "deactivated", recovery: "cron" }, + { change: "failed check", recovery: "dashboard" }, + { change: "failed check", recovery: "cron" }, + ] as const)( + "reconciles an accepted transfer through $recovery after a $change connection, but refuses new sends", + async ({ change, recovery }) => { + const mock = await ready(); + const response = await send(); + expect(response.status).toBe(200); + const transfer = (await response.json()).data.transfer; + expect(transfer).toMatchObject({ status: "processing", signature: null }); + const db = getDb(env); + const connection = await db.queryOne<{ id: string }>( + "SELECT custody_connection_id AS id FROM custody_wallets WHERE id = ?", + [walletId] + ); + if (!connection) throw new Error("Expected Fireblocks connection"); + if (change === "deactivated") { + // The real teardown first requires the connection's wallets to be inactive. + await db.execute("UPDATE custody_wallets SET status = 'inactive' WHERE id = ?", [walletId]); + const deactivated = await request(`/connections/${connection.id}/deactivate`, {}); + expect(deactivated.status, await deactivated.clone().text()).toBe(200); + } else { + await db.execute( + "UPDATE custody_connections SET status = 'failed', activated_at = NULL, last_check_status = 'failed' WHERE id = ?", + [connection.id] + ); + } + mock.mockClear(); + expect((await send("after-connection-change")).status).toBe(409); + expect(mock).not.toHaveBeenCalled(); + + providerStatus = "COMPLETED"; + if (recovery === "cron") { + await runInClusterScope({ projectId: null, environment: "sandbox" }, () => + trackPendingTransfers(testEnv) + ); + expect( + await db.queryOne<{ status: string; signature: string }>( + "SELECT status, signature FROM payment_transfers WHERE id = ?", + [transfer.id] + ) + ).toMatchObject({ status: "confirmed", signature: SIG }); + } else { + expect(await refresh(transfer.id)).toMatchObject({ status: "confirmed", signature: SIG }); + } + expect(mock).toHaveBeenCalledTimes(1); + expect(mock.mock.calls[0]?.[1]?.method).toBe("GET"); + // Settled intents never acquire recovery access or poll again. + expect(await refresh(transfer.id)).toMatchObject({ status: "confirmed", signature: SIG }); + expect(mock).toHaveBeenCalledTimes(1); + } + ); + + it.each([ + { connectionId: "another-connection" }, + { accountFingerprint: "another-account" }, + { vaultAccountId: "99" }, + ])("refuses recovery with a mismatched intent %j after a failed check", async (patch) => { + const mock = await ready(); + const response = await send(); + expect(response.status).toBe(200); + const transfer = (await response.json()).data.transfer; + await getDb(env).execute( + "UPDATE custody_connections SET status = 'failed', activated_at = NULL, last_check_status = 'failed' WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + await getDb(env).execute( + `UPDATE payment_transfers SET provider_data = jsonb_set(provider_data, '{fireblocksTransfer}', + (provider_data->'fireblocksTransfer') || ?::jsonb) WHERE id = ?`, + [JSON.stringify(patch), transfer.id] + ); + mock.mockClear(); + const secrets = vi.spyOn(credentialSecretStore, "createCredentialSecretStore"); + providerStatus = "COMPLETED"; + expect(await refresh(transfer.id)).toMatchObject({ status: "processing", signature: null }); + expect(secrets).not.toHaveBeenCalled(); + expect(mock).not.toHaveBeenCalled(); + }); + + it.each([OTHER_ADDRESS, null])( + "refuses recovery with a changed source address (%s)", + async (source) => { + const mock = await ready(); + const response = await send(); + expect(response.status).toBe(200); + const transfer = (await response.json()).data.transfer; + await getDb(env).execute("UPDATE payment_transfers SET source_address = ? WHERE id = ?", [ + source, + transfer.id, + ]); + mock.mockClear(); + const secrets = vi.spyOn(credentialSecretStore, "createCredentialSecretStore"); + providerStatus = "COMPLETED"; + expect(await refresh(transfer.id)).toMatchObject({ status: "processing", signature: null }); + expect(secrets).not.toHaveBeenCalled(); + expect(mock).not.toHaveBeenCalled(); + } + ); + + it("does not read a revoked credential during recovery", async () => { + const mock = await ready(); + const response = await send(); + expect(response.status).toBe(200); + const transfer = (await response.json()).data.transfer; + await getDb(env).execute( + "UPDATE provider_credentials SET status = 'deactivated', deactivated_at = sdp_iso_now() WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + mock.mockClear(); + const secrets = vi.spyOn(credentialSecretStore, "createCredentialSecretStore"); + providerStatus = "COMPLETED"; + expect(await refresh(transfer.id)).toMatchObject({ status: "processing", signature: null }); + expect(secrets).not.toHaveBeenCalled(); + expect(mock).not.toHaveBeenCalled(); + }); + + it("does not confirm mismatched or incomplete settlement details or resubmit the payment", async () => { + const mock = await ready(); + const response = await send(); + const transfer = (await response.json()).data.transfer; + providerStatus = "COMPLETED"; + for (const patch of [ + { destinationAddress: ADDRESS }, + { destinationAddress: "" }, + { amountInfo: null }, + { amountInfo: { amount: "0.02", netAmount: "0.02" } }, + { amountInfo: { amount: "0.01", netAmount: "0.009" } }, + { amountInfo: { amount: "0.01", netAmount: "0.01", requestedAmount: "1" } }, + { source: { type: "VAULT_ACCOUNT", id: "99" } }, + { externalTxId: "another-payment" }, + { assetId: "SOL" }, + ]) { + settlementPatch = patch; + expect(await refresh(transfer.id)).toMatchObject({ status: "processing", signature: null }); + } + settlementPatch = { amountInfo: { amount: "0.010000000", netAmount: "0.010000000" } }; + expect(await refresh(transfer.id)).toMatchObject({ status: "confirmed", signature: SIG }); + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + }); + it("submits TRANSFER with tenant credentials, records pending approval and replays without another POST", async () => { + const mock = await ready(); + const response = await send(); + expect(response.status, await response.clone().text()).toBe(200); + const transfer = (await response.json()).data.transfer; + expect(transfer).toMatchObject({ + status: "processing", + signature: null, + fireblocks: { transactionId: "fb-proposal", status: "PENDING_AUTHORIZATION" }, + }); + expect(createBody).toMatchObject({ + operation: "TRANSFER", + assetId: "SOL_TEST", + externalTxId: transfer.id, + source: { type: "VAULT_ACCOUNT", id: "0" }, + destination: { type: "ONE_TIME_ADDRESS", oneTimeAddress: { address: OTHER_ADDRESS } }, + amount: "0.01", + treatAsGrossAmount: false, + }); + expect(createBody).not.toHaveProperty("extraParameters"); + expect(mock).toHaveBeenCalledTimes(1); + const replay = await send(); + expect(replay.status).toBe(200); + expect((await replay.json()).data.transfer.id).toBe(transfer.id); + expect(mock).toHaveBeenCalledTimes(1); + providerStatus = "COMPLETED"; + expect(await refresh(transfer.id)).toMatchObject({ status: "confirmed", signature: SIG }); + providerStatus = "PENDING_SIGNATURE"; + expect(await refresh(transfer.id)).toMatchObject({ status: "confirmed", signature: SIG }); + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + }); + it("maps USDC by exact mint and network and rejects unsupported assets and precision", async () => { + await ready(); + const response = await send("usdc", { token: USDC, amount: "0.123456" }); + expect(response.status, await response.clone().text()).toBe(200); + expect(createBody.assetId).toBe("SOL_USDC_JKVK"); + expect(createBody.amount).toBe("0.123456"); + expect((await send("unsupported", { token: ADDRESS })).status).toBe(400); + expect((await send("too-precise", { token: USDC, amount: "0.0000001" })).status).toBe(400); + }); + it("recovers an accepted payment after a lost response without resubmitting", async () => { + const mock = await ready(); + lostResponse = true; + const response = await send(); + expect(response.status, await response.clone().text()).toBe(200); + const transfer = (await response.json()).data.transfer; + expect(transfer).toMatchObject({ + status: "processing", + signature: null, + fireblocks: { transactionId: null }, + }); + expect((await send()).status).toBe(200); + readMissing = true; + expect(await refresh(transfer.id)).toMatchObject({ + status: "processing", + fireblocks: { transactionId: null }, + }); + readMissing = false; + expect(await refresh(transfer.id)).toMatchObject({ + status: "processing", + fireblocks: { transactionId: "fb-proposal" }, + }); + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + }); + it("persists policy substatus and does not treat human approval as a five-minute timeout", async () => { + await ready(); + const response = await send(); + expect(response.status, await response.clone().text()).toBe(200); + const transfer = (await response.json()).data.transfer; + await getDb(env).execute("UPDATE payment_transfers SET updated_at = ? WHERE id = ?", [ + new Date(Date.now() - 3600000).toISOString(), + transfer.id, + ]); + await runInClusterScope({ projectId: null, environment: "sandbox" }, () => + trackPendingTransfers(testEnv) + ); + expect( + ( + await createSystemPaymentsRepository(testEnv).getTransferById({ + transferId: transfer.id, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + }) + )?.status + ).toBe("processing"); + providerStatus = "BLOCKED"; + expect(await refresh(transfer.id)).toMatchObject({ + status: "failed", + signature: null, + error: expect.stringContaining("BLOCKED_BY_POLICY"), + }); + }); + it("does not expose credentials and blocks another project from reading or executing this wallet", async () => { + const mock = await ready(); + const response = await send(); + const raw = await response.text(); + expect(raw).not.toContain(fields.apiKey); + expect(raw).not.toContain("PRIVATE KEY"); + const id = JSON.parse(raw).data.transfer.id; + const other = await paymentApp.request( + `/v1/payments/transfers/${id}`, + { headers: { Authorization: `Bearer ${token}`, "X-Project-ID": `${PROJECT_ID}_production` } }, + testEnv + ); + expect(other.status).toBe(404); + expect(mock).toHaveBeenCalledTimes(1); + }); + it.each([400, 401, 403, 422, 429])( + "returns and replays TRANSACTION_FAILED for HTTP %s", + async (status) => { + const mock = await ready(); + createStatus = status; + for (let attempt = 0; attempt < 2; attempt++) { + const response = await send(); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error.code).toBe("TRANSACTION_FAILED"); + expect(body.error.message).not.toContain("secret must not be echoed"); + } + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + } + ); + it.each(["REJECTED", "BLOCKED"])( + "returns an error for an immediate %s result", + async (status) => { + await ready(); + providerStatus = status; + const response = await send(); + expect(response.status).toBe(400); + expect((await response.json()).error.code).toBe("TRANSACTION_FAILED"); + } + ); + it("keeps server failures pending and does not resubmit", async () => { + const mock = await ready(); + createStatus = 503; + for (let attempt = 0; attempt < 2; attempt++) { + const response = await send(); + expect(response.status).toBe(200); + expect((await response.json()).data.transfer.status).toBe("processing"); + } + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + }); + it("fails an unacknowledged intent only after authenticated 404s separated by 24 hours", async () => { + const mock = await ready(); + lostResponse = true; + readMissing = true; + const response = await send(); + const id = (await response.json()).data.transfer.id; + expect(await refresh(id)).toMatchObject({ status: "processing" }); + await getDb(env).execute( + "UPDATE payment_transfers SET provider_data = provider_data || ?::jsonb WHERE id = ?", + [ + JSON.stringify({ + fireblocksFirstMissingAt: new Date(Date.now() - 86_400_001).toISOString(), + }), + id, + ] + ); + expect(await refresh(id)).toMatchObject({ + status: "failed", + error: expect.stringContaining("24 hours"), + }); + expect((await (await send()).json()).data.transfer.id).toBe(id); + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + }); + it("never fails an acknowledged transfer because a lookup returns 404", async () => { + await ready(); + const response = await send(); + const id = (await response.json()).data.transfer.id; + readMissing = true; + await getDb(env).execute( + "UPDATE payment_transfers SET provider_data = provider_data || ?::jsonb WHERE id = ?", + [ + JSON.stringify({ + fireblocksFirstMissingAt: new Date(Date.now() - 86_400_001).toISOString(), + }), + id, + ] + ); + expect(await refresh(id)).toMatchObject({ status: "processing" }); + }); + it("does not expire an intent acknowledged while the absence lookup is in flight", async () => { + const mock = await ready(); + lostResponse = true; + const id = (await (await send()).json()).data.transfer.id; + await getDb(env).execute( + "UPDATE payment_transfers SET provider_data = provider_data || ?::jsonb WHERE id = ?", + [ + JSON.stringify({ + fireblocksFirstMissingAt: new Date(Date.now() - 86_400_001).toISOString(), + }), + id, + ] + ); + mock.mockImplementationOnce(async () => { + await getDb(env).execute( + "UPDATE payment_transfers SET provider_reference = 'fb-proposal' WHERE id = ?", + [id] + ); + return Response.json({}, { status: 404 }); + }); + expect(await refresh(id)).toMatchObject({ status: "processing" }); + const row = await getDb(env).queryOne<{ provider_reference: string; error: string | null }>( + "SELECT provider_reference, error FROM payment_transfers WHERE id = ?", + [id] + ); + expect(row).toEqual({ provider_reference: "fb-proposal", error: null }); + }); + it("joins concurrent sends under one intent and throttles concurrent status reads", async () => { + const mock = await ready(); + const responses = await Promise.all([send(), send()]); + const successful = responses.find((response) => response.status === 200); + if (!successful) throw new Error("Expected one concurrent request to succeed"); + for (const response of responses) { + if (response.status === 409) { + expect((await response.json()).error.code).toBe("IDEMPOTENCY_KEY_IN_FLIGHT"); + } else expect(response.status).toBe(200); + } + const replay = await send(); + expect(replay.status).toBe(200); + const ids = [ + (await successful.json()).data.transfer.id, + (await replay.json()).data.transfer.id, + ]; + expect(ids[0]).toBe(ids[1]); + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + const read = () => + paymentApp.request( + `/v1/payments/transfers/${ids[0]}`, + { headers: { Authorization: `Bearer ${token}`, "X-Project-ID": PROJECT_ID } }, + testEnv + ); + await Promise.all([read(), read(), read()]); + expect(mock.mock.calls.filter(([, init]) => init?.method === "GET")).toHaveLength(1); + }); + it.each([ + { projectId: `${PROJECT_ID}_production`, defaultNetwork: "devnet", assetId: "SOL" }, + { projectId: PROJECT_ID, defaultNetwork: "mainnet-beta", assetId: "SOL_TEST" }, + ] as const)( + "uses the project's $assetId despite deployment default $defaultNetwork", + async ({ projectId, defaultNetwork, assetId }) => { + testEnv.SOLANA_NETWORK = defaultNetwork; + const mock = await ready(projectId); + const response = await send("project-network", {}, projectId); + expect(response.status, await response.clone().text()).toBe(200); + expect(createBody.assetId).toBe(assetId); + expect(mock).toHaveBeenCalledTimes(1); + } + ); + + it("only polls Fireblocks intents in the cron's current environment", async () => { + const projectId = `${PROJECT_ID}_production`; + const mock = await ready(projectId); + expect((await send("cron-production", {}, projectId)).status).toBe(200); + mock.mockClear(); + await runInClusterScope({ projectId: null, environment: "sandbox" }, () => + trackPendingTransfers(testEnv) + ); + expect(mock).not.toHaveBeenCalled(); + await runInClusterScope({ projectId: null, environment: "production" }, () => + trackPendingTransfers(testEnv) + ); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock.mock.calls[0]?.[1]?.method).toBe("GET"); + }); + + it.each(["suspended organization", "revoked key", "removed grant", "removed wallet"])( + "refuses %s changed after route authorization, before the provider POST", + async (change) => { + const mock = await ready(); + const db = getDb(env); + const raw = `sk_test_${crypto.randomUUID().replaceAll("-", "")}`; + await seedProjectApiKey(db, testEnv, { + key: { id: "key_fireblocks_send", raw, prefix: raw.slice(0, 16) }, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + createdBy: USER_ID, + grants: ["transfers.execute"], + }); + token = raw; + const original = AuditService.prototype.beginCritical; + vi.spyOn(AuditService.prototype, "beginCritical").mockImplementation(async function ( + this: AuditService, + ...args + ) { + const intent = await original.apply(this, args); + if (change === "suspended organization") + await db.execute("UPDATE organizations SET status = 'suspended' WHERE id = ?", [ + ORGANIZATION_ID, + ]); + else if (change === "revoked key") + await db.execute("UPDATE api_keys SET status = 'revoked' WHERE id = ?", [ + "key_fireblocks_send", + ]); + else if (change === "removed grant") + await db.execute( + "UPDATE api_keys SET grants = ARRAY['transfers.read']::text[] WHERE id = ?", + ["key_fireblocks_send"] + ); + else + await db.execute("UPDATE api_keys SET wallet_scope = 'selected' WHERE id = ?", [ + "key_fireblocks_send", + ]); + return intent; + }); + const response = await send(); + expect(response.status, await response.clone().text()).toBe(403); + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(0); + const row = await db.queryOne<{ status: string }>( + "SELECT status FROM payment_transfers WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + expect(row?.status).toBe("failed"); + } + ); + + it.each([ + "inactive wallet", + "deactivated connection", + "failed health check", + "retired credential", + "changed connection intent", + "changed account", + "changed vault", + "changed source", + ] as const)("refuses %s changed during preparation without a provider POST", async (change) => { + const mock = await ready(); + const db = getDb(env); + const original = AuditService.prototype.beginCritical; + vi.spyOn(AuditService.prototype, "beginCritical").mockImplementation(async function ( + this: AuditService, + ...args + ) { + const audit = await original.apply(this, args); + switch (change) { + case "inactive wallet": + await db.execute("UPDATE custody_wallets SET status = 'inactive' WHERE id = ?", [ + walletId, + ]); + break; + case "deactivated connection": + await db.execute( + "UPDATE custody_connections SET status = 'deactivated', deactivated_at = sdp_iso_now() WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + break; + case "failed health check": + await db.execute( + "UPDATE custody_connections SET status = 'failed', activated_at = NULL, last_check_status = 'failed' WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + break; + case "retired credential": + await db.execute( + "UPDATE provider_credentials SET status = 'deactivated', deactivated_at = sdp_iso_now() WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + break; + case "changed connection intent": + await db.execute( + `UPDATE payment_transfers SET provider_data = jsonb_set(provider_data, + '{fireblocksTransfer,connectionId}', '"another-connection"'::jsonb) + WHERE organization_id = ?`, + [ORGANIZATION_ID] + ); + break; + case "changed account": + await db.execute( + "UPDATE custody_connections SET provider_account_fingerprint = 'another-account' WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + break; + case "changed vault": + await db.execute("UPDATE custody_wallets SET wallet_id = 'fb_1' WHERE id = ?", [ + walletId, + ]); + break; + case "changed source": + await db.execute("UPDATE custody_wallets SET public_key = ? WHERE id = ?", [ + OTHER_ADDRESS, + walletId, + ]); + break; + default: { + const exhaustive: never = change; + throw new Error(`Unexpected custody change: ${exhaustive}`); + } + } + return audit; + }); + const response = await send(); + expect(response.status, await response.clone().text()).toBe(409); + expect(mock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(0); + const row = await db.queryOne<{ + id: string; + status: string; + provider_reference: string | null; + }>("SELECT id, status, provider_reference FROM payment_transfers WHERE organization_id = ?", [ + ORGANIZATION_ID, + ]); + expect(row).toMatchObject({ status: "failed", provider_reference: null }); + if (!row) throw new Error("Expected failed payment intent"); + mock.mockClear(); + expect(await refresh(row.id)).toMatchObject({ status: "failed" }); + await send(); + expect(mock).not.toHaveBeenCalled(); + }); + + it("refuses a disabled connection before contacting Fireblocks", async () => { + const mock = await ready(); + await getDb(env).execute( + "UPDATE custody_connections SET status = 'deactivated', deactivated_at = sdp_iso_now() WHERE organization_id = ?", + [ORGANIZATION_ID] + ); + const response = await send(); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(mock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/sdp-api/src/services/payments/fireblocks-transfer.ts b/apps/sdp-api/src/services/payments/fireblocks-transfer.ts new file mode 100644 index 0000000000..08d7172e54 --- /dev/null +++ b/apps/sdp-api/src/services/payments/fireblocks-transfer.ts @@ -0,0 +1,392 @@ +import { FireblocksApiError } from "@sdp/custody/provisioning/fireblocks-http"; +import { + createFireblocksTransfer, + type FireblocksTransferResult, + readFireblocksTransfer, + resolveFireblocksTransferAsset, +} from "@sdp/custody/provisioning/fireblocks-transfers"; +import type { CustodyProvisioningRuntime } from "@sdp/custody/provisioning/runtime"; +import { currentClusterScope } from "@sdp/rpc/scope"; +import { compareDecimalAmounts, parseDecimalAmount } from "@sdp/solana/amount"; +import { fireblocksPaymentStatus, isFireblocksPaymentMint } from "@sdp/types/fireblocks"; +import { assertIsSignature } from "@solana/kit"; +import { z } from "zod"; +import { getDb } from "@/db/client"; +import type { PaymentsRepository, PaymentTransferRow } from "@/db/repositories/payments.repository"; +import { AppError, badRequest, conflict } from "@/lib/errors"; +import { assertMoneyStartAdmitted } from "@/lib/money-admission"; +import { getLogger } from "@/runtime/logger"; +import { describeError } from "@/runtime/money-path-events"; +import { CustodyRuntimeTargets } from "@/services/domain/signing/custody-runtime-target"; +import { + isNativePaymentToken, + type OutboundPaymentOperation, +} from "@/services/payment-operation.service"; +import type { Env } from "@/types/env"; + +const intentSchema = z.object({ + connectionId: z.string(), + accountFingerprint: z.string(), + vaultAccountId: z.string(), + assetId: z.string(), +}); +type Intent = z.infer; +export interface PreparedFireblocksTransfer { + intent: Intent; +} +const runtime: CustodyProvisioningRuntime = { + fetch: (input, init) => globalThis.fetch(input, init), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now: () => Date.now(), + randomUUID: () => crypto.randomUUID(), + getRandomValues: (data) => crypto.getRandomValues(new Uint8Array(data)), + sha256: (data) => crypto.subtle.digest("SHA-256", new Uint8Array(data)), +}; + +export function isFireblocksTransfer(row: PaymentTransferRow): boolean { + return row.provider_data.fireblocksTransfer !== undefined; +} + +/** Validate the exact owner, network and token before admitting a payment intent. */ +export async function prepareFireblocksTransfer( + env: Env, + organizationId: string, + projectId: string, + operation: OutboundPaymentOperation +): Promise { + const access = await new CustodyRuntimeTargets( + getDb(env), + env, + new Map() + ).getFireblocksTransferAccess({ + organizationId, + projectId, + custodyWalletId: operation.sourceWallet.id, + forSubmission: true, + }); + if (!access) return null; + if (access.publicKey !== operation.sourceAddress) + throw conflict("Fireblocks source wallet changed"); + const network = currentClusterScope().cluster; + const expectedAsset = network === "mainnet-beta" ? "SOL" : "SOL_TEST"; + if (access.assetId !== expectedAsset) + throw badRequest("Fireblocks connection does not match the Solana network"); + const native = isNativePaymentToken(operation.token); + if (!native && !isFireblocksPaymentMint(operation.token, network)) + throw badRequest("Fireblocks payments currently support SOL and USDC only"); + const asset = await resolveFireblocksTransferAsset( + runtime, + access, + expectedAsset, + native ? null : operation.token + ); + let units: bigint; + try { + units = parseDecimalAmount(operation.amount, asset.decimals); + } catch { + throw badRequest(`Payment amount must have at most ${asset.decimals} decimal places`); + } + if (units <= 0n || units > 18446744073709551615n) + throw badRequest("Payment amount is outside the supported range"); + if (!access.accountFingerprint) throw conflict("Fireblocks connection is unverified"); + return { + intent: { + connectionId: access.connectionId, + accountFingerprint: access.accountFingerprint, + vaultAccountId: access.vaultAccountId, + assetId: asset.id, + }, + }; +} + +function providerFailure(result: FireblocksTransferResult): string { + const subStatus = + result.subStatus && /^[A-Z0-9_]{1,100}$/.test(result.subStatus) ? ` (${result.subStatus})` : ""; + const next = + result.status === "BLOCKED" + ? " Check this transfer's approval and destination rules in Fireblocks." + : " Review this transaction in Fireblocks."; + return `Fireblocks ${result.status}${subStatus}.${next}`; +} + +async function persistResult( + repo: PaymentsRepository, + row: PaymentTransferRow, + result: FireblocksTransferResult, + detail: boolean +): Promise { + const intent = intentSchema.parse(row.provider_data.fireblocksTransfer); + if (row.provider_reference && row.provider_reference !== result.id) + throw conflict("Fireblocks transaction identity changed"); + if ( + detail && + (result.externalTxId !== row.id || + result.operation !== "TRANSFER" || + result.assetId !== intent.assetId || + result.source?.type !== "VAULT_ACCOUNT" || + result.source.id !== intent.vaultAccountId) + ) + throw conflict("Fireblocks transaction does not match the payment intent"); + let signature: string | undefined; + if (result.txHash) { + assertIsSignature(result.txHash); + signature = result.txHash; + } + // A hash during approval/broadcast can still be replaced. Only COMPLETED is a chain verdict. + const completed = detail && result.status === "COMPLETED" && signature !== undefined; + if (completed) { + // Fireblocks may omit destinationAddress before confirmation. A completed + // transfer must prove the exact destination and net payment, using decimal + // strings rather than the deprecated floating-point amount fields. + const amounts = result.amountInfo; + if ( + result.destinationAddress !== row.destination_address || + !row.amount || + !amounts?.amount || + !amounts.netAmount || + compareDecimalAmounts(amounts.amount, row.amount) !== 0 || + compareDecimalAmounts(amounts.netAmount, row.amount) !== 0 || + (amounts.requestedAmount != null && + compareDecimalAmounts(amounts.requestedAmount, row.amount) !== 0) + ) + throw conflict("Fireblocks settlement does not match the payment destination and amount"); + } + let fee: number | undefined; + if ( + completed && + (result.feeCurrency === "SOL" || result.feeCurrency === "SOL_TEST") && + typeof result.feeInfo?.networkFee === "string" + ) { + try { + const lamports = parseDecimalAmount(result.feeInfo.networkFee, 9); + if (lamports <= BigInt(Number.MAX_SAFE_INTEGER)) fee = Number(lamports); + } catch { + /* An unavailable fee must not prevent recording a confirmed payment. */ + } + } + const status = fireblocksPaymentStatus(result.status, completed); + return ( + (await repo.updateTransfer({ + transferId: row.id, + organizationId: row.organization_id, + projectId: row.project_id, + expectedStatus: "processing", + status, + signature: completed ? signature : undefined, + fee, + providerReference: result.id, + providerData: { + fireblocksStatus: result.status, + fireblocksSubStatus: + result.subStatus && /^[A-Z0-9_]{1,100}$/.test(result.subStatus) ? result.subStatus : null, + }, + error: status === "failed" ? providerFailure(result) : null, + updatedAt: new Date().toISOString(), + })) ?? (await currentRow(repo, row)) + ); +} + +async function currentRow(repo: PaymentsRepository, row: PaymentTransferRow) { + return ( + (await repo.getTransferById({ + transferId: row.id, + organizationId: row.organization_id, + projectId: row.project_id, + })) ?? row + ); +} + +/** Exactly one create attempt per durable intent. Recovery never sends funds. */ +export async function submitFireblocksTransfer( + env: Env, + repo: PaymentsRepository, + row: PaymentTransferRow +): Promise { + if (!row.destination_address || !row.amount || !row.project_id || !row.custody_wallet_id) + throw badRequest("Incomplete payment intent"); + const startedAt = new Date().toISOString(); + const claimed = await repo.claimFireblocksTransferSubmission({ + transferId: row.id, + organizationId: row.organization_id, + projectId: row.project_id, + now: startedAt, + }); + if (!claimed) return currentRow(repo, row); + let submissionAttempted = false; + try { + const intent = intentSchema.parse(claimed.provider_data.fireblocksTransfer); + // Preparation can precede asset discovery and durable payment/audit writes. + // Resolve fresh custody state after those awaits; never submit captured credentials. + const access = await new CustodyRuntimeTargets( + getDb(env), + env, + new Map() + ).getFireblocksTransferAccess({ + organizationId: row.organization_id, + projectId: row.project_id, + custodyWalletId: row.custody_wallet_id, + forSubmission: true, + }); + const expectedAsset = currentClusterScope().cluster === "mainnet-beta" ? "SOL" : "SOL_TEST"; + if ( + !access || + access.connectionId !== intent.connectionId || + access.accountFingerprint !== intent.accountFingerprint || + access.vaultAccountId !== intent.vaultAccountId || + access.publicKey !== claimed.source_address || + access.assetId !== expectedAsset + ) + throw conflict("Fireblocks connection does not match the payment intent"); + await assertMoneyStartAdmitted( + env, + { organizationId: row.organization_id, projectId: row.project_id }, + { surface: "http", movement: "payments.transfer", subjectId: row.id }, + { custodyWalletId: row.custody_wallet_id } + ); + submissionAttempted = true; + const result = await createFireblocksTransfer(runtime, access, { + operation: "TRANSFER", + externalTxId: row.id, + assetId: intent.assetId, + source: { type: "VAULT_ACCOUNT", id: intent.vaultAccountId }, + destination: { + type: "ONE_TIME_ADDRESS", + oneTimeAddress: { address: row.destination_address }, + }, + amount: row.amount, + treatAsGrossAmount: false, + // SDP's memo is a payment note, not an instruction added to the chain. + note: `SDP ${row.id}${row.memo ? `: ${row.memo}` : ""}`, + }); + return await persistResult(repo, claimed, result, false); + } catch (error) { + if (!submissionAttempted) { + // A local refusal is definitely unsent, unlike an ambiguous provider outcome. + await repo.updateTransfer({ + transferId: row.id, + organizationId: row.organization_id, + projectId: row.project_id, + expectedStatus: "processing", + status: "failed", + error: + error instanceof AppError + ? error.toResponse().error.message + : "Fireblocks admission failed before submission", + providerData: { + executionFailure: { code: error instanceof AppError ? error.code : "INTERNAL_ERROR" }, + }, + updatedAt: new Date().toISOString(), + }); + throw error; + } + // A timeout, duplicate-ID conflict, invalid response, or DB failure may follow acceptance. + // Keep the claim and recover by externalTxId; never turn uncertainty into permission to resend. + if (error instanceof FireblocksApiError && [400, 401, 403, 422, 429].includes(error.status)) { + return ( + (await repo.updateTransfer({ + transferId: row.id, + organizationId: row.organization_id, + projectId: row.project_id, + expectedStatus: "processing", + status: "failed", + error: `${error.message}. Check the API user's permissions and Fireblocks destination policy.`, + updatedAt: new Date().toISOString(), + })) ?? currentRow(repo, row) + ); + } + getLogger().warn( + { transferId: row.id, ...describeError(error) }, + "fireblocks_transfer_submission_outcome_unknown" + ); + return currentRow(repo, claimed); + } +} + +const MISSING_TRANSACTION_WINDOW_MS = 24 * 60 * 60 * 1000; +/** A known provider transaction can never be failed from an unavailable lookup. */ +async function recordMissingTransaction( + repo: PaymentsRepository, + row: PaymentTransferRow, + now: number +) { + if (row.provider_reference) { + getLogger().error( + { transferId: row.id, fireblocksTransactionId: row.provider_reference }, + "fireblocks_known_transaction_missing" + ); + return row; + } + const firstMissing = row.provider_data.fireblocksFirstMissingAt; + const elapsed = typeof firstMissing === "string" ? now - Date.parse(firstMissing) : 0; + // Two authenticated 404s separated by at least 24h, bound to the same + // account, establish that the unacknowledged intent is still absent. + const absent = elapsed >= MISSING_TRANSACTION_WINDOW_MS; + if (absent) getLogger().warn({ transferId: row.id }, "fireblocks_unaccepted_transfer_expired"); + return ( + (await repo.updateTransfer({ + transferId: row.id, + organizationId: row.organization_id, + projectId: row.project_id, + expectedStatus: "processing", + status: absent ? "failed" : "processing", + expectedProviderReference: null, + providerData: { + fireblocksFirstMissingAt: + typeof firstMissing === "string" ? firstMissing : new Date(now).toISOString(), + }, + error: absent + ? "Fireblocks still has no transaction for this payment after 24 hours of recovery. Review the payment in Fireblocks before intentionally starting a new transfer." + : null, + updatedAt: new Date(now).toISOString(), + })) ?? currentRow(repo, row) + ); +} + +/** Dashboard and cron reconciliation. Neither path can POST or automatically resubmit. */ +export async function refreshFireblocksTransfer( + env: Env, + repo: PaymentsRepository, + row: PaymentTransferRow +): Promise { + if ( + !isFireblocksTransfer(row) || + row.status !== "processing" || + !row.project_id || + !row.custody_wallet_id + ) + return row; + const now = Date.now(); + const claimed = await repo.claimFireblocksTransferPoll({ + transferId: row.id, + organizationId: row.organization_id, + projectId: row.project_id, + now: new Date(now).toISOString(), + polledBefore: new Date(now - 15_000).toISOString(), + }); + if (!claimed) return currentRow(repo, row); + try { + const intent = intentSchema.parse(claimed.provider_data.fireblocksTransfer); + const access = await new CustodyRuntimeTargets( + getDb(env), + env, + new Map() + ).getFireblocksTransferAccess({ + organizationId: row.organization_id, + projectId: row.project_id, + custodyWalletId: row.custody_wallet_id, + forSubmission: false, + intent: { ...intent, sourceAddress: row.source_address }, + }); + if (!access) throw conflict("Fireblocks connection does not match the payment intent"); + const result = await readFireblocksTransfer(runtime, access, row.id); + return result + ? await persistResult(repo, claimed, result, true) + : await recordMissingTransaction(repo, claimed, now); + } catch (error) { + getLogger().warn( + { transferId: row.id, ...describeError(error) }, + "fireblocks_transfer_status_unavailable" + ); + return currentRow(repo, claimed); + } +} diff --git a/apps/sdp-api/src/services/payments/sign-with-fresh-blockhash.test.ts b/apps/sdp-api/src/services/payments/sign-with-fresh-blockhash.test.ts new file mode 100644 index 0000000000..4dc5fa62a0 --- /dev/null +++ b/apps/sdp-api/src/services/payments/sign-with-fresh-blockhash.test.ts @@ -0,0 +1,53 @@ +import * as solana from "@sdp/rpc/solana"; +import { createRpcFromTransport, isBlockhashValid } from "@sdp/rpc/solana"; +import { blockhash } from "@solana/kit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { signWithFreshBlockhash } from "./sign-with-fresh-blockhash"; + +const rpc = createRpcFromTransport(async () => { + throw new Error("Unexpected RPC call"); +}); +beforeEach(() => { + vi.spyOn(solana, "isBlockhashValid"); +}); +const hash = blockhash("11111111111111111111111111111111"); +afterEach(() => vi.restoreAllMocks()); + +describe("payment approval lifetime", () => { + it("adds no RPC read for fast signing", async () => { + vi.spyOn(performance, "now").mockReturnValueOnce(0).mockReturnValueOnce(20); + vi.mocked(isBlockhashValid).mockClear(); + expect(await signWithFreshBlockhash({ rpc, blockhash: hash, sign: async () => "signed" })).toBe( + "signed" + ); + expect(isBlockhashValid).not.toHaveBeenCalled(); + }); + + it("checks the exact blockhash once after slow approval", async () => { + vi.spyOn(performance, "now").mockReturnValueOnce(0).mockReturnValueOnce(30_000); + vi.mocked(isBlockhashValid).mockReset().mockResolvedValue(true); + expect(await signWithFreshBlockhash({ rpc, blockhash: hash, sign: async () => "signed" })).toBe( + "signed" + ); + expect(isBlockhashValid).toHaveBeenCalledExactlyOnceWith(rpc, hash, "confirmed"); + }); + + it("refuses expired signatures without retrying custody signing", async () => { + vi.spyOn(performance, "now").mockReturnValueOnce(0).mockReturnValueOnce(80_000); + vi.mocked(isBlockhashValid).mockReset().mockResolvedValue(false); + const sign = vi.fn(async () => "signed"); + await expect(signWithFreshBlockhash({ rpc, blockhash: hash, sign })).rejects.toMatchObject({ + code: "TRANSACTION_FAILED", + message: expect.stringContaining("was not submitted"), + }); + expect(sign).toHaveBeenCalledTimes(1); + }); + + it("fails closed when the lifetime check is unavailable", async () => { + vi.spyOn(performance, "now").mockReturnValueOnce(0).mockReturnValueOnce(30_000); + vi.mocked(isBlockhashValid).mockReset().mockRejectedValue(new Error("RPC unavailable")); + await expect( + signWithFreshBlockhash({ rpc, blockhash: hash, sign: async () => "signed" }) + ).rejects.toThrow("RPC unavailable"); + }); +}); diff --git a/apps/sdp-api/src/services/payments/sign-with-fresh-blockhash.ts b/apps/sdp-api/src/services/payments/sign-with-fresh-blockhash.ts new file mode 100644 index 0000000000..c59820849c --- /dev/null +++ b/apps/sdp-api/src/services/payments/sign-with-fresh-blockhash.ts @@ -0,0 +1,24 @@ +import { isBlockhashValid, type SolanaRpc } from "@sdp/rpc/solana"; +import type { Blockhash } from "@solana/kit"; +import { transactionFailed } from "@/lib/errors"; + +/** Recheck the exact approved message after slow custody signing; never silently re-sign it. */ +export async function signWithFreshBlockhash(input: { + rpc: SolanaRpc; + blockhash: Blockhash; + sign: () => Promise; +}): Promise { + const started = performance.now(); + const signed = await input.sign(); + // Fast signatures use the fresh build's lifetime. Human approval can consume it. + // One additional read only when signing takes at least 15 seconds. + if ( + performance.now() - started >= 15_000 && + !(await isBlockhashValid(input.rpc, input.blockhash, "confirmed")) + ) { + throw transactionFailed( + "This transaction expired while waiting for custody approval and was not submitted. Review payment status before creating another payment." + ); + } + return signed; +} diff --git a/apps/sdp-api/src/services/provider-credential-installation.service.ts b/apps/sdp-api/src/services/provider-credential-installation.service.ts index 50bcd0b288..3177913830 100644 --- a/apps/sdp-api/src/services/provider-credential-installation.service.ts +++ b/apps/sdp-api/src/services/provider-credential-installation.service.ts @@ -1,6 +1,7 @@ import { normalizePrivyWalletId } from "@sdp/custody/provider-wallet-ids"; import type { ProvisionPrivyResult } from "@sdp/custody/provisioning"; import { SigningError } from "@sdp/custody/signing"; +import type { ByokCustodyProvider } from "@sdp/types/custody"; import type { Context } from "hono"; import { type DatabaseClient, getDb } from "@/db/client"; import { isPostgresUniqueViolation, parsePostgresJsonOr } from "@/db/postgres-utils"; @@ -22,12 +23,21 @@ import { CredentialSecretStoreError, type StoredCredentialSecret, } from "@/services/credential-secret-store"; +import { + type ByokCredentialAuthentication, + checkByokCredential, + parseByokCredential, +} from "@/services/custody/byok-credential"; import { checkPrivyCredential, getPrivyProviderAccountFingerprint, type PrivyCredentialAuthentication, } from "@/services/custody/privy-credential"; -import { findPrivyWalletByExternalId, provisionPrivyWallet } from "@/services/custody/provisioning"; +import { + findPrivyWalletByExternalId, + getFireblocksVault, + provisionPrivyWallet, +} from "@/services/custody/provisioning"; import { admitByokCustodySetup, type CustodySetupAdmission, @@ -42,6 +52,7 @@ import { import type { SafeProviderCredential } from "@/services/provider-credential-submission.service"; import { destroySecretVersion, queueOrphanedSecretVersion } from "@/services/secret-retirement"; import { + getPendingFireblocksVaultIds, getPendingWalletLabel, type InstallationConnectionState, ProviderCredentialStore, @@ -65,7 +76,7 @@ interface SafeCompletion { export interface SafeInstallationConnection { id: string; - provider: "privy"; + provider: ByokCustodyProvider; label: string; status: InstallationConnectionState["status"]; completion: SafeCompletion | null; @@ -98,7 +109,11 @@ interface LoadedInstallation { } type ProviderOutcome = - | { kind: "success"; wallet: ProvisionPrivyResult } + | { + kind: "success"; + wallet: ProvisionPrivyResult & { label?: string }; + additionalWallets?: Array; + } | { kind: "failed"; code: "invalid_credentials" | "provider_account_already_connected" | "wallet_conflict"; @@ -144,7 +159,10 @@ export async function deactivateCustodyConnection( action: "deactivate", resourceType: "custody_connection", resourceId: connectionId, - metadata: { event: "custody_connection_deactivation_started", provider: "privy" }, + metadata: { + event: "custody_connection_deactivation_started", + provider: loaded.target.provider, + }, }); let changed = false; let refusedTarget = loaded.target; @@ -228,7 +246,7 @@ export async function deactivateCustodyConnection( } if (changed) { await context.audit.completeCritical(c, intent, { - metadata: { event: "custody_connection_deactivated", provider: "privy" }, + metadata: { event: "custody_connection_deactivated", provider: loaded.target.provider }, }); } else { await completeInstallationCriticalNoop( @@ -306,7 +324,7 @@ export async function completeProviderCredentialInstallation( resourceId: connectionId, metadata: { event: "provider_credential_installation_completion_started", - provider: "privy", + provider: loaded.target.provider, scope: loaded.target.credential_scope, projectId: context.projectId, providerCredentialId: loaded.target.provider_credential_id, @@ -321,11 +339,7 @@ export async function completeProviderCredentialInstallation( context.c.env, loaded.target.credential_storage_backend ); - const credential = await readPrivyCredential( - secretStore, - context.organizationId, - loaded.target - ); + const credential = await readByokCredential(secretStore, context.organizationId, loaded.target); failureCode = "completion_failed"; const leaseToken = await acquireCompletionLease(context, loaded.target); if (!leaseToken) { @@ -358,7 +372,13 @@ export async function completeProviderCredentialInstallation( completionPersistencePending = true; const replay = outcome.kind === "success" - ? await persistSuccess(context, loaded.target, leaseToken, outcome.wallet) + ? await persistSuccess( + context, + loaded.target, + leaseToken, + outcome.wallet, + outcome.additionalWallets + ) : outcome.kind === "retry_unknown" ? await persistRetryUnknown(context, loaded.target, leaseToken) : await persistFailure(context, loaded.target, leaseToken, outcome.code); @@ -385,7 +405,7 @@ export async function completeProviderCredentialInstallation( throw installationConflict("provider_account_already_connected"); } if (outcome.code === "wallet_conflict") { - throw conflict("Privy wallet cannot be reconciled"); + throw conflict("Custody wallet cannot be reconciled"); } } return completionResult(completed); @@ -396,7 +416,7 @@ export async function completeProviderCredentialInstallation( event: "sdp_api_credential_installation_audit_unresolved", organizationId: context.organizationId, projectId: context.projectId, - provider: "privy", + provider: loaded.target.provider, providerCredentialId: loaded.target.provider_credential_id, connectionId, auditIntentId: auditIntent.id, @@ -438,7 +458,7 @@ export async function cancelProviderCredentialInstallation( resourceType: "custody_connection", resourceId: connectionId, metadata: { - provider: "privy", + provider: loaded.target.provider, event: "provider_credential_installation_cancellation_started", providerCredentialId: loaded.target.provider_credential_id, }, @@ -637,7 +657,12 @@ function mapSafeCredential(target: InstallationConnectionState): SafeProviderCre projectId: target.credential_project_id, status: target.credential_status, createdAt: target.credential_created_at, - displayMetadata: appIdSuffix ? { appIdSuffix } : {}, + displayMetadata: { + appIdSuffix, + apiKeySuffix: typeof metadata.apiKeySuffix === "string" ? metadata.apiKeySuffix : undefined, + primaryVaultId: + typeof metadata.primaryVaultId === "string" ? metadata.primaryVaultId : undefined, + }, }; } @@ -656,11 +681,11 @@ function createPersistedSecretStore( } } -async function readPrivyCredential( +async function readByokCredential( store: CredentialSecretStore, organizationId: string, row: InstallationConnectionState -): Promise { +): Promise { let payload: CredentialSecretPayload; try { payload = await store.read({ orgId: organizationId, stored: toStoredCredentialSecret(row) }); @@ -671,12 +696,7 @@ async function readPrivyCredential( throw internalError(); } - const appId = typeof payload.appId === "string" ? payload.appId.trim() : ""; - const appSecret = typeof payload.appSecret === "string" ? payload.appSecret : ""; - if (!appId || !appSecret) { - throw internalError(); - } - return { appId, appSecret }; + return parseByokCredential(row.provider, payload); } function toStoredCredentialSecret(row: InstallationConnectionState): StoredCredentialSecret { @@ -688,13 +708,113 @@ function toStoredCredentialSecret(row: InstallationConnectionState): StoredCrede }; } +async function verifySelectedFireblocksVaults( + context: InstallationContext, + target: InstallationConnectionState, + credential: Extract, + primaryWallet: ProvisionPrivyResult +): Promise { + const selectedIds = getPendingFireblocksVaultIds(target.setup_metadata); + const imported: Array = []; + if (selectedIds) { + if (!selectedIds.includes(credential.vaultAccountId)) + return { kind: "failed", code: "invalid_credentials" }; + // Only selected vaults are read; each address is verified once by solana-keychain. + for (const id of selectedIds) { + try { + const vault = await getFireblocksVault(context.c.env, credential, id); + if (!vault.hasSolanaWallet) return { kind: "failed", code: "invalid_credentials" }; + const verified = + id === credential.vaultAccountId + ? { status: "success" as const, wallet: primaryWallet } + : await checkByokCredential(context.c.env, { ...credential, vaultAccountId: id }); + if (verified.status !== "success" || !verified.wallet) + return verified.status === "failed" + ? { kind: "failed", code: "invalid_credentials" } + : { kind: "retry_unknown" }; + imported.push({ ...verified.wallet, label: vault.name.slice(0, 100) }); + } catch (error) { + return providerErrorOutcome(error); + } + } + const duplicate = await context.db.queryOne<{ id: string }>( + `SELECT w.id FROM custody_wallets w JOIN custody_connections c ON c.id = w.custody_connection_id + WHERE c.organization_id = ? AND c.project_id = ? AND c.provider = 'fireblocks' + AND c.id <> ? AND w.status = 'active' + AND w.public_key IN (SELECT jsonb_array_elements_text(CAST(? AS jsonb))) LIMIT 1`, + [ + context.organizationId, + context.projectId, + target.id, + JSON.stringify(imported.map((wallet) => wallet.address)), + ] + ); + if (duplicate) return { kind: "failed", code: "provider_account_already_connected" }; + } + + return { + kind: "success", + wallet: imported.find((wallet) => wallet.walletId === primaryWallet.walletId) ?? primaryWallet, + additionalWallets: imported.filter((wallet) => wallet.walletId !== primaryWallet.walletId), + }; +} + +async function completeFireblocksInstallation( + context: InstallationContext, + target: InstallationConnectionState, + credential: Extract, + leaseToken: string +): Promise { + const checked = await checkByokCredential(context.c.env, credential); + if (checked.status !== "success") + return checked.status === "failed" + ? { kind: "failed", code: "invalid_credentials" } + : { kind: "retry_unknown" }; + if (!checked.wallet) return { kind: "retry_unknown" }; + const imported = await verifySelectedFireblocksVaults( + context, + target, + credential, + checked.wallet + ); + if (imported.kind !== "success") return imported; + if ( + target.provider_account_fingerprint && + target.provider_account_fingerprint !== checked.fingerprint + ) + return { kind: "failed", code: "wallet_conflict" }; + if (!target.provider_account_fingerprint) { + try { + if ( + !(await context.store.reserveProviderAccountFingerprint({ + connectionId: target.id, + providerCredentialId: target.provider_credential_id, + leaseToken, + fingerprint: checked.fingerprint, + })) + ) { + const current = await loadInstallation(context, target.id); + if (current.decisions.complete.kind === "replay") + return { kind: "replay", installation: current }; + throw conflict(INSTALLATION_UNAVAILABLE_MESSAGE); + } + } catch (error) { + if (!isPostgresUniqueViolation(error)) throw error; + return { kind: "failed", code: "provider_account_already_connected" }; + } + } + return imported; +} + async function executeCompletionMode( context: InstallationContext, target: InstallationConnectionState, - credential: PrivyCredentialAuthentication, + credential: ByokCredentialAuthentication, leaseToken: string, mode: "full" | "reconcile_only" ): Promise { + if (credential.provider === "fireblocks") + return completeFireblocksInstallation(context, target, credential, leaseToken); const externalId = `sdp_${target.id}`; const fingerprint = await getPrivyProviderAccountFingerprint(credential.appId); if (target.provider_account_fingerprint && target.provider_account_fingerprint !== fingerprint) { @@ -794,7 +914,8 @@ async function persistSuccess( context: InstallationContext, target: InstallationConnectionState, leaseToken: string, - wallet: ProvisionPrivyResult + wallet: ProvisionPrivyResult & { label?: string }, + additionalWallets?: Array ): Promise { try { await context.db.transaction(async (tx) => { @@ -808,7 +929,8 @@ async function persistSuccess( leaseToken, providerWalletId: wallet.walletId, publicKey: wallet.address, - label: getPendingWalletLabel(target.setup_metadata), + label: getPendingWalletLabel(target.setup_metadata) ?? wallet.label, + additionalWallets, }); if (!updated) throw new Error("Installation Credential changed during success persistence"); }); @@ -866,7 +988,7 @@ async function persistFailure( tx, retirementContext(target), storedSecretRef(target), - "orphaned by a rejected Privy installation" + "orphaned by a rejected custody installation" ); return row; }); @@ -953,7 +1075,7 @@ function storedSecretRef(credential: InstallationConnectionState) { /** Who the retirement belongs to, for the queue row and the orphan-risk log. */ function retirementContext(credential: InstallationConnectionState) { return { - provider: "privy" as const, + provider: credential.provider, orgId: credential.organization_id, sourceId: credential.provider_credential_id, }; diff --git a/apps/sdp-api/src/services/provider-credential-lifecycle.service.ts b/apps/sdp-api/src/services/provider-credential-lifecycle.service.ts index 2efa3796ab..790f0db09e 100644 --- a/apps/sdp-api/src/services/provider-credential-lifecycle.service.ts +++ b/apps/sdp-api/src/services/provider-credential-lifecycle.service.ts @@ -1,3 +1,4 @@ +import { denormalizeFireblocksWalletId } from "@sdp/custody/provider-wallet-ids"; import { hashString } from "@sdp/payments/hash"; import { isProviderCredentialCreationInProgress } from "@sdp/types/custody"; import type { Context } from "hono"; @@ -6,6 +7,7 @@ import { isPostgresUniqueViolation } from "@/db/postgres-utils"; import { getAuth, requireProjectId } from "@/lib/auth"; import { AppError, + badRequest, conflict, forbidden, internalError, @@ -25,10 +27,17 @@ import { type StoredCredentialSecret, } from "@/services/credential-secret-store"; import { - checkPrivyCredential, - getPrivyProviderAccountFingerprint, - type PrivyCredentialAuthentication, -} from "@/services/custody/privy-credential"; + type ByokCredentialAuthentication, + type ByokCredentialFields, + type ByokRegistration, + checkByokCredential, + credentialDisplayMetadata, + parseByokCredential, +} from "@/services/custody/byok-credential"; +import { + resolveRegistrationPayload, + retireFireblocksCsr, +} from "@/services/custody/fireblocks-registration.service"; import { assertCustodyProviderAvailable } from "@/services/provider-availability.service"; import { assertCredentialCreationSettled, @@ -40,6 +49,7 @@ import { } from "@/services/provider-credential-submission.service"; import { type CredentialReferenceRow, + type CredentialWalletReferenceRow, type LifecycleCredentialRow, type LifecycleCredentialWithSecretRow, type ProviderCredentialRow, @@ -144,7 +154,7 @@ export async function getProviderCredentialLifecycle( export async function rotateProviderCredential( c: Context<{ Bindings: Env }>, currentCredentialId: string, - fields: { appId: string; appSecret: string }, + registration: ByokRegistration, idempotencyKey: string ): Promise { const context = createContext(c); @@ -152,6 +162,10 @@ export async function rotateProviderCredential( context, await requireChannelScopedCredential(context, currentCredentialId) ); + if (registration.provider !== target.credential.provider) { + throw badRequest("Rotation provider does not match the credential"); + } + const { fields } = registration; const fingerprint = await rotationFingerprint(context, currentCredentialId, fields); const existing = await context.store.findReplayByKey(context.organizationId, idempotencyKey); if (existing) { @@ -160,7 +174,11 @@ export async function rotateProviderCredential( } const authorizedReplay = await loadAuthorizedCandidate(context, existing.id, true); const replay = await resolveIdempotencyReplay(() => Promise.resolve(existing), fingerprint); - if (replay) return replayRotationCandidate(c, authorizedReplay); + if (replay) { + const result = await replayRotationCandidate(c, authorizedReplay); + await retireFireblocksCsr(c, fields, result.providerCredential); + return result; + } } const current = await loadAuthorizedCurrent(context, currentCredentialId, ROTATION_UNAVAILABLE); if ( @@ -169,13 +187,22 @@ export async function rotateProviderCredential( throw conflict(ROTATION_UNAVAILABLE); } - return submitRotationCandidate(context, current, fields, idempotencyKey, fingerprint); + await resolveRegistrationPayload(c, target.credential.provider, fields, idempotencyKey); + const result = await submitRotationCandidate( + context, + current, + fields, + idempotencyKey, + fingerprint + ); + await retireFireblocksCsr(c, fields, result.providerCredential); + return result; } async function submitRotationCandidate( context: LifecycleContext, current: AuthorizedCredential, - fields: { appId: string; appSecret: string }, + fields: ByokCredentialFields, idempotencyKey: string, fingerprint: string ): Promise { @@ -189,7 +216,7 @@ async function submitRotationCandidate( resourceId: candidateId, metadata: { event: "provider_credential_rotation_submission_started", - provider: "privy", + provider: current.credential.provider, scope: current.credential.scope, predecessorCredentialId: current.credential.id, }, @@ -212,8 +239,9 @@ async function submitRotationCandidate( context, secretStore, candidateId, - fields.appId, - fields.appSecret + current.credential.provider, + fields, + idempotencyKey ); } } catch (error) { @@ -253,12 +281,12 @@ async function submitRotationCandidate( id: candidateId, organizationId: context.organizationId, projectId: current.credential.project_id, - provider: "privy", + provider: current.credential.provider, label: current.credential.label, scope: current.credential.scope, source: "stored", stored, - displayMetadata: fields.appId.length > 4 ? { appIdSuffix: fields.appId.slice(-4) } : {}, + displayMetadata: credentialDisplayMetadata(fields), version: await store.nextCredentialVersion(context.organizationId, current.credential.id), rotatedFromId: current.credential.id, idempotencyKey, @@ -355,7 +383,7 @@ async function finishGcpRotationCreation( current: AuthorizedCredential, candidate: LifecycleCredentialRow, secretStore: CredentialSecretStore, - fields: { appId: string; appSecret: string }, + fields: ByokCredentialFields, auditIntent: AuditIntent, existingSecretRef?: string ): Promise { @@ -371,12 +399,14 @@ async function finishGcpRotationCreation( } let written: StoredCredentialSecret | undefined; try { + if (!candidate.idempotency_key) throw internalError(); written = await writeRotationSecret( context, secretStore, candidateId, - fields.appId, - fields.appSecret, + current.credential.provider, + fields, + candidate.idempotency_key, existingSecretRef ); const { secretRef, secretVersionRef } = written; @@ -466,7 +496,7 @@ export async function completeRotationCandidate( resourceId: loaded.candidate.id, metadata: { event: "provider_credential_rotation_started", - provider: "privy", + provider: loaded.candidate.provider, scope: loaded.candidate.scope, fromProviderCredentialId: loaded.predecessor.id, toProviderCredentialId: loaded.candidate.id, @@ -475,28 +505,36 @@ export async function completeRotationCandidate( }, }); let candidateSecret: LifecycleCredentialWithSecretRow; - let authentication: PrivyCredentialAuthentication; + let authentication: ByokCredentialAuthentication; try { candidateSecret = await requireLifecycleCredentialSecret(context, loaded.candidate.id); const secretStore = createPersistedSecretStore(c, candidateSecret.storage_backend, candidateId); - authentication = await readPrivyCredential(secretStore, context, candidateSecret); + authentication = await readByokCredential(secretStore, context, candidateSecret); } catch (error) { return reconcileCandidateSecretReadFailure(c, context, loaded.candidate.id, error, auditIntent); } - const check = await checkPrivyCredential(c.env, authentication); + const wallets = + authentication.provider === "fireblocks" + ? await context.store.listCredentialWalletReferences( + context.organizationId, + loaded.predecessor.id + ) + : []; + const checked = await checkCredentialReplacement( + c.env, + authentication, + loaded.references, + wallets + ); + const check = checked.status; if (check === "retry_unknown") { const race = await settleCandidateOutcome(context, loaded, "retry_unknown", auditIntent); if (race) return race; return rotationResult(loaded.candidate, "retry_unknown", "provider_response_unknown"); } - const accountMatches = - check === "success" && - (await continuityFingerprint(authentication.appId)) === - uniqueReferenceFingerprint(loaded.references); - if (check === "failed" || !accountMatches) { - const code: RotationFailureCode = - check === "failed" ? "invalid_credentials" : "provider_account_mismatch"; + if (checked.status === "failed") { + const code = checked.code; const race = await settleCandidateOutcome(context, loaded, code, auditIntent); if (race?.rotation.status === "success") return race; await cleanupTerminalCredential(c, candidateSecret); @@ -531,6 +569,13 @@ export async function completeRotationCandidate( ); assertCandidateSnapshot(lockedCandidate, lockedPredecessor, lockedReferences, loaded); if (lockedCandidateReferences.length > 0) throw conflict(ROTATION_UNAVAILABLE); + await assertCredentialWalletSnapshot( + context, + store, + loaded.predecessor, + wallets, + ROTATION_UNAVAILABLE + ); const changed = await store.cutOverRotation({ organizationId: context.organizationId, predecessorId: loaded.predecessor.id, @@ -563,7 +608,7 @@ export async function completeRotationCandidate( throw conflict(ROTATION_UNAVAILABLE); } await context.audit.completeCritical(c, auditIntent, { - metadata: { event: "provider_credential_rotated", provider: "privy" }, + metadata: { event: "provider_credential_rotated", provider: loaded.candidate.provider }, }); return rotationResult({ ...loaded.candidate, status: "active" }, "success"); } @@ -596,7 +641,7 @@ export async function rollbackProviderCredential( resourceId: predecessor.id, metadata: { event: "provider_credential_rollback_started", - provider: "privy", + provider: current.credential.provider, scope: current.credential.scope, fromProviderCredentialId: current.credential.id, toProviderCredentialId: predecessor.id, @@ -605,8 +650,9 @@ export async function rollbackProviderCredential( }, }); let failureCode = "credential_storage_unavailable"; + let wallets: CredentialWalletReferenceRow[] = []; try { - let authentication: PrivyCredentialAuthentication; + let authentication: ByokCredentialAuthentication; try { const predecessorSecret = await requireLifecycleCredentialSecret(context, predecessor.id); const secretStore = createPersistedSecretStore( @@ -614,7 +660,7 @@ export async function rollbackProviderCredential( predecessorSecret.storage_backend, predecessor.id ); - authentication = await readPrivyCredential(secretStore, context, predecessorSecret); + authentication = await readByokCredential(secretStore, context, predecessorSecret); } catch (error) { const refreshedCurrent = await loadAuthorizedCurrent( context, @@ -631,16 +677,24 @@ export async function rollbackProviderCredential( throw error; } failureCode = "provider_response_unknown"; - const check = await checkPrivyCredential(c.env, authentication); + if (authentication.provider === "fireblocks") { + wallets = await context.store.listCredentialWalletReferences( + context.organizationId, + current.credential.id + ); + } + const checked = await checkCredentialReplacement( + c.env, + authentication, + current.references, + wallets + ); + const check = checked.status; if (check === "retry_unknown") { throw providerUnavailable("Credential provider is temporarily unavailable"); } - failureCode = check === "failed" ? "invalid_credentials" : "provider_account_mismatch"; - if ( - check === "failed" || - (await continuityFingerprint(authentication.appId)) !== - uniqueReferenceFingerprint(current.references) - ) { + if (checked.status === "failed") { + failureCode = checked.code; throw conflict(ROLLBACK_UNAVAILABLE); } } catch (error) { @@ -671,6 +725,13 @@ export async function rollbackProviderCredential( { lock: true } ); assertRollbackSnapshot(lockedCurrent, lockedPredecessor, lockedReferences, current); + await assertCredentialWalletSnapshot( + context, + store, + current.credential, + wallets, + ROLLBACK_UNAVAILABLE + ); if ( await store.findUnfinishedDirectChild(context.organizationId, current.credential.id, { lock: true, @@ -712,7 +773,7 @@ export async function rollbackProviderCredential( throw conflict(ROLLBACK_UNAVAILABLE); } await context.audit.completeCritical(c, auditIntent, { - metadata: { event: "provider_credential_rolled_back", provider: "privy" }, + metadata: { event: "provider_credential_rolled_back", provider: current.credential.provider }, }); return { providerCredential: mapProviderCredential({ ...predecessor, status: "active" }), @@ -774,7 +835,7 @@ export async function deactivateProviderCredential( event: rotationCancellation ? "provider_credential_rotation_cancellation_started" : "provider_credential_deactivation_started", - provider: "privy", + provider: loaded.credential.provider, }, }); let applied = false; @@ -897,7 +958,7 @@ export async function deactivateProviderCredential( } await cleanupTerminalCredential(c, secret); await context.audit.completeCritical(c, auditIntent, { - metadata: { event: completedEvent, provider: "privy" }, + metadata: { event: completedEvent, provider: loaded.credential.provider }, }); return { providerCredential: mapProviderCredential({ ...loaded.credential, status: "deactivated" }), @@ -919,7 +980,7 @@ async function auditCredentialDeactivationRefusal( status: "failure", metadata: { event: "provider_credential_deactivation_blocked", - provider: "privy", + provider: target.credential.provider, scope: target.credential.scope, projectId: context.projectId, credentialStatus: target.credential.status, @@ -1351,7 +1412,7 @@ async function settleCandidateOutcome( outcome === "retry_unknown" ? "provider_credential_rotation_retry_unknown" : "provider_credential_rotation_rejected", - provider: "privy", + provider: expected.candidate.provider, failureCode: outcome === "retry_unknown" ? "provider_response_unknown" : outcome, }, }); @@ -1520,6 +1581,62 @@ async function deactivationCommitIsVisible( } } +/** One live keychain address read per distinct vault, including the primary vault. */ +async function checkCredentialReplacement( + env: Env, + authentication: ByokCredentialAuthentication, + references: readonly CredentialReferenceRow[], + wallets: readonly CredentialWalletReferenceRow[] +): Promise { + const primary = await checkByokCredential(env, authentication); + if (primary.status !== "success") + return primary.status === "failed" + ? { status: "failed", code: "invalid_credentials" } + : { status: "retry_unknown", code: "provider_response_unknown" }; + if (primary.fingerprint !== uniqueReferenceFingerprint(references)) + return { status: "failed", code: "provider_account_mismatch" }; + if (authentication.provider !== "fireblocks") return { status: "success" }; + if (!primary.wallet) return { status: "retry_unknown", code: "provider_response_unknown" }; + const addresses = new Map([[authentication.vaultAccountId, primary.wallet.address]]); + for (const wallet of wallets) { + const vaultId = denormalizeFireblocksWalletId(wallet.wallet_id); + if (!/^\d{1,100}$/.test(vaultId)) + return { status: "failed", code: "provider_account_mismatch" }; + let address = addresses.get(vaultId); + if (address === undefined) { + const checked = await checkByokCredential(env, { + ...authentication, + vaultAccountId: vaultId, + }); + if (checked.status === "failed") return { status: "failed", code: "invalid_credentials" }; + if (checked.status !== "success" || !checked.wallet) + return { status: "retry_unknown", code: "provider_response_unknown" }; + address = checked.wallet.address; + addresses.set(vaultId, address); + } + if (address !== wallet.public_key) + return { status: "failed", code: "provider_account_mismatch" }; + } + return { status: "success" }; +} + +async function assertCredentialWalletSnapshot( + context: LifecycleContext, + store: ProviderCredentialStore, + credential: LifecycleCredentialRow, + expected: readonly CredentialWalletReferenceRow[], + message: string +): Promise { + if (credential.provider !== "fireblocks") return; + // Connection rows are already locked, so imports cannot add wallets during cutover. + const current = await store.listCredentialWalletReferences( + context.organizationId, + credential.id, + { lock: true } + ); + if (JSON.stringify(current) !== JSON.stringify(expected)) throw conflict(message); +} + function uniqueReferenceFingerprint(references: readonly CredentialReferenceRow[]): string | null { const fingerprints = new Set( references.map((reference) => reference.provider_account_fingerprint) @@ -1528,10 +1645,6 @@ function uniqueReferenceFingerprint(references: readonly CredentialReferenceRow[ return fingerprints.values().next().value ?? null; } -async function continuityFingerprint(appId: string): Promise { - return getPrivyProviderAccountFingerprint(appId); -} - function rotationResult( credential: LifecycleCredentialRow, status: "success" @@ -1566,7 +1679,7 @@ function safeRotationFailureCode(value: string | null): RotationFailureCode | nu async function rotationFingerprint( context: LifecycleContext, credentialId: string, - fields: { appId: string; appSecret: string } + fields: ByokCredentialFields ): Promise { const pepper = context.c.env.CREDENTIAL_FINGERPRINT_PEPPER; if (!pepper) throw internalError(); @@ -1613,16 +1726,17 @@ async function writeRotationSecret( context: LifecycleContext, store: CredentialSecretStore, candidateId: string, - appId: string, - appSecret: string, + provider: "privy" | "fireblocks", + fields: ByokCredentialFields, + idempotencyKey: string, existingSecretRef?: string ): Promise { try { return await store.write({ orgId: context.organizationId, - provider: "privy", + provider, providerCredentialId: candidateId, - payload: { appId, appSecret }, + payload: await resolveRegistrationPayload(context.c, provider, fields, idempotencyKey), existingSecretRef, }); } catch (error) { @@ -1637,11 +1751,11 @@ async function writeRotationSecret( } } -async function readPrivyCredential( +async function readByokCredential( store: CredentialSecretStore, context: LifecycleContext, row: LifecycleCredentialWithSecretRow -): Promise { +): Promise { let payload: CredentialSecretPayload; try { payload = await store.read({ orgId: context.organizationId, stored: storedSecret(row) }); @@ -1652,10 +1766,7 @@ async function readPrivyCredential( } throw internalError(); } - const appId = typeof payload.appId === "string" ? payload.appId.trim() : ""; - const appSecret = typeof payload.appSecret === "string" ? payload.appSecret : ""; - if (!appId || !appSecret) throw internalError(); - return { appId, appSecret }; + return parseByokCredential(row.provider, payload); } function storedSecret(row: LifecycleCredentialWithSecretRow): StoredCredentialSecret { @@ -1693,9 +1804,9 @@ function logUnresolvedAuditIntent( // Keep this intent unresolved until its own transaction outcome is proven. logEvent("warn", { event: "sdp_api_credential_lifecycle_audit_unresolved", + provider: intent.entry.metadata?.provider, organization_id: context.organizationId, project_id: context.projectId, - provider: "privy", provider_credential_id: credentialId, audit_intent_id: intent.id, request_id: context.c.get("requestId"), @@ -1717,7 +1828,6 @@ function logLifecycleFailure( stage, organization_id: auth.organizationId, project_id: auth.projectId, - provider: "privy", provider_credential_id: credentialId, request_id: c.get("requestId"), ...describeError(cause), @@ -1732,7 +1842,6 @@ function logCleanupFailure( getLogger().error( { providerCredentialId: credentialId, - provider: "privy", storageBackend, requestId: c.get("requestId"), reason: "secret_cleanup_failed", @@ -1750,7 +1859,6 @@ function logSecretWriteOutcomeUnknown( getLogger().error( { providerCredentialId: credentialId, - provider: "privy", storageBackend, requestId: c.get("requestId"), reason: "secret_write_outcome_unknown", diff --git a/apps/sdp-api/src/services/provider-credential-submission.service.ts b/apps/sdp-api/src/services/provider-credential-submission.service.ts index 7e1cd956ea..447b776b3e 100644 --- a/apps/sdp-api/src/services/provider-credential-submission.service.ts +++ b/apps/sdp-api/src/services/provider-credential-submission.service.ts @@ -1,4 +1,5 @@ import { hashString } from "@sdp/payments/hash"; +import type { ByokCustodyProvider } from "@sdp/types/custody"; import type { Context } from "hono"; import { type DatabaseClient, getDb } from "@/db/client"; import { isPostgresUniqueViolation, parsePostgresJsonOr } from "@/db/postgres-utils"; @@ -14,6 +15,14 @@ import { CredentialSecretStoreError, type StoredCredentialSecret, } from "@/services/credential-secret-store"; +import { + type ByokCredentialFields, + credentialDisplayMetadata, +} from "@/services/custody/byok-credential"; +import { + resolveRegistrationPayload, + retireFireblocksCsr, +} from "@/services/custody/fireblocks-registration.service"; import { admitByokCustodySetup, refuseCustodySetup, @@ -35,21 +44,15 @@ import { import type { Env } from "@/types/env"; const UNFINISHED_INSTALLATION_MESSAGE = - "A Privy custody installation is already in progress for this project"; + "A custody installation is already in progress for this project"; const REPLACEMENT_CONFLICT_MESSAGE = "Custody Connection cannot accept replacement credentials"; -interface PrivyCredentialFields { - credentialLabel: string; - scope: "project"; - appId: string; - appSecret: string; -} - -interface SubmitPrivyCredentialInput { - provider: "privy"; +interface SubmitByokCredentialInput { + provider: ByokCustodyProvider; requestDelayMs?: number; walletLabel?: string; - fields: PrivyCredentialFields; + fireblocksVaultAccountIds?: string[]; + fields: ByokCredentialFields & { credentialLabel: string; scope: "project" }; } export interface SafeProviderCredential { @@ -61,7 +64,7 @@ export interface SafeProviderCredential { projectId: string | null; status: ProviderCredentialRow["status"]; createdAt: string; - displayMetadata: { appIdSuffix?: string }; + displayMetadata: { appIdSuffix?: string; apiKeySuffix?: string; primaryVaultId?: string }; } export interface ProviderCredentialSubmissionResult { @@ -86,13 +89,13 @@ type CompensationOutcome = "not_required" | "deferred"; type SubmissionAuditBase = { organizationId: string; userId: string; - provider: "privy"; + provider: ByokCustodyProvider; scope: "organization" | "project"; }; interface SubmissionContext { c: Context<{ Bindings: Env }>; - input: SubmitPrivyCredentialInput; + input: SubmitByokCredentialInput; idempotencyKey: string; organizationId: string; projectId: string; @@ -138,24 +141,28 @@ class SubmissionOutcomeUnknown extends Error { export async function submitProviderCredential( c: Context<{ Bindings: Env }>, - input: SubmitPrivyCredentialInput, + input: SubmitByokCredentialInput, idempotencyKey: string ): Promise { - return submitProviderCredentialIntent(c, input, idempotencyKey, null); + const result = await submitProviderCredentialIntent(c, input, idempotencyKey, null); + await retireFireblocksCsr(c, input.fields, result.providerCredential); + return result; } export async function replaceProviderCredential( c: Context<{ Bindings: Env }>, connectionId: string, - input: SubmitPrivyCredentialInput, + input: SubmitByokCredentialInput, idempotencyKey: string ): Promise { - return submitProviderCredentialIntent(c, input, idempotencyKey, connectionId); + const result = await submitProviderCredentialIntent(c, input, idempotencyKey, connectionId); + await retireFireblocksCsr(c, input.fields, result.providerCredential); + return result; } async function submitProviderCredentialIntent( c: Context<{ Bindings: Env }>, - input: SubmitPrivyCredentialInput, + input: SubmitByokCredentialInput, idempotencyKey: string, replacementConnectionId: string | null ): Promise { @@ -172,11 +179,13 @@ async function submitProviderCredentialIntent( } const fingerprint = await computeSubmissionFingerprint(context); + const { credentialLabel: _label, scope: _scope, ...fields } = input.fields; const setup = await prepareSetup(context, fingerprint); if (setup.kind === "replay") { return setup.result; } + await resolveRegistrationPayload(c, input.provider, fields, idempotencyKey); return persistPreparedSubmission({ ...context, fingerprint, @@ -186,7 +195,7 @@ async function submitProviderCredentialIntent( function createSubmissionContext( c: Context<{ Bindings: Env }>, - input: SubmitPrivyCredentialInput, + input: SubmitByokCredentialInput, idempotencyKey: string, replacementConnectionId: string | null ): SubmissionContext { @@ -464,22 +473,25 @@ async function writeSubmissionSecret( secretStore: CredentialSecretStore, existingSecretRef?: string ): Promise { - const fields = context.input.fields; + const { credentialLabel: _label, scope: _scope, ...fields } = context.input.fields; try { return await secretStore.write({ orgId: context.organizationId, provider: context.input.provider, providerCredentialId, existingSecretRef, - payload: { - appId: fields.appId, - appSecret: fields.appSecret, - }, + payload: await resolveRegistrationPayload( + context.c, + context.input.provider, + fields, + context.idempotencyKey + ), }); } catch (error) { const upstream = error instanceof CredentialSecretStoreError && error.code === "UPSTREAM_ERROR"; if (upstream && secretStore.storageBackend === "gcp_secret_manager") { logOrphanRisk({ + provider: context.input.provider, providerCredentialId, storageBackend: secretStore.storageBackend, requestId: context.c.get("requestId"), @@ -631,12 +643,12 @@ async function insertSubmissionCredential( id: submission.providerCredentialId, organizationId: submission.organizationId, projectId: submission.projectId, - provider: "privy", + provider: submission.input.provider, label: fields.credentialLabel, scope: "project", source: "stored", stored: submission.stored, - displayMetadata: fields.appId.length > 4 ? { appIdSuffix: fields.appId.slice(-4) } : {}, + displayMetadata: credentialDisplayMetadata(fields), version: plan.kind === "replacement" ? await store.nextCredentialVersion(submission.organizationId, plan.currentCredential.id) @@ -661,10 +673,12 @@ async function persistConnection( id: submission.connectionId, organizationId: submission.organizationId, projectId: submission.projectId, + provider: submission.input.provider, providerCredentialId: submission.providerCredentialId, providerCredentialScopeKey: providerCredential.scope_key, requestDelayMs: submission.input.requestDelayMs, pendingWalletLabel: submission.input.walletLabel, + pendingFireblocksVaultIds: submission.input.fireblocksVaultAccountIds, createdBy: submission.userId, }); return; @@ -677,6 +691,7 @@ async function persistConnection( providerCredentialScopeKey: providerCredential.scope_key, requestDelayMs: submission.input.requestDelayMs, pendingWalletLabel: submission.input.walletLabel, + pendingFireblocksVaultIds: submission.input.fireblocksVaultAccountIds, }); if (!updated) { throw new SetupConflict(undefined, lockedPlan.connection.id); @@ -774,7 +789,10 @@ function isUnfinishedInstallationUniqueViolation(error: unknown): boolean { typeof error === "object" && error !== null && "constraint" in error && - error.constraint === "idx_custody_connections_privy_unfinished" + [ + "idx_custody_connections_privy_unfinished", + "idx_custody_connections_fireblocks_unfinished", + ].includes(String(error.constraint ?? "")) ); } @@ -797,7 +815,7 @@ async function reconcileTransactionOutcome( async function buildProviderCredentialSubmissionFingerprint(params: { organizationId: string; projectId: string; - input: SubmitPrivyCredentialInput; + input: SubmitByokCredentialInput; pepper: string; replacementConnectionId?: string | null; }): Promise { @@ -815,6 +833,7 @@ async function buildProviderCredentialSubmissionFingerprint(params: { provider: params.input.provider, requestDelayMs: params.input.requestDelayMs, walletLabel: params.input.walletLabel, + fireblocksVaultAccountIds: params.input.fireblocksVaultAccountIds, fields: params.input.fields, }) ); @@ -876,7 +895,7 @@ async function resolveLateReplay(params: { auditBase: { organizationId: string; userId: string; - provider: "privy"; + provider: ByokCustodyProvider; scope: "organization" | "project"; }; organizationId: string; @@ -902,14 +921,15 @@ async function resolveLateReplay(params: { async function classifySetup( context: Pick< SubmissionContext, - "store" | "organizationId" | "projectId" | "replacementConnectionId" + "store" | "organizationId" | "projectId" | "replacementConnectionId" | "input" >, lock = false, creatingCredentialId?: string ): Promise { const creating = await context.store.findCreatingSubmission( context.organizationId, - context.projectId + context.projectId, + context.input.provider ); if (creating && creating.id !== creatingCredentialId) { throw new SetupConflict( @@ -921,7 +941,7 @@ async function classifySetup( const connections = await context.store.listProjectConnections( context.organizationId, context.projectId, - { lock } + { lock, provider: context.input.provider } ); const unfinished = connections.find( (connection) => connection.status === "pending" || connection.status === "checking" @@ -938,7 +958,7 @@ async function classifySetup( context.replacementConnectionId, { lock, excludedCreatingCredentialId: creatingCredentialId } ); - if (!connection) { + if (!connection || connection.provider !== context.input.provider) { throw notFound("Custody Connection"); } const nowMs = await context.store.getDatabaseNowMs(); @@ -1012,11 +1032,20 @@ export function mapProviderCredential(row: ProviderCredentialRow): SafeProviderC projectId: row.project_id, status: row.status, createdAt: row.created_at, - displayMetadata: appIdSuffix ? { appIdSuffix } : {}, + displayMetadata: { + appIdSuffix, + apiKeySuffix: + typeof storedMetadata.apiKeySuffix === "string" ? storedMetadata.apiKeySuffix : undefined, + primaryVaultId: + typeof storedMetadata.primaryVaultId === "string" + ? storedMetadata.primaryVaultId + : undefined, + }, }; } function logOrphanRisk(params: { + provider: ByokCustodyProvider; providerCredentialId: string; storageBackend: "gcp_secret_manager"; providerResourceVersion?: number; @@ -1026,7 +1055,7 @@ function logOrphanRisk(params: { getLogger().error( { providerCredentialId: params.providerCredentialId, - provider: "privy", + provider: params.provider, storageBackend: params.storageBackend, ...(params.providerResourceVersion !== undefined && { providerResourceVersion: params.providerResourceVersion, @@ -1044,7 +1073,7 @@ async function auditFailure( base: { organizationId: string; userId: string; - provider: "privy"; + provider: ByokCustodyProvider; scope: "organization" | "project"; }, failure: { diff --git a/apps/sdp-api/src/services/provider-setup-registry.ts b/apps/sdp-api/src/services/provider-setup-registry.ts index 0524f51c8a..e866ce9cc6 100644 --- a/apps/sdp-api/src/services/provider-setup-registry.ts +++ b/apps/sdp-api/src/services/provider-setup-registry.ts @@ -2,6 +2,10 @@ import type { CustodyProvider } from "@sdp/types/custody"; import type { ComplianceProviderId, RampProviderId } from "@sdp/types/provider-access"; import type { Context } from "hono"; import { z } from "zod"; +import { + fireblocksRegistrationSchema, + privyAuthenticationSchema, +} from "@/services/custody/byok-credential"; import { isProviderConfigured } from "@/services/provider-availability.service"; import { cancelProviderCredentialInstallation, @@ -53,8 +57,7 @@ const privyCredentialFieldsSchema = z .object({ credentialLabel: z.string().trim().min(1), scope: z.literal("project"), - appId: z.string().trim().min(1).max(4096), - appSecret: z.string().min(1).max(4096), + ...privyAuthenticationSchema.shape, }) .strict(); @@ -69,15 +72,43 @@ const privyCredentialSetupSchema = privyCredentialSetupBaseSchema .strict(); export type PrivyCredentialSetupPayload = z.infer; +const fireblocksCredentialSetupSchema = privyCredentialSetupBaseSchema + .extend({ + provider: z.literal("fireblocks"), + fireblocksVaultAccountIds: z + .array(z.string().regex(/^\d{1,100}$/)) + .min(1) + .max(20) + .optional(), + fields: privyCredentialFieldsSchema + .omit({ appId: true, appSecret: true }) + .extend(fireblocksRegistrationSchema.shape) + .strict(), + }) + .strict() + .refine( + (input) => + !input.fireblocksVaultAccountIds || + (input.fireblocksVaultAccountIds.includes(input.fields.vaultAccountId) && + new Set(input.fireblocksVaultAccountIds).size === input.fireblocksVaultAccountIds.length), + "Select distinct vaults including the connection's signing vault" + ); +const custodyCredentialSetupSchema = z.discriminatedUnion("provider", [ + privyCredentialSetupSchema, + fireblocksCredentialSetupSchema, +]); +export function validateCustodySetupPayload(payload: unknown) { + return custodyCredentialSetupSchema.safeParse(payload); +} -interface PrivyStoreCredentialsInput { +interface CustodyStoreCredentialsInput { context: AppContext; idempotencyKey: string; - payload: PrivyCredentialSetupPayload; + payload: z.infer; connectionId?: string; } -interface PrivyConnectionOperationInput { +interface CustodyConnectionOperationInput { context: AppContext; connectionId: string; } @@ -86,7 +117,7 @@ function validatePrivySetupPayload(payload: unknown) { return privyCredentialSetupSchema.safeParse(payload); } -async function storePrivyCredentials(input: PrivyStoreCredentialsInput) { +export async function storeCustodyCredentials(input: CustodyStoreCredentialsInput) { if (input.connectionId) { return replaceProviderCredential( input.context, @@ -154,16 +185,27 @@ function rampSetup(provider: Provider) { export const PROVIDER_SETUP_REGISTRY = { custody: { local: { family: "custody", provider: "local", setupMode: "platform_managed" }, - fireblocks: { family: "custody", provider: "fireblocks", setupMode: "contact" }, + fireblocks: { + family: "custody", + provider: "fireblocks", + setupMode: "self_service", + validateSetupPayload: (payload: unknown) => + fireblocksCredentialSetupSchema.safeParse(payload), + storeCredentials: storeCustodyCredentials, + activate: ({ context, connectionId }: CustodyConnectionOperationInput) => + completeProviderCredentialInstallation(context, connectionId), + deactivate: ({ context, connectionId }: CustodyConnectionOperationInput) => + cancelProviderCredentialInstallation(context, connectionId), + }, privy: { family: "custody", provider: "privy", setupMode: "self_service", validateSetupPayload: validatePrivySetupPayload, - storeCredentials: storePrivyCredentials, - activate: ({ context, connectionId }: PrivyConnectionOperationInput) => + storeCredentials: storeCustodyCredentials, + activate: ({ context, connectionId }: CustodyConnectionOperationInput) => completeProviderCredentialInstallation(context, connectionId), - deactivate: ({ context, connectionId }: PrivyConnectionOperationInput) => + deactivate: ({ context, connectionId }: CustodyConnectionOperationInput) => cancelProviderCredentialInstallation(context, connectionId), }, coinbase_cdp: { diff --git a/apps/sdp-api/src/services/stores/provider-credential-secret-cleanup.store.ts b/apps/sdp-api/src/services/stores/provider-credential-secret-cleanup.store.ts index 7add24acaf..bf6b4a5f17 100644 --- a/apps/sdp-api/src/services/stores/provider-credential-secret-cleanup.store.ts +++ b/apps/sdp-api/src/services/stores/provider-credential-secret-cleanup.store.ts @@ -13,6 +13,7 @@ export interface RetainedProviderCredentialSecretRow { export interface CredentialSecretContainerRow { id: string; + provider: "privy" | "fireblocks"; organization_id: string; secret_ref: string; } @@ -68,8 +69,8 @@ export class ProviderCredentialSecretCleanupStore { async listDueContainers(now: Date, limit: number): Promise { return this.db.queryMany( - `SELECT id, organization_id, secret_ref FROM provider_credentials - WHERE provider = 'privy' AND source = 'stored' AND storage_backend = 'gcp_secret_manager' + `SELECT id, provider, organization_id, secret_ref FROM provider_credentials + WHERE provider IN ('privy', 'fireblocks') AND source = 'stored' AND storage_backend = 'gcp_secret_manager' AND secret_next_scan_at <= ?::timestamptz ORDER BY secret_next_scan_at, id LIMIT ?`, [now.toISOString(), limit] @@ -82,7 +83,7 @@ export class ProviderCredentialSecretCleanupStore { (await this.db.execute( `UPDATE provider_credentials SET secret_next_scan_at = ?::timestamptz WHERE id = ? AND organization_id = ? AND secret_ref = ? - AND provider = 'privy' AND source = 'stored' AND storage_backend = 'gcp_secret_manager' + AND provider IN ('privy', 'fireblocks') AND source = 'stored' AND storage_backend = 'gcp_secret_manager' AND secret_next_scan_at <= ?::timestamptz`, [next, owner.id, owner.organization_id, owner.secret_ref, now.toISOString()] )) === 1 @@ -96,7 +97,7 @@ export class ProviderCredentialSecretCleanupStore { deactivated_at = sdp_iso_now(), last_failed_at = sdp_iso_now(), last_failure_code = 'secret_creation_abandoned', secret_retention_expires_at = sdp_iso_now(), updated_at = sdp_iso_now() - WHERE pc.organization_id = ? AND pc.secret_ref = ? AND pc.provider = 'privy' + WHERE pc.organization_id = ? AND pc.secret_ref = ? AND pc.provider IN ('privy', 'fireblocks') AND pc.source = 'stored' AND pc.storage_backend = 'gcp_secret_manager' AND pc.status = 'creating' AND pc.created_at::timestamptz <= clock_timestamp() - interval '2 minutes' @@ -121,7 +122,7 @@ export class ProviderCredentialSecretCleanupStore { ): Promise { return this.db.queryMany( `SELECT ${COLUMNS} FROM provider_credentials pc - WHERE pc.organization_id = ? AND pc.secret_ref = ? AND pc.provider = 'privy' + WHERE pc.organization_id = ? AND pc.secret_ref = ? AND pc.provider IN ('privy', 'fireblocks') AND pc.source = 'stored' AND pc.storage_backend = 'gcp_secret_manager' AND pc.secret_version_ref IS NOT NULL AND pc.secret_retention_expires_at IS NOT NULL AND ${GCP_TERMINAL} AND ${GCP_UNREFERENCED} @@ -136,7 +137,7 @@ export class ProviderCredentialSecretCleanupStore { }): Promise { return this.db.queryOne( `UPDATE provider_credentials pc SET updated_at = sdp_iso_now() - WHERE pc.id = ? AND pc.provider = 'privy' AND pc.source = 'stored' + WHERE pc.id = ? AND pc.provider IN ('privy', 'fireblocks') AND pc.source = 'stored' AND pc.storage_backend = 'gcp_secret_manager' AND pc.secret_version_ref = ? AND ${GCP_TERMINAL} AND ${GCP_UNREFERENCED} AND NOT EXISTS (SELECT 1 FROM provider_credentials other @@ -158,7 +159,7 @@ export class ProviderCredentialSecretCleanupStore { (await this.db.execute( `UPDATE provider_credentials pc SET secret_retention_expires_at = NULL, secret_cleanup_next_attempt_at = NULL, updated_at = sdp_iso_now() - WHERE pc.id = ? AND pc.provider = 'privy' AND pc.source = 'stored' + WHERE pc.id = ? AND pc.provider IN ('privy', 'fireblocks') AND pc.source = 'stored' AND pc.storage_backend = 'gcp_secret_manager' AND pc.secret_version_ref = ? AND pc.secret_retention_expires_at = ? AND ${GCP_TERMINAL} AND ${GCP_UNREFERENCED}`, [params.id, params.expectedSecretVersionRef, params.expectedRetentionExpiresAt] @@ -181,7 +182,7 @@ export class ProviderCredentialSecretCleanupStore { secret_cleanup_outcome = CASE WHEN secret_version_ref IS NULL THEN NULL ELSE secret_cleanup_outcome END, secret_cleanup_absent_since = CASE WHEN secret_version_ref IS NULL THEN NULL ELSE secret_cleanup_absent_since END, secret_version_ref = COALESCE(secret_version_ref, ?), updated_at = sdp_iso_now() - WHERE id = ? AND secret_ref = ? AND provider = 'privy' AND source = 'stored' + WHERE id = ? AND secret_ref = ? AND provider IN ('privy', 'fireblocks') AND source = 'stored' AND storage_backend = 'gcp_secret_manager' AND status = 'deactivated' AND last_failure_code = 'secret_creation_abandoned' AND (secret_version_ref IS NULL OR secret_version_ref = ?)`, diff --git a/apps/sdp-api/src/services/stores/provider-credential.store.ts b/apps/sdp-api/src/services/stores/provider-credential.store.ts index 15f3eca852..28fd01c6e4 100644 --- a/apps/sdp-api/src/services/stores/provider-credential.store.ts +++ b/apps/sdp-api/src/services/stores/provider-credential.store.ts @@ -33,7 +33,7 @@ export interface ProviderCredentialRow { } export interface LifecycleCredentialRow extends ProviderCredentialRow { - provider: "privy"; + provider: "privy" | "fireblocks"; source: "stored" | "runtime"; storage_backend: StoredCredentialSecret["storageBackend"]; deactivated_at: string | null; @@ -56,11 +56,18 @@ export interface CredentialReferenceRow { provider_account_fingerprint: string | null; } +export interface CredentialWalletReferenceRow { + id: string; + custody_connection_id: string; + wallet_id: string; + public_key: string; +} + export interface CustodyConnectionRow { id: string; organization_id: string; project_id: string; - provider: "privy"; + provider: "privy" | "fireblocks"; scope: "project"; provider_credential_id: string; provider_credential_scope_key: string; @@ -78,7 +85,7 @@ export interface CustodyConnectionRow { export interface ProjectConnectionListRow { id: string; - provider: "privy"; + provider: "privy" | "fireblocks"; connection_status: CustodyConnectionStatus; setup_metadata: unknown; last_check_status: CustodyConnectionCheckStatus | null; @@ -123,14 +130,14 @@ const CREATING_SUBMISSION = `creating.status = 'creating' const CREDENTIAL_LINEAGE_SQL = `WITH RECURSIVE credential_lineage(id, rotated_from_provider_credential_id, credential_version) AS ( SELECT id, rotated_from_provider_credential_id, credential_version FROM provider_credentials - WHERE id = ? AND organization_id = ? AND provider = 'privy' + WHERE id = ? AND organization_id = ? AND provider IN ('privy', 'fireblocks') UNION SELECT related.id, related.rotated_from_provider_credential_id, related.credential_version FROM provider_credentials related JOIN credential_lineage member ON related.rotated_from_provider_credential_id = member.id OR related.id = member.rotated_from_provider_credential_id - WHERE related.organization_id = ? AND related.provider = 'privy' + WHERE related.organization_id = ? AND related.provider IN ('privy', 'fireblocks') )`; export class ProviderCredentialStore { @@ -175,7 +182,7 @@ export class ProviderCredentialStore { async listProjectConnections( organizationId: string, projectId: string, - options: { lock?: boolean } = {} + options: { lock?: boolean; provider?: CustodyProvider } = {} ): Promise { return this.db.queryMany( `SELECT c.id, c.organization_id, c.project_id, c.provider, c.scope, @@ -192,10 +199,11 @@ export class ProviderCredentialStore { JOIN provider_credentials pc ON pc.id = c.provider_credential_id WHERE c.organization_id = ? AND c.project_id = ? - AND c.provider = 'privy' + AND c.provider IN ('privy', 'fireblocks') + ${options.provider ? "AND c.provider = ?" : ""} ORDER BY c.created_at, c.id ${options.lock ? "FOR UPDATE OF c" : ""}`, - [organizationId, projectId] + [organizationId, projectId, ...(options.provider ? [options.provider] : [])] ); } @@ -227,7 +235,7 @@ export class ProviderCredentialStore { idempotency_fingerprint, deactivated_at, last_validated_at, last_failed_at, last_failure_code, secret_retention_expires_at, created_at FROM provider_credentials - WHERE id = ? AND organization_id = ? AND provider = 'privy' + WHERE id = ? AND organization_id = ? AND provider IN ('privy', 'fireblocks') ${options.lock ? "FOR UPDATE" : ""}`, [id, organizationId] ); @@ -245,7 +253,7 @@ export class ProviderCredentialStore { idempotency_fingerprint, deactivated_at, last_validated_at, last_failed_at, last_failure_code, secret_retention_expires_at, created_at FROM provider_credentials - WHERE id = ? AND organization_id = ? AND provider = 'privy'`, + WHERE id = ? AND organization_id = ? AND provider IN ('privy', 'fireblocks')`, [id, organizationId] ); } @@ -253,7 +261,7 @@ export class ProviderCredentialStore { async findGcpContainerRef(organizationId: string, credentialId: string): Promise { const row = await this.db.queryOne<{ secret_ref: string }>( `SELECT secret_ref FROM provider_credentials - WHERE id = ? AND organization_id = ? AND provider = 'privy' + WHERE id = ? AND organization_id = ? AND provider IN ('privy', 'fireblocks') AND source = 'stored' AND storage_backend = 'gcp_secret_manager' AND secret_ref IS NOT NULL AND secret_version_ref IS NOT NULL`, [credentialId, organizationId] @@ -269,7 +277,7 @@ export class ProviderCredentialStore { const row = await this.db.queryOne<{ provider_credential_id: string }>( `SELECT provider_credential_id FROM custody_connections - WHERE id = ? AND organization_id = ? AND project_id = ? AND provider = 'privy'`, + WHERE id = ? AND organization_id = ? AND project_id = ? AND provider IN ('privy', 'fireblocks')`, [connectionId, organizationId, projectId] ); return row?.provider_credential_id ?? null; @@ -286,7 +294,7 @@ export class ProviderCredentialStore { FROM custody_connections c JOIN projects p ON p.id = c.project_id WHERE c.organization_id = ? - AND c.provider = 'privy' + AND c.provider IN ('privy', 'fireblocks') AND c.provider_credential_id = ? AND c.status <> 'deactivated' ORDER BY c.project_id, c.id @@ -307,13 +315,31 @@ export class ProviderCredentialStore { JOIN credential_lineage lineage ON lineage.id = c.provider_credential_id JOIN projects p ON p.id = c.project_id WHERE c.organization_id = ? - AND c.provider = 'privy' + AND c.provider IN ('privy', 'fireblocks') AND c.status <> 'deactivated' ORDER BY c.project_id, c.id`, [providerCredentialId, organizationId, organizationId, organizationId] ); } + /** Include inactive wallets: they retain this credential and can be reactivated. */ + async listCredentialWalletReferences( + organizationId: string, + providerCredentialId: string, + options: { lock?: boolean } = {} + ): Promise { + return this.db.queryMany( + `SELECT w.id, w.custody_connection_id, w.wallet_id, w.public_key + FROM custody_connections c + JOIN custody_wallets w ON w.custody_connection_id = c.id + WHERE c.organization_id = ? AND c.provider = 'fireblocks' + AND c.provider_credential_id = ? AND c.status <> 'deactivated' + ORDER BY w.id + ${options.lock ? "FOR UPDATE OF w" : ""}`, + [organizationId, providerCredentialId] + ); + } + async lockAuthorizedProjects( organizationId: string, userId: string, @@ -359,7 +385,7 @@ export class ProviderCredentialStore { last_failed_at, last_failure_code, secret_retention_expires_at, created_at FROM provider_credentials WHERE organization_id = ? - AND provider = 'privy' + AND provider IN ('privy', 'fireblocks') AND rotated_from_provider_credential_id = ? AND status IN ('creating', 'pending') ORDER BY created_at, id @@ -416,7 +442,7 @@ export class ProviderCredentialStore { `UPDATE custody_connections SET provider_credential_id = ?, provider_credential_scope_key = ?, updated_at = sdp_iso_now() WHERE organization_id = ? - AND provider = 'privy' + AND provider IN ('privy', 'fireblocks') AND provider_credential_id = ? AND status <> 'deactivated'`, [params.candidateId, params.candidateScopeKey, params.organizationId, params.predecessorId] @@ -476,7 +502,7 @@ export class ProviderCredentialStore { `UPDATE custody_connections SET provider_credential_id = ?, provider_credential_scope_key = ?, updated_at = sdp_iso_now() WHERE organization_id = ? - AND provider = 'privy' + AND provider IN ('privy', 'fireblocks') AND provider_credential_id = ? AND status <> 'deactivated'`, [params.predecessorId, params.predecessorScopeKey, params.organizationId, params.currentId] @@ -509,7 +535,7 @@ export class ProviderCredentialStore { if (connectionIds.length === 0) return; await this.db.queryMany<{ id: string }>( `SELECT id FROM custody_connections - WHERE organization_id = ? AND provider = 'privy' + WHERE organization_id = ? AND provider IN ('privy', 'fireblocks') AND id IN (SELECT jsonb_array_elements_text(?::jsonb)) ORDER BY project_id, id FOR UPDATE`, [organizationId, JSON.stringify(connectionIds)] @@ -520,7 +546,7 @@ export class ProviderCredentialStore { const row = await this.db.queryOne<{ id: string }>( `SELECT w.id FROM custody_connections c JOIN custody_wallets w ON w.custody_connection_id = c.id - WHERE c.organization_id = ? AND c.provider = 'privy' + WHERE c.organization_id = ? AND c.provider IN ('privy', 'fireblocks') AND c.provider_credential_id = ? AND w.status = 'active' LIMIT 1`, [organizationId, credentialId] @@ -547,7 +573,7 @@ export class ProviderCredentialStore { deactivated_at = sdp_iso_now(), updated_at = sdp_iso_now() WHERE candidate.id = ? AND candidate.organization_id = ? - AND candidate.provider = 'privy' + AND candidate.provider IN ('privy', 'fireblocks') AND candidate.status = ? AND candidate.rotated_from_provider_credential_id IS NOT DISTINCT FROM ? AND NOT EXISTS ( @@ -626,7 +652,7 @@ export class ProviderCredentialStore { WHERE id = ? AND organization_id = ? AND project_id = ? - AND provider = 'privy' + AND provider IN ('privy', 'fireblocks') AND scope = 'project' UNION SELECT next.id @@ -635,7 +661,7 @@ export class ProviderCredentialStore { ON next.rotated_from_provider_credential_id = previous.id WHERE next.organization_id = ? AND next.project_id = ? - AND next.provider = 'privy' + AND next.provider IN ('privy', 'fireblocks') AND next.scope = 'project' ) SELECT DISTINCT c.id @@ -643,7 +669,7 @@ export class ProviderCredentialStore { JOIN credential_lineage lineage ON lineage.id = c.provider_credential_id WHERE c.organization_id = ? AND c.project_id = ? - AND c.provider = 'privy' + AND c.provider IN ('privy', 'fireblocks') ORDER BY c.id`, [ providerCredentialId, @@ -667,7 +693,7 @@ export class ProviderCredentialStore { `SELECT w.id FROM custody_wallets w JOIN custody_connections c ON c.id = w.custody_connection_id WHERE c.id = ? AND c.organization_id = ? AND c.project_id = ? - AND c.provider = 'privy' AND w.status = 'active' + AND c.provider IN ('privy', 'fireblocks') AND w.status = 'active' LIMIT 1`, [connectionId, organizationId, projectId] )); @@ -686,7 +712,7 @@ export class ProviderCredentialStore { `UPDATE custody_connections c SET status = 'deactivated', deactivated_at = sdp_iso_now(), updated_at = sdp_iso_now() WHERE c.id = ? AND c.organization_id = ? AND c.project_id = ? - AND c.provider = 'privy' AND c.provider_credential_id = ? + AND c.provider IN ('privy', 'fireblocks') AND c.provider_credential_id = ? AND c.status = ? AND c.status IN ('failed', 'active') AND EXISTS ( SELECT 1 FROM provider_credentials pc @@ -759,7 +785,7 @@ export class ProviderCredentialStore { WHERE c.id = ? AND c.organization_id = ? AND c.project_id = ? - AND c.provider = 'privy' + AND c.provider IN ('privy', 'fireblocks') ${options.lock ? "FOR UPDATE OF c, pc" : ""}`, [options.excludedCreatingCredentialId ?? null, connectionId, organizationId, projectId] ); @@ -849,21 +875,27 @@ export class ProviderCredentialStore { providerWalletId: string; publicKey: string; label?: string; + additionalWallets?: Array<{ walletId: string; address: string; label?: string }>; }): Promise { const custodyWalletId = `cwlt_${crypto.randomUUID()}`; - await this.db.execute( - `INSERT INTO custody_wallets ( + const wallets = [ + { walletId: params.providerWalletId, address: params.publicKey, label: params.label }, + ...(params.additionalWallets ?? []), + ]; + for (const [index, wallet] of wallets.entries()) + await this.db.execute( + `INSERT INTO custody_wallets ( id, custody_config_id, custody_connection_id, wallet_id, public_key, label, status, updated_at ) VALUES (?, NULL, ?, ?, ?, ?, 'active', sdp_iso_now())`, - [ - custodyWalletId, - params.connectionId, - params.providerWalletId, - params.publicKey, - params.label ?? null, - ] - ); + [ + index === 0 ? custodyWalletId : `cwlt_${crypto.randomUUID()}`, + params.connectionId, + wallet.walletId, + wallet.address, + wallet.label ?? null, + ] + ); // Write-only: default_custody_wallet_id is set solely to satisfy // custody_connections_active_lifecycle_check until HOO-1985 drops it; nothing reads it. @@ -871,7 +903,7 @@ export class ProviderCredentialStore { `UPDATE custody_connections SET default_custody_wallet_id = ?, status = 'active', - setup_metadata = setup_metadata - 'pendingWalletLabel', + setup_metadata = setup_metadata - 'pendingWalletLabel' - 'pendingFireblocksVaultIds', last_check_status = 'success', last_check_failure_code = NULL, activated_at = sdp_iso_now(), @@ -1109,7 +1141,8 @@ export class ProviderCredentialStore { async findCreatingSubmission( organizationId: string, - projectId: string + projectId: string, + provider: CustodyProvider = "privy" ): Promise { return this.db.queryOne( `SELECT creating.id, creating.organization_id, creating.project_id, creating.provider, @@ -1118,10 +1151,10 @@ export class ProviderCredentialStore { creating.idempotency_key, creating.idempotency_fingerprint, creating.last_failure_code, creating.created_at FROM provider_credentials creating - WHERE creating.organization_id = ? AND creating.project_id = ? AND creating.provider = 'privy' + WHERE creating.organization_id = ? AND creating.project_id = ? AND creating.provider = ? AND ${CREATING_SUBMISSION} ORDER BY creating.created_at, creating.id LIMIT 1`, - [organizationId, projectId] + [organizationId, projectId, provider] ); } @@ -1135,7 +1168,7 @@ export class ProviderCredentialStore { return this.db.queryOne( `UPDATE provider_credentials SET status = 'pending', secret_version_ref = ?, updated_at = sdp_iso_now() - WHERE id = ? AND organization_id = ? AND provider = 'privy' + WHERE id = ? AND organization_id = ? AND provider IN ('privy', 'fireblocks') AND status = 'creating' AND source = 'stored' AND storage_backend = 'gcp_secret_manager' AND secret_ref = ? AND secret_version_ref IS NULL RETURNING id, organization_id, project_id, provider, label, scope, scope_key, @@ -1156,7 +1189,7 @@ export class ProviderCredentialStore { SET status = 'deactivated', deactivated_at = sdp_iso_now(), last_failed_at = sdp_iso_now(), last_failure_code = 'secret_creation_abandoned', secret_retention_expires_at = sdp_iso_now(), updated_at = sdp_iso_now() - WHERE pc.id = ? AND pc.organization_id = ? AND pc.provider = 'privy' + WHERE pc.id = ? AND pc.organization_id = ? AND pc.provider IN ('privy', 'fireblocks') AND pc.status = 'creating' AND pc.storage_backend = 'gcp_secret_manager' AND NOT EXISTS ( SELECT 1 FROM custody_connections c @@ -1171,10 +1204,12 @@ export class ProviderCredentialStore { id: string; organizationId: string; projectId: string; + provider?: "privy" | "fireblocks"; providerCredentialId: string; providerCredentialScopeKey: string; requestDelayMs?: number; pendingWalletLabel?: string; + pendingFireblocksVaultIds?: string[]; createdBy: string; }): Promise { const row = await this.db.queryOne( @@ -1182,7 +1217,7 @@ export class ProviderCredentialStore { id, organization_id, project_id, provider, scope, provider_credential_id, provider_credential_scope_key, request_delay_ms, setup_metadata, status, created_by - ) VALUES (?, ?, ?, 'privy', 'project', ?, ?, ?, ?, 'pending', ?) + ) VALUES (?, ?, ?, ?, 'project', ?, ?, ?, ?, 'pending', ?) RETURNING id, organization_id, project_id, provider, scope, provider_credential_id, provider_credential_scope_key, provider_account_fingerprint, @@ -1194,12 +1229,14 @@ export class ProviderCredentialStore { params.id, params.organizationId, params.projectId, + params.provider ?? "privy", params.providerCredentialId, params.providerCredentialScopeKey, params.requestDelayMs ?? null, - JSON.stringify( - params.pendingWalletLabel ? { pendingWalletLabel: params.pendingWalletLabel } : {} - ), + JSON.stringify({ + pendingWalletLabel: params.pendingWalletLabel || undefined, + pendingFireblocksVaultIds: params.pendingFireblocksVaultIds, + }), params.createdBy, ] ); @@ -1216,6 +1253,7 @@ export class ProviderCredentialStore { providerCredentialScopeKey: string; requestDelayMs?: number; pendingWalletLabel?: string; + pendingFireblocksVaultIds?: string[]; }): Promise { return this.db.queryOne( `UPDATE custody_connections @@ -1261,9 +1299,10 @@ export class ProviderCredentialStore { params.providerCredentialId, params.providerCredentialScopeKey, params.requestDelayMs ?? null, - JSON.stringify( - params.pendingWalletLabel ? { pendingWalletLabel: params.pendingWalletLabel } : {} - ), + JSON.stringify({ + pendingWalletLabel: params.pendingWalletLabel || undefined, + pendingFireblocksVaultIds: params.pendingFireblocksVaultIds, + }), params.id, params.expectedProviderCredentialId, ] @@ -1275,3 +1314,17 @@ export function getPendingWalletLabel(value: unknown): string | undefined { const label = parsePostgresJsonOr>(value, {}).pendingWalletLabel; return typeof label === "string" ? label : undefined; } + +export function getPendingFireblocksVaultIds(value: unknown): string[] | undefined { + const ids = parsePostgresJsonOr>(value, {}).pendingFireblocksVaultIds; + if (ids === undefined) return undefined; + if ( + !Array.isArray(ids) || + ids.length < 1 || + ids.length > 20 || + !ids.every((id): id is string => typeof id === "string" && /^\d{1,100}$/.test(id)) || + new Set(ids).size !== ids.length + ) + throw new Error("Invalid pending Fireblocks selection"); + return ids; +} diff --git a/apps/sdp-api/src/test/mocks/db.ts b/apps/sdp-api/src/test/mocks/db.ts index a75c507805..7b2f3d1e77 100644 --- a/apps/sdp-api/src/test/mocks/db.ts +++ b/apps/sdp-api/src/test/mocks/db.ts @@ -48,6 +48,7 @@ const POSTGRES_TEST_TABLES = [ "custody_connections", "provider_credentials", "secret_retirements", + "fireblocks_csr_requests", "custody_wallets", "custody_configs", "payment_recurring_payment_update_events", diff --git a/apps/sdp-web/messages/en/dashboard-custody.json b/apps/sdp-web/messages/en/dashboard-custody.json index 190be3b8a5..c15e2f1cb0 100644 --- a/apps/sdp-web/messages/en/dashboard-custody.json +++ b/apps/sdp-web/messages/en/dashboard-custody.json @@ -303,7 +303,7 @@ "providerPrivyAppSecret": "Privy app secret", "providerPrivyAppSecretDescription": "Enter the app secret from your Privy dashboard. After saving, this secret cannot be viewed again.", "providerInitialWalletLabel": "Initial wallet label", - "providerInitialWalletLabelDescription": "Optionally name the default wallet created when the connection check succeeds.", + "providerInitialWalletLabelDescription": "Optionally name the first wallet linked to this connection.", "apiKeyAdminDescription": "Admin includes custody and platform-level privileges. Developer excludes custody actions.", "apiKeyEnvironmentDescription": "Switch the workspace environment in the sidebar to create a key in the other environment.", "expiresOn": "Expires {date}", @@ -406,9 +406,9 @@ "byokProviderDetails": "Provider details", "byokConnect": "Connect and verify", "byokChecking": "Verifying credentials…", - "byokInvalidCredentials": "Privy rejected these credentials. Check the app ID and secret, then connect again.", - "byokAccountAlreadyConnected": "This Privy app is already connected to a custody setup. Use a different app, then connect again.", - "byokWalletConflict": "This connection is tied to a Privy wallet that cannot be reconciled. Replacing the credentials will not fix it; contact support to resolve the connection.", + "byokInvalidCredentials": "The provider rejected these credentials. Check the credential fields, then connect again.", + "byokAccountAlreadyConnected": "This provider account is already connected to a custody setup. Use a different account, then connect again.", + "byokWalletConflict": "This connection is tied to a wallet that cannot be reconciled. Contact support to resolve the connection.", "byokCheckFailed": "Verification did not complete. Connect again to retry.", "byokRetryUnknown": "We could not confirm whether verification finished. Your credentials are stored; checking again is safe and will not create a duplicate.", "byokCheckAgain": "Check again", @@ -445,7 +445,7 @@ "connectionCount": "{count} connections", "connectionCountInProject": "{count} connections in {project}", "connectionsEmptyInProject": "No connections in {project} yet", - "addConnectionDescription": "Connect a Privy app to this project.", + "addConnectionDescription": "Connect a provider account to this project.", "connectionSigningPaused": "Signing is disabled", "signingPausedTitle": "Signing through your own credentials is currently not allowed for this organization.", "signingPausedBody": "Connections, wallets and history stay visible. You can still rotate, roll back and deactivate. New signing requests through these connections fail until it is allowed again. Nothing has been deleted.", @@ -484,7 +484,7 @@ "rotateSuccessTitle": "Credentials rotated", "rotateSuccessDescription": "{connections} connections in {projects} projects switched.", "rotateFailedTitle": "Rotation not applied", - "rotateFailed": "Privy rejected the new credentials. The current credentials remain in use; nothing changed.", + "rotateFailed": "The provider rejected the new credentials. The current credentials remain in use.", "rotateUnknownTitle": "Result unknown", "rotateRetryHint": "The result is unknown. Retry this rotation to confirm its outcome. Closing this dialog does not cancel the rotation.", "rotateResumeHint": "The rotation result is still unknown. Resume the rotation to check its outcome.", @@ -517,11 +517,11 @@ "deactivateConnectionConfirm": "Deactivate connection", "deactivateConnectionTitle": "Deactivate “{label}”?", "deactivateBlockedTitle": "Not possible right now.", - "deactivateConnectionBlockedBody": "{count} wallets are still active on this connection. Privy wallets can't be deactivated from SDP yet, so this connection stays active.", - "deactivateConnectionBlockedExplainer": "When it is possible: deactivation is permanent, keeps all history and wallet records, and does not revoke the App Secret at Privy — do that in your Privy dashboard if needed. It is also not the same as cancelling an unfinished setup.", + "deactivateConnectionBlockedBody": "{count} wallets are still active on this connection. This provider does not support wallet deactivation from SDP, so the connection stays active.", + "deactivateConnectionBlockedExplainer": "Deactivation is permanent and keeps all history and wallet records. It does not revoke credentials at the provider or cancel an unfinished setup.", "deactivateConnectionExplainer": "This connection stops permanently. The record and its history stay visible as Deactivated. To connect this account again later, add a new connection.", "deactivateConnectionPointIrreversible": "Cannot be undone.", - "deactivateConnectionPointProviderKey": "Does not revoke the App Secret at Privy.", + "deactivateConnectionPointProviderKey": "Does not revoke credentials at the provider.", "deactivateBlockedByWallets": "Unavailable while {count} wallets are active on this connection.", "deactivateNotApplicableUnfinished": "Not applicable: use Cancel setup above while setup is unfinished.", "deactivateConnectionSuccessTitle": "Connection deactivated", @@ -533,7 +533,7 @@ "deactivateCredentialsTitle": "Deactivate “{label}” credentials?", "credentialsStillInUseTitle": "Still in use.", "credentialsStillInUseBody": "{count} connections reference these credentials, in {projects}. Deactivate or move those connections first.", - "deactivateCredentialsExplainer": "Deactivation permanently disables these credentials in SDP and starts removing the stored secret. Use it when no connections reference these credentials. It does not revoke the key at Privy.", + "deactivateCredentialsExplainer": "Deactivation permanently disables these credentials in SDP and starts removing the stored secret. Use it when no connections reference these credentials. It does not revoke credentials at the provider.", "deactivateCredentialsSuccessTitle": "Credentials deactivated", "deactivateCredentialsSuccessDescription": "These credentials can no longer be used for signing through SDP.", "deactivateCredentialsFailedTitle": "Credentials not deactivated", @@ -551,24 +551,24 @@ "addWalletTitle": "Add wallet", "addWalletNameLabel": "Wallet name", "addWalletNamePlaceholder": "e.g. Payouts EU", - "addWalletHint": "Created in this connection's Privy account. If the result is unknown, SDP will not retry automatically — check the wallet list before trying again.", + "addWalletHint": "Created in this connection's provider account. If the result is unknown, SDP will not retry automatically. Check the wallet list before trying again.", "addWalletConfirm": "Create wallet", "addWalletSuccessTitle": "Wallet created", "addWalletSuccessDescription": "It is listed on this connection.", "addWalletFailedTitle": "Wallet not created", "addWalletFailed": "The wallet could not be created.", "addWalletUnknownTitle": "Result unknown", - "outcomeInvalidTitle": "Privy rejected these credentials.", - "outcomeInvalidBody": "Check the app ID and secret, then connect again.", - "outcomeMismatchTitle": "These credentials belong to a different Privy account.", + "outcomeInvalidTitle": "The provider rejected these credentials.", + "outcomeInvalidBody": "Check the credential fields, then connect again.", + "outcomeMismatchTitle": "These credentials belong to a different provider account or vault.", "outcomeMismatchBody": "Rotation only replaces credentials for the same account, so existing wallets keep working. To use another account, add a new connection instead.", - "outcomeTemporaryTitle": "Privy did not answer in time.", + "outcomeTemporaryTitle": "The provider did not answer in time.", "outcomeTemporaryBody": "This is a connection problem, not a rejection. Your credentials are stored; checking again is safe and will not create a duplicate.", "outcomeConflictTitle": "This connection changed while you were working.", "outcomeConflictBody": "Someone else updated it. Nothing was applied. Reload to see the current state before trying again.", "outcomeUnknownTitle": "We could not confirm whether verification finished.", "outcomeUnknownBody": "Your credentials are stored; checking again is safe and will not create a duplicate.", - "outcomeWalletConflictTitle": "This connection is tied to a Privy wallet that cannot be reconciled.", + "outcomeWalletConflictTitle": "This connection is tied to a wallet that cannot be reconciled.", "outcomeWalletConflictBody": "Replacing the credentials will not fix it; contact support to resolve the connection.", "outcomeReload": "Reload", "outcomeCheckCurrentState": "Check current state", @@ -743,6 +743,47 @@ "label": "Helius Rings", "description": "Rings wallets and operations." } - } + }, + "providerFireblocksApiKey": "API user ID", + "providerFireblocksApiKeyDescription": "Available in Developer Center → API Users after your new API user is approved.", + "providerFireblocksVaultAccountId": "Vault account ID", + "providerFireblocksVaultAccountIdDescription": "Use an existing vault with a Solana wallet. Sandbox projects use SOL_TEST; production projects use SOL. Keep this vault when rotating credentials.", + "providerFireblocksSigningDescription": "Allow this API user to propose TRANSFER payments under your Fireblocks approval and destination policies.", + "fireblocksApiKeyPlaceholder": "API user ID from Fireblocks", + "fireblocksRegisterTitle": "1. Register SDP in Fireblocks", + "fireblocksRegisterDescription": "Download the CSR for your organization, then register it in the Fireblocks workspace you want to connect.", + "fireblocksCsrGenerate": "Generate and download CSR", + "fireblocksCsrDownload": "Download CSR", + "fireblocksCsrGenerating": "Generating CSR…", + "fireblocksKeyStorage": "SDP securely stores the private key. Only the CSR is shared with Fireblocks.", + "fireblocksSetupGuide": "Fireblocks setup guide", + "fireblocksConnectTitle": "2. Enter your API user ID", + "fireblocksApprovalPolicy": "Add SDP as an Editor and keep yourself as the designated signer in your Fireblocks policy. Allow TRANSFER for this API user, the selected vaults, and your intended destinations.", + "fireblocksVerifyDescription": "Connecting verifies access and imports the selected wallets. It does not create vaults or move funds.", + "fireblocksCsrReady": "Your CSR is ready. Register it in Fireblocks and connect within 7 days. You can return to this setup later.", + "fireblocksCsrExpired": "This registration has expired. Generate a new CSR to continue.", + "fireblocksCsrFailed": "Could not generate your CSR. Try again.", + "fireblocksInstructionConsole": "In the Fireblocks Console, open Developer Center → API Users → Add API user.", + "fireblocksInstructionEditor": "Name the API user “SDP” and choose Editor. Keep yourself as the designated signer in your transaction policy.", + "fireblocksInstructionUpload": "Upload the CSR downloaded above. This integration does not require setting up a new API Co-signer.", + "fireblocksInstructionApprove": "Select Add user and complete the approvals required by your workspace. Wait for the API user to become active.", + "fireblocksCopyApiUser": "Copy the value from the API User (ID) column in Fireblocks. This is also called the API key.", + "fireblocksVaultsLoad": "Load vaults", + "fireblocksVaultsLoading": "Loading vaults…", + "fireblocksChooseVaults": "3. Choose your vaults", + "fireblocksVaultSelectionHelp": "Select up to 20 vaults to connect. Only vaults with a Solana wallet on this project’s network are shown.", + "fireblocksRotationVaultHelp": "Select the original vault used to connect this account. Rotation keeps all existing wallet connections.", + "fireblocksVaultReference": "Vault {id}", + "fireblocksVaultsEmpty": "No Solana vaults found. Check that this workspace has a Solana wallet on your project’s network.", + "fireblocksVaultsMore": "Load more vaults", + "fireblocksVaultsSelected": "{count} selected", + "fireblocksVaultLabels": "SDP uses each Fireblocks vault name as its wallet label. You can rename wallets after connecting.", + "fireblocksTestingTitle": "Before your first signing test", + "fireblocksRawRequirement": "SOL and USDC payments use Fireblocks transfers. RAW signing is not required. Your Fireblocks approval and destination policies still apply.", + "fireblocksIpRequirement": "If Fireblocks restricts API IP addresses, allow the SDP server’s public outbound IP. For local testing, use your network or VPN’s public IP.", + "fireblocksTestingInstructions": "Keep SOL in the source vault for network fees, including USDC payments. Send a small payment and approve it in Fireblocks. SDP tracks the payment while you approve. Signer check tests the separate RAW-signing path and is not required for these payments.", + "fireblocksRawGuide": "Fireblocks RAW signing requirements", + "fireblocksVaultsFailed": "Could not load vaults. Check your connection and try again.", + "fireblocksCredentialVault": "Credential verification vault — keep this vault when rotating credentials." } } diff --git a/apps/sdp-web/messages/en/dashboard-payments.json b/apps/sdp-web/messages/en/dashboard-payments.json index 4a4842dbdc..132a26ee11 100644 --- a/apps/sdp-web/messages/en/dashboard-payments.json +++ b/apps/sdp-web/messages/en/dashboard-payments.json @@ -397,7 +397,16 @@ "transferFailed": "Transfer failed.", "transferSuccess": "Your transfer was sent successfully.", "transferStatus": "Status: {status}", - "viewOnExplorer": "View on explorer" + "viewOnExplorer": "View on explorer", + "fireblocksReview": "Approve this payment in Fireblocks. Your source vault pays the network fee in SOL, including for USDC. The destination must be permitted by your Fireblocks one-time-address policy. The memo is a payment note, not an on-chain memo.", + "fireblocksPending": "Payment submitted to Fireblocks. Review and approve it there; SDP will update its status after signing and confirmation.", + "fireblocksUnknown": "SDP is checking whether Fireblocks received this payment. Check its transaction details before starting another payment.", + "viewTransaction": "View transaction", + "fireblocksTransactionId": "Fireblocks transaction ID", + "fireblocksStatus": "Fireblocks status", + "fireblocks": "Fireblocks", + "whitelistWarning": "This address is not whitelisted in Fireblocks.", + "whitelistDescription": "Add {address} for {asset} in Fireblocks, or use an allowed one-time transfer. “Don’t show again” applies to this project on this browser." }, "onchainReceive": { "wallet": "Wallet", @@ -1585,6 +1594,21 @@ } } }, - "noTransactions": "No transactions found yet." + "noTransactions": "No transactions found yet.", + "fireblocksPolicy": { + "missing": "No matching Fireblocks transfer policy was found. Set up a transfer rule in Fireblocks before sending.", + "blocked": "A Fireblocks transfer policy blocks this payment. Review the rule in Fireblocks before sending.", + "unknown": "SDP could not determine whether Fireblocks allows this payment. Review your transfer policy in Fireblocks.", + "permissions": "Your Fireblocks API user cannot read policies. Check in Fireblocks that a transfer rule allows this payment.", + "recheck": "Check again", + "openFireblocks": "Open Fireblocks", + "configure": "Configure a Transfer policy in Fireblocks for this API user, source vault, destination and asset, with your approvers and a designated human signer. SDP cannot inspect policies using the recommended Editor role." + }, + "fireblocksWarnings": { + "suppress": "Don’t show again", + "dismiss": "Dismiss", + "scope": "Hide these warnings for this project on this browser.", + "saveFailed": "Couldn’t save this preference. Check that browser storage is enabled." + } } } diff --git a/apps/sdp-web/src/app/api/dashboard/fireblocks/destination/route.ts b/apps/sdp-web/src/app/api/dashboard/fireblocks/destination/route.ts new file mode 100644 index 0000000000..d25092ac59 --- /dev/null +++ b/apps/sdp-web/src/app/api/dashboard/fireblocks/destination/route.ts @@ -0,0 +1,8 @@ +import { proxyToSdpApi } from "@/lib/sdp-api"; +export async function GET(request: Request) { + return proxyToSdpApi({ + request, + traceSource: "route.dashboard.fireblocks.destination", + path: `/internal/dashboard/fireblocks/destination${new URL(request.url).search}`, + }); +} diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/custody/byok-actions.ts b/apps/sdp-web/src/app/dashboard/[projectId]/custody/byok-actions.ts index f6552cdf06..bd03b6b644 100644 --- a/apps/sdp-web/src/app/dashboard/[projectId]/custody/byok-actions.ts +++ b/apps/sdp-web/src/app/dashboard/[projectId]/custody/byok-actions.ts @@ -7,11 +7,11 @@ import { createSdpApiClient, requestProjectHref } from "@/lib/sdp-api"; interface SafeProviderCredential { id: string; - provider: "privy"; + provider: "privy" | "fireblocks"; label: string; scope: "organization" | "project"; status: string; - displayMetadata: { appIdSuffix?: string }; + displayMetadata: { appIdSuffix?: string; apiKeySuffix?: string }; } interface ProviderCredentialSubmissionResult { @@ -127,7 +127,9 @@ async function runCompletion(connectionId: string): Promise +) { + const ids = + provider === "fireblocks" ? formData.getAll("fireblocksVaultAccountIds").map(String) : []; + return { + valid: + ids.length <= 20 && + ids.every((id) => /^\d{1,100}$/.test(id)) && + new Set(ids).size === ids.length && + (ids.length === 0 || ids.includes(fields.vaultAccountId ?? "")), + payload: ids.length ? { fireblocksVaultAccountIds: ids } : {}, + }; +} + /** * Stores the credential, then runs the connection completion in the same * action, per the install design: submit is call-and-wait, not submit-and-poll. @@ -170,14 +189,29 @@ export async function submitPrivyCredentialAction( const idempotencyKey = String(formData.get("idempotencyKey") ?? "").trim(); const credentialLabel = String(formData.get("credentialLabel") ?? "").trim(); - const appId = String(formData.get("appId") ?? "").trim(); - const appSecret = String(formData.get("appSecret") ?? ""); + const provider = String(formData.get("provider") ?? "privy"); + if (provider !== "privy" && provider !== "fireblocks") + return { status: "invalid", message: t("DashboardCustody.byokMissingFields") }; + const fields = + provider === "fireblocks" + ? { + apiKey: String(formData.get("apiKey") ?? "").trim(), + signingKeyId: String(formData.get("signingKeyId") ?? "").trim(), + vaultAccountId: String(formData.get("vaultAccountId") ?? "").trim(), + } + : { + appId: String(formData.get("appId") ?? "").trim(), + appSecret: String(formData.get("appSecret") ?? ""), + }; + const selection = readVaultSelection(formData, provider, fields); + if (!selection.valid) + return { status: "invalid", message: t("DashboardCustody.byokMissingFields") }; const walletLabel = String(formData.get("walletLabel") ?? "").trim(); const replaceConnectionId = String(formData.get("connectionId") ?? "").trim(); // Trimmed-empty fields pass the browser's `required` check but are known // rejects before any request leaves — `invalid`, never the replay path. - if (!idempotencyKey || !credentialLabel || !appId || !appSecret) { + if (!idempotencyKey || !credentialLabel || Object.values(fields).some((value) => !value.trim())) { return { status: "invalid", message: t("DashboardCustody.byokMissingFields") }; } @@ -193,11 +227,12 @@ export async function submitPrivyCredentialAction( method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ - provider: "privy", + provider, ...(walletLabel ? { walletLabel } : {}), + ...selection.payload, // Credentials always bind to the calling project; the API no longer // accepts an organization scope for stored custody credentials. - fields: { credentialLabel, scope: "project", appId, appSecret }, + fields: { credentialLabel, scope: "project", ...fields }, }), }); } catch (error) { diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/custody/byok-actions.unit.test.ts b/apps/sdp-web/src/app/dashboard/[projectId]/custody/byok-actions.unit.test.ts index b80314d814..2d670565f7 100644 --- a/apps/sdp-web/src/app/dashboard/[projectId]/custody/byok-actions.unit.test.ts +++ b/apps/sdp-web/src/app/dashboard/[projectId]/custody/byok-actions.unit.test.ts @@ -171,6 +171,48 @@ describe("submitPrivyCredentialAction outcome classification", () => { mocks.createSdpApiClient.mockResolvedValue(client); }); + it("submits Fireblocks credentials and refreshes the Fireblocks integration", async () => { + const fireblocksCredential = { ...credential, provider: "fireblocks" }; + client.fetch + .mockResolvedValueOnce({ + providerCredential: fireblocksCredential, + connectionId: "conn_test", + }) + .mockResolvedValueOnce({ + ...completionResult("success"), + providerCredential: fireblocksCredential, + }); + const data = new FormData(); + for (const [name, value] of Object.entries({ + provider: "fireblocks", + credentialLabel: "Treasury", + idempotencyKey: "fireblocks-key", + apiKey: "api-user", + signingKeyId: "fbcsr_11111111-1111-4111-8111-111111111111", + vaultAccountId: "0", + })) + data.set(name, value); + data.append("fireblocksVaultAccountIds", "0"); + data.append("fireblocksVaultAccountIds", "7"); + expect(await submitPrivyCredentialAction(data)).toEqual({ + status: "success", + connectionId: "conn_test", + }); + expect(JSON.parse(client.fetch.mock.calls[0]?.[1]?.body)).toEqual({ + provider: "fireblocks", + fireblocksVaultAccountIds: ["0", "7"], + fields: { + credentialLabel: "Treasury", + scope: "project", + apiKey: "api-user", + signingKeyId: "fbcsr_11111111-1111-4111-8111-111111111111", + vaultAccountId: "0", + }, + }); + expect(mocks.revalidatePath).toHaveBeenCalledWith( + `/dashboard/${PRODUCTION_PROJECT.id}/integrations/fireblocks` + ); + }); it("runs the connection completion after storing the credential", async () => { client.fetch .mockResolvedValueOnce({ providerCredential: credential, connectionId: "conn_test" }) diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/add-connection-modal.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/add-connection-modal.tsx index 6ba94be974..a5f4f3d75d 100644 --- a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/add-connection-modal.tsx +++ b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/add-connection-modal.tsx @@ -1,11 +1,12 @@ "use client"; +import { isByokCustodyProvider } from "@sdp/types/custody"; import { Button } from "@sdp/ui/button"; import { Modal } from "@sdp/ui/modal"; import { Loader2Icon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useId, useState } from "react"; -import { PrivyCredentialForm } from "@/app/dashboard/[projectId]/custody/setup/privy-credential-form"; +import { ByokCredentialForm } from "@/app/dashboard/[projectId]/custody/setup/privy-credential-form"; import { useTranslations } from "@/i18n/provider"; import { useProjectHref } from "@/lib/use-dashboard-project"; @@ -34,6 +35,7 @@ export function AddConnectionModal({ const router = useRouter(); const href = useProjectHref(); const formId = useId(); + const [ready, setReady] = useState(provider !== "fireblocks"); const [submitting, setSubmitting] = useState(false); // Once the form has swapped its fields for a recovery panel there is no // `
` left for this footer to submit, and the panel carries @@ -60,6 +62,8 @@ export function AddConnectionModal({ ); }; + if (!isByokCustodyProvider(provider)) return null; + return ( {t("DashboardCustody.addConnectionDescription")}

- : undefined } diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/add-connection-modal.unit.test.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/add-connection-modal.unit.test.tsx index dc1cbb6d07..e37a86b645 100644 --- a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/add-connection-modal.unit.test.tsx +++ b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/add-connection-modal.unit.test.tsx @@ -89,7 +89,9 @@ it("keeps an unknown submission open and replays the same payload and key", asyn const { submit, complete } = mockApi(); submit.mockRejectedValueOnce(new TypeError("fetch failed")); complete.mockImplementation(async () => - Response.json({ data: { completion: { status: "success" } } }) + Response.json({ + data: { providerCredential: { provider: "privy" }, completion: { status: "success" } }, + }) ); const user = userEvent.setup(); render(); diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-actions.ts b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-actions.ts index f79c8b436d..0bdfa3ac98 100644 --- a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-actions.ts +++ b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-actions.ts @@ -107,10 +107,19 @@ export async function rotateCredentialsAction( const credentialId = String(formData.get("credentialId") ?? "").trim(); const connectionId = String(formData.get("connectionId") ?? "").trim(); const idempotencyKey = String(formData.get("idempotencyKey") ?? "").trim(); - const appId = String(formData.get("appId") ?? "").trim(); - const appSecret = String(formData.get("appSecret") ?? ""); + const fields = + provider === "fireblocks" + ? { + apiKey: String(formData.get("apiKey") ?? "").trim(), + signingKeyId: String(formData.get("signingKeyId") ?? "").trim(), + vaultAccountId: String(formData.get("vaultAccountId") ?? "").trim(), + } + : { + appId: String(formData.get("appId") ?? "").trim(), + appSecret: String(formData.get("appSecret") ?? ""), + }; - if (!credentialId || !idempotencyKey || !appId || !appSecret) { + if (!credentialId || !idempotencyKey || Object.values(fields).some((value) => !value.trim())) { return { status: "failed", kind: "invalid_credentials", @@ -126,7 +135,7 @@ export async function rotateCredentialsAction( { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, - body: JSON.stringify({ fields: { appId, appSecret } }), + body: JSON.stringify({ provider: custodyProvider, fields }), } ); } catch (error) { diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-credentials-section.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-credentials-section.tsx index 22b6719f79..12c8927d17 100644 --- a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-credentials-section.tsx +++ b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-credentials-section.tsx @@ -34,8 +34,8 @@ function CredentialRow({ label, children }: { label: string; children: React.Rea } /** `····9f2a` — the only part of an App ID ever shown back. */ -function maskedAppId(credential: LifecycleCredential): string { - const suffix = credential.displayMetadata.appIdSuffix; +function maskedCredentialId(credential: LifecycleCredential): string { + const suffix = credential.displayMetadata.appIdSuffix ?? credential.displayMetadata.apiKeySuffix; return suffix ? `····${suffix}` : "—"; } @@ -118,8 +118,14 @@ function CredentialDetails({ {t("DashboardCustody.credentialDeploymentBadge")} )} - - {maskedAppId(credential)} + + {maskedCredentialId(credential)} {managedHere ? ( diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-credentials-section.unit.test.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-credentials-section.unit.test.tsx index 969c88ed2f..30208b4ce0 100644 --- a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-credentials-section.unit.test.tsx +++ b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-credentials-section.unit.test.tsx @@ -287,7 +287,9 @@ describe("credentials section", () => { }) ); const dialog = await openDialog(); - expect(within(dialog).getByText(/does not revoke the key at Privy/)).toBeTruthy(); + expect( + within(dialog).getByText(/does not revoke credentials at the provider/) + ).toBeTruthy(); await userEvent.click( within(dialog).getByRole("button", { name: "Deactivate credentials" }) ); diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-detail.data.ts b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-detail.data.ts index ea5e87d907..d684f61cf4 100644 --- a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-detail.data.ts +++ b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/connection-detail.data.ts @@ -51,7 +51,11 @@ const safeCredentialSchema = z.object({ projectId: z.string().nullable(), status: z.enum(PROVIDER_CREDENTIAL_STATUSES), createdAt: z.string(), - displayMetadata: z.object({ appIdSuffix: z.string().optional() }), + displayMetadata: z.object({ + appIdSuffix: z.string().optional(), + apiKeySuffix: z.string().optional(), + primaryVaultId: z.string().optional(), + }), source: z.enum(["stored", "runtime"]), }); diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/rotate-credentials-modal.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/rotate-credentials-modal.tsx index 5e344af103..3f67682c84 100644 --- a/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/rotate-credentials-modal.tsx +++ b/apps/sdp-web/src/app/dashboard/[projectId]/custody/connections/rotate-credentials-modal.tsx @@ -8,6 +8,7 @@ import { Label } from "@sdp/ui/label"; import { Modal } from "@sdp/ui/modal"; import { Loader2Icon, RefreshCwIcon } from "lucide-react"; import { useState } from "react"; +import { FireblocksCredentialFields } from "@/app/dashboard/[projectId]/custody/setup/fireblocks-credential-fields"; import { useTranslations } from "@/i18n/provider"; import { rotateCredentialsAction } from "./connection-actions"; import type { CustodyCredentialLifecycle } from "./connection-detail.data"; @@ -41,6 +42,9 @@ export function RotateCredentialsModal({ const t = useTranslations(); const { pending, run } = useCustodyAction(); // Keep the attempt outside Modal's children, which unmount while it is hidden. + const [childPending, setChildPending] = useState(false); + const [fireblocksReady, setFireblocksReady] = useState(false); + const [csrAttempt, setCsrAttempt] = useState(0); const [appId, setAppId] = useState(""); const [appSecret, setAppSecret] = useState(""); const [idempotencyKey, setIdempotencyKey] = useState(() => crypto.randomUUID()); @@ -48,14 +52,18 @@ export function RotateCredentialsModal({ const connectionCount = lifecycle.impact.connections.length; const fieldsDisabled = pending || attempt !== null || !canRotate; - const handleClose = () => onClose(attempt !== null); + const inFlight = pending || childPending; + const handleClose = () => { + if (!inFlight) onClose(attempt !== null); + }; const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - if (pending || (!attempt && !canRotate)) return; + if (inFlight || (!attempt && (!canRotate || (provider === "fireblocks" && !fireblocksReady)))) + return; // Keep fields, key and target together: a refresh may replace the current // credential while the original request's response is still unknown. - const formData = attempt ?? new FormData(); + const formData = attempt ?? new FormData(event.currentTarget); if (!attempt) { formData.set("credentialId", lifecycle.providerCredential.id); formData.set("connectionId", connectionId); @@ -82,6 +90,7 @@ export function RotateCredentialsModal({ // Only a conclusive refusal permits a corrected intent under a fresh key. if (result.status === "failed") { setIdempotencyKey(crypto.randomUUID()); + setCsrAttempt((value) => value + 1); } if (result.status === "success") { @@ -92,8 +101,8 @@ export function RotateCredentialsModal({ return ( @@ -106,34 +115,52 @@ export function RotateCredentialsModal({ {t("DashboardCustody.rotateRetryHint")} ) : null} -
- - setAppId(event.target.value)} /> -
+ ) : ( + <> +
+ + setAppId(event.target.value)} + /> +
- + + + )}
-