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
// `
- : 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)}
+ />
+
-
+
+ >
+ )}
-
+
);
}
@@ -262,13 +261,16 @@ function ReviewSummary({ wizard, counterpartyName }: StepProps) {
/>
)}
+ {isFireblocksByokWallet(selectedWallet) ? (
+
+ {t("DashboardPayments.onchainSend.fireblocksReview")}
+
+ ) : null}
>
);
}
function ReviewStep({ wizard, counterpartyName }: StepProps) {
- const t = useTranslations();
- const cluster = useSolanaCluster();
const { transferResult } = wizard;
const summary = (
@@ -280,30 +282,7 @@ function ReviewStep({ wizard, counterpartyName }: StepProps) {
if (transferResult === null) {
return summary;
}
- const signature = transferResult.signature;
- return (
-
-
- {signature === null
- ? t("DashboardPayments.onchainSend.transferStatus", { status: transferResult.status })
- : t("DashboardPayments.onchainSend.transferSuccess")}
-
- {summary}
- {signature === null ? null : (
-
}
- onClick={() =>
- window.open(explorerTxUrl(signature, cluster), "_blank", "noopener,noreferrer")
- }
- >
- {t("DashboardPayments.onchainSend.viewOnExplorer")}
-
- )}
-
- );
+ return ;
}
export function OnchainSendStepContent({ wizard, counterpartyName }: StepProps) {
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-fireblocks-destination.ts b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-fireblocks-destination.ts
new file mode 100644
index 0000000000..e7bcde62f9
--- /dev/null
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-fireblocks-destination.ts
@@ -0,0 +1,114 @@
+"use client";
+
+import { SOL_MINT } from "@sdp/types/well-known-tokens";
+import { useEffect, useRef } from "react";
+import { toast } from "sonner";
+import { useTranslations } from "@/i18n/provider";
+import { dashboardFetch } from "@/lib/dashboard-fetch";
+import { useProjectId } from "@/lib/use-dashboard-project";
+import { useFireblocksWarning } from "@/lib/use-fireblocks-warning";
+
+type State = "approved" | "pending" | "not_whitelisted" | "unknown" | "failed";
+type Result = {
+ data: {
+ state: State;
+ assetId: string;
+ };
+};
+interface Selection {
+ enabled: boolean;
+ walletId: string;
+ counterpartyId: string;
+ accountId: string;
+ token: string | null;
+ address: string | null;
+}
+/** Advisory only. Whitelist changes are made by the customer in Fireblocks. */
+export function useFireblocksDestination({
+ enabled,
+ walletId,
+ counterpartyId,
+ accountId,
+ token,
+ address,
+}: Selection) {
+ const t = useTranslations();
+ const projectId = useProjectId();
+ const offered = useRef(new Set());
+ const { suppressed, suppress, canSuppress, scopeKey } = useFireblocksWarning("whitelist");
+ useEffect(() => {
+ if (
+ suppressed ||
+ !enabled ||
+ !walletId ||
+ !counterpartyId ||
+ !accountId ||
+ !token ||
+ !address ||
+ !projectId
+ )
+ return;
+ const input = { walletId, counterpartyId, accountId, token, address };
+ const key = JSON.stringify([scopeKey, projectId, input]);
+ const controller = new AbortController();
+ let active = true;
+ let toastId: string | number | undefined;
+ const timer = setTimeout(async () => {
+ const result = await dashboardFetch(
+ `/api/dashboard/fireblocks/destination?${new URLSearchParams(input)}`,
+ { signal: controller.signal }
+ );
+ if (
+ !active ||
+ !result.ok ||
+ result.data.data.state !== "not_whitelisted" ||
+ offered.current.has(key)
+ )
+ return;
+ offered.current.add(key);
+ toastId = toast.message(t("DashboardPayments.onchainSend.whitelistWarning"), {
+ description: t("DashboardPayments.onchainSend.whitelistDescription", {
+ address: `${address.slice(0, 6)}…${address.slice(-6)}`,
+ asset: token === "SOL" || token === SOL_MINT ? "SOL" : "USDC",
+ }),
+ duration: 15000,
+ style: { flexWrap: "wrap" },
+ classNames: { content: "!basis-full" },
+ action: canSuppress
+ ? {
+ label: t("DashboardPayments.fireblocksWarnings.suppress"),
+ onClick: () => {
+ if (active && !suppress()) {
+ toast.error(t("DashboardPayments.fireblocksWarnings.saveFailed"));
+ offered.current.delete(key);
+ }
+ },
+ }
+ : undefined,
+ cancel: {
+ label: t("DashboardPayments.fireblocksWarnings.dismiss"),
+ onClick: () => undefined,
+ },
+ });
+ }, 300);
+ return () => {
+ active = false;
+ clearTimeout(timer);
+ controller.abort();
+ if (toastId !== undefined) toast.dismiss(toastId);
+ };
+ }, [
+ enabled,
+ walletId,
+ counterpartyId,
+ accountId,
+ token,
+ address,
+ projectId,
+ t,
+ suppressed,
+ suppress,
+ canSuppress,
+ scopeKey,
+ ]);
+}
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-fireblocks-destination.unit.test.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-fireblocks-destination.unit.test.tsx
new file mode 100644
index 0000000000..91a3763c6e
--- /dev/null
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-fireblocks-destination.unit.test.tsx
@@ -0,0 +1,169 @@
+// @vitest-environment jsdom
+import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react";
+import * as sonner from "sonner";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import * as dashboard from "@/lib/dashboard-fetch";
+import { useFireblocksDestination } from "./use-fireblocks-destination";
+
+const mocks = vi.hoisted(() => ({
+ projectId: "project-one",
+ userId: "user-one",
+ fetch: vi.fn(),
+ toast: Object.assign(
+ vi.fn<(...args: Parameters) => string>(() => "toast-book"),
+ { dismiss: vi.fn(), error: vi.fn() }
+ ),
+ t: (key: string) => key,
+}));
+vi.mock("@/i18n/provider", () => ({ useTranslations: () => mocks.t }));
+vi.mock("@/lib/use-dashboard-project", () => ({ useProjectId: () => mocks.projectId }));
+vi.mock("@/contexts/dashboard-workspace-context", () => ({
+ useOptionalDashboardWorkspace: () => ({
+ dashboardCacheScope: { userId: mocks.userId, orgId: "org" },
+ }),
+}));
+const selection = {
+ enabled: true,
+ walletId: "wallet",
+ counterpartyId: "contact",
+ accountId: "address",
+ token: "SOL",
+ address: "11111111111111111111111111111111",
+};
+const response = (state: string) => ({ ok: true, data: { data: { state, assetId: "SOL" } } });
+async function tick() {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(350);
+ });
+}
+function action() {
+ const action = mocks.toast.mock.calls[0][1]?.action;
+ if (!action || typeof action !== "object" || !("onClick" in action)) {
+ throw new Error("Expected a toast action");
+ }
+ return () => {
+ const button = render(
+
+ Suppress warning
+
+ );
+ fireEvent.click(button.getByRole("button", { name: "Suppress warning" }));
+ button.unmount();
+ };
+}
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ vi.spyOn(dashboard, "dashboardFetch").mockImplementation(mocks.fetch);
+ vi.spyOn(sonner.toast, "message").mockImplementation(mocks.toast);
+ vi.spyOn(sonner.toast, "dismiss").mockImplementation(mocks.toast.dismiss);
+ vi.spyOn(sonner.toast, "error").mockImplementation(mocks.toast.error);
+ window.localStorage.clear();
+ mocks.projectId = "project-one";
+ mocks.userId = "user-one";
+ mocks.fetch.mockResolvedValue(response("not_whitelisted"));
+});
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+describe("Fireblocks destination warning", () => {
+ it("only reads, and saves suppression across remounts without proposing anything", async () => {
+ const view = renderHook(() => useFireblocksDestination(selection));
+ await tick();
+ expect(mocks.fetch).toHaveBeenCalledTimes(1);
+ expect(mocks.fetch.mock.calls[0]?.[0]).toContain("/destination?");
+ expect(mocks.toast).toHaveBeenCalledOnce();
+ action()();
+ view.unmount();
+ renderHook(() => useFireblocksDestination(selection));
+ await tick();
+ expect(mocks.fetch).toHaveBeenCalledTimes(1);
+ expect(mocks.toast).toHaveBeenCalledOnce();
+ expect(window.localStorage.length).toBe(1);
+ expect(mocks.fetch.mock.calls.every(([, opts]) => !opts.method || opts.method === "GET")).toBe(
+ true
+ );
+ });
+ it("does not suppress warnings for another user or project", async () => {
+ const view = renderHook(() => useFireblocksDestination(selection));
+ await tick();
+ action()();
+ mocks.projectId = "project-two";
+ view.rerender();
+ await tick();
+ expect(mocks.toast).toHaveBeenCalledTimes(2);
+ mocks.projectId = "project-one";
+ mocks.userId = "user-two";
+ view.unmount();
+ renderHook(() => useFireblocksDestination(selection));
+ await tick();
+ expect(mocks.toast).toHaveBeenCalledTimes(3);
+ });
+ it.each(["approved", "pending", "unknown", "failed"])(
+ "does not warn about absence for %s",
+ async (state) => {
+ mocks.fetch.mockResolvedValue(response(state));
+ renderHook(() => useFireblocksDestination(selection));
+ await tick();
+ expect(mocks.toast).not.toHaveBeenCalled();
+ }
+ );
+ it("does not turn a failed lookup into an absent address", async () => {
+ mocks.fetch.mockResolvedValue({ ok: false, error: "Unavailable" });
+ renderHook(() => useFireblocksDestination(selection));
+ await tick();
+ expect(mocks.toast).not.toHaveBeenCalled();
+ });
+ it("ignores stale responses, aborts old lookups and dismisses old warnings", async () => {
+ let resolve: (value: unknown) => void = () => undefined;
+ mocks.fetch.mockImplementationOnce(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ })
+ );
+ const view = renderHook((props) => useFireblocksDestination(props), {
+ initialProps: selection,
+ });
+ await tick();
+ const signal = mocks.fetch.mock.calls[0]?.[1].signal;
+ view.rerender({ ...selection, accountId: "next" });
+ expect(signal.aborted).toBe(true);
+ await act(async () => {
+ resolve(response("not_whitelisted"));
+ });
+ expect(mocks.toast).not.toHaveBeenCalled();
+ await tick();
+ const oldAction = action();
+ view.rerender({ ...selection, enabled: false });
+ expect(mocks.toast.dismiss).toHaveBeenCalledWith("toast-book");
+ oldAction();
+ expect(window.localStorage.length).toBe(0);
+ });
+ it("does not repeat reads on unrelated renders or for another provider", async () => {
+ const view = renderHook((props) => useFireblocksDestination(props), {
+ initialProps: selection,
+ });
+ await tick();
+ view.rerender({ ...selection });
+ await tick();
+ expect(mocks.fetch).toHaveBeenCalledTimes(1);
+ view.rerender({ ...selection, enabled: false });
+ await tick();
+ expect(mocks.fetch).toHaveBeenCalledTimes(1);
+ });
+ it("reports a storage failure instead of claiming permanent suppression", async () => {
+ const storage = vi.spyOn(window.Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("Blocked");
+ });
+ renderHook(() => useFireblocksDestination(selection));
+ await tick();
+ action()();
+ expect(mocks.toast.error).toHaveBeenCalledWith(
+ "DashboardPayments.fireblocksWarnings.saveFailed"
+ );
+ storage.mockRestore();
+ });
+});
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-fireblocks-payment-advisory.ts b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-fireblocks-payment-advisory.ts
new file mode 100644
index 0000000000..43b2d26baf
--- /dev/null
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-fireblocks-payment-advisory.ts
@@ -0,0 +1,29 @@
+"use client";
+import type { PaymentsDashboardWallet } from "@sdp/types/payments";
+import { isFireblocksByokWallet } from "@/app/dashboard/[projectId]/payments/fireblocks-wallet";
+import { useFireblocksDestination } from "./use-fireblocks-destination";
+/** Translate the Pay wizard's nullable selections into a single advisory request. */
+export function useFireblocksPaymentAdvisory(input: {
+ wallet: PaymentsDashboardWallet | null;
+ signingUnavailable: boolean;
+ submitting: boolean;
+ finished: boolean;
+ walletId: string;
+ counterpartyId: string;
+ account: { id: string } | null;
+ balance: { mint: string } | null;
+ address: string | null;
+}) {
+ useFireblocksDestination({
+ enabled:
+ isFireblocksByokWallet(input.wallet) &&
+ !input.signingUnavailable &&
+ !input.submitting &&
+ !input.finished,
+ walletId: input.walletId,
+ counterpartyId: input.counterpartyId,
+ accountId: input.account?.id ?? "",
+ token: input.balance?.mint ?? null,
+ address: input.address,
+ });
+}
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.redesign.ts b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.redesign.ts
index 53d4745721..26b807fa6a 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.redesign.ts
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.redesign.ts
@@ -3,6 +3,7 @@
import { compareDecimalAmounts } from "@sdp/solana/amount";
import type { CounterpartyAccount } from "@sdp/types/counterparties";
import type { PaymentsDashboardWallet, PaymentTransferSummary } from "@sdp/types/payments";
+import { CLUSTER_BY_SDP_ENVIRONMENT, type SolanaCluster } from "@sdp/types/well-known-tokens";
import { address } from "@solana/kit";
import { CoinsIcon, DollarSignIcon, WalletIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -20,13 +21,14 @@ import {
onchainDetailsSchema,
onchainSendSchema,
} from "@/app/dashboard/[projectId]/payments/ramps/schema";
-import { walletBalanceAssetOptions } from "@/app/dashboard/[projectId]/payments/ramps/wallet-options";
+import { onchainPaymentAssetOptions } from "@/app/dashboard/[projectId]/payments/ramps/wallet-options";
import {
optionalDetail,
summaryAmount,
} from "@/app/dashboard/[projectId]/payments/ramps/wizard-summary";
import { sendTransferUnderKey } from "@/app/dashboard/[projectId]/payments/transfer-idempotency";
import type { WizardSummaryDetail } from "@/app/dashboard/[projectId]/payments/wizard-summary-list";
+import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context";
import type { MessageKey } from "@/i18n/messages";
import { useLocale, useTranslations } from "@/i18n/provider";
import type { TranslationValues } from "@/i18n/translate";
@@ -35,6 +37,7 @@ import { DEMO_PREFILL_AMOUNTS } from "@/lib/payments-demo/demo-prefill";
import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context";
import { useProjectHref } from "@/lib/use-dashboard-project";
import { useZodForm } from "@/lib/use-zod-form";
+import { useFireblocksPaymentAdvisory } from "./use-fireblocks-payment-advisory";
import { usePaymentsActionWallets } from "./use-payments-action-wallets";
import type { RampWizardStep } from "./use-ramp-wizard.redesign";
@@ -188,6 +191,24 @@ function useOnchainSubmission(signingUnavailable: boolean, t: Translate) {
try {
const { transfer } = await sendTransferUnderKey(submission, t);
setTransferResult(transfer);
+ if (transfer.status === "failed") {
+ toast.error(t("DashboardPayments.onchainSend.transferFailed"), {
+ id: toastId,
+ description: transfer.error ?? undefined,
+ });
+ return;
+ }
+ if (transfer.fireblocks && !transfer.signature) {
+ toast.info(t("DashboardPayments.onchainSend.transferSubmitted"), {
+ id: toastId,
+ description: t(
+ transfer.fireblocks.transactionId
+ ? "DashboardPayments.onchainSend.fireblocksPending"
+ : "DashboardPayments.onchainSend.fireblocksUnknown"
+ ),
+ });
+ return;
+ }
toast.success(t("DashboardPayments.onchainSend.transferSubmitted"), {
id: toastId,
description: submittedTransferDescription(transfer, t),
@@ -214,11 +235,12 @@ function useOnchainAssetSelection(
selectedWallet: PaymentsDashboardWallet | null,
fields: OnchainSendFields,
issuedTokenSymbolsByMint: Record,
- t: Translate
+ t: Translate,
+ cluster: SolanaCluster
) {
const assetOptions = useMemo(
- () => walletBalanceAssetOptions(selectedWallet, issuedTokenSymbolsByMint, t),
- [issuedTokenSymbolsByMint, selectedWallet, t]
+ () => onchainPaymentAssetOptions(selectedWallet, issuedTokenSymbolsByMint, t, cluster),
+ [issuedTokenSymbolsByMint, selectedWallet, t, cluster]
);
const selectedAsset = useMemo(() => {
const asset = assetOptions.find((candidate) => candidate.value === fields.asset);
@@ -226,12 +248,16 @@ function useOnchainAssetSelection(
}, [assetOptions, fields.asset]);
const selectedAssetBalance = useMemo(() => {
- if (selectedWallet === null || selectedWallet.balances === undefined) {
+ if (
+ selectedAsset === null ||
+ selectedWallet === null ||
+ selectedWallet.balances === undefined
+ ) {
return null;
}
const balance = selectedWallet.balances.find((candidate) => candidate.mint === fields.asset);
return balance === undefined ? null : balance;
- }, [selectedWallet, fields.asset]);
+ }, [selectedWallet, fields.asset, selectedAsset]);
let availableAmount: string | null;
if (selectedWallet === null) {
@@ -257,6 +283,8 @@ export function useOnchainSendWizard({
const href = useProjectHref();
const t = useTranslations();
const locale = useLocale();
+ const { sdpEnvironment } = useDashboardWorkspace();
+ const cluster = CLUSTER_BY_SDP_ENVIRONMENT[sdpEnvironment];
const steps = getOnchainSendSteps(t);
const [stepIndex, setStepIndex] = useState(0);
const demo = usePaymentsDemo();
@@ -316,13 +344,24 @@ export function useOnchainSendWizard({
t
);
const { assetOptions, selectedAsset, selectedAssetBalance, availableAmount, exceedsBalance } =
- useOnchainAssetSelection(selectedWallet, fields, issuedTokenSymbolsByMint, t);
+ useOnchainAssetSelection(selectedWallet, fields, issuedTokenSymbolsByMint, t, cluster);
+ useFireblocksPaymentAdvisory({
+ wallet: selectedWallet,
+ signingUnavailable,
+ submitting,
+ finished,
+ walletId: fields.walletId,
+ counterpartyId,
+ account: selectedAccount,
+ balance: selectedAssetBalance,
+ address: destinationAddress,
+ });
const selectWallet = (walletId: string) => {
setField("walletId", walletId);
const matchingWallet = liveWallets.find((wallet) => wallet.id === walletId);
const nextWallet = matchingWallet === undefined ? null : matchingWallet;
- const nextAssets = walletBalanceAssetOptions(nextWallet, issuedTokenSymbolsByMint, t);
+ const nextAssets = onchainPaymentAssetOptions(nextWallet, issuedTokenSymbolsByMint, t, cluster);
const nextAsset = nextAssetAfterWalletChange(fields.asset, nextAssets);
if (nextAsset !== fields.asset) {
setField("asset", nextAsset);
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.redesign.unit.test.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.redesign.unit.test.tsx
index a4a6e7d0c2..887952ac41 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.redesign.unit.test.tsx
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.redesign.unit.test.tsx
@@ -2,7 +2,7 @@
import type { CounterpartyAccount } from "@sdp/types/counterparties";
import type { PaymentsDashboardWallet, PaymentTransferSummary } from "@sdp/types/payments";
-import { SOL_MINT } from "@sdp/types/well-known-tokens";
+import { SOL_MINT, WELL_KNOWN_TOKENS } from "@sdp/types/well-known-tokens";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { SWRConfig } from "swr";
@@ -118,13 +118,13 @@ function wrapper({ children }: { children: ReactNode }) {
);
}
-function renderWizard(onExit = vi.fn()) {
+function renderWizard(onExit = vi.fn(), sourceWallets = wallets) {
return {
onExit,
...renderHook(
() =>
useOnchainSendWizard({
- wallets,
+ wallets: sourceWallets,
walletsError: null,
issuedTokenSymbolsByMint: {},
counterpartyId: "counterparty-test",
@@ -213,6 +213,56 @@ afterEach(() => {
});
describe("useOnchainSendWizard", () => {
+ it("resets unsupported assets when switching to Fireblocks BYOK and rejects stale selections", async () => {
+ const balances = [SOL_MINT, WELL_KNOWN_TOKENS.USDC.mints.devnet.address, SECOND_MINT].map(
+ (mint) => ({
+ mint,
+ token: mint === SOL_MINT ? "SOL" : "USDC",
+ amount: "1000000",
+ uiAmount: "1",
+ decimals: 6,
+ })
+ );
+ const sourceWallets: PaymentsDashboardWallet[] = [
+ { ...wallets[0], id: "managed", provider: "fireblocks", balances },
+ {
+ ...wallets[0],
+ id: "byok",
+ provider: "fireblocks",
+ custodyConnectionId: "cconn_fb",
+ balances,
+ },
+ ];
+ const fallback = fetchMock.getMockImplementation();
+ if (!fallback) throw new Error("Missing fetch fixture");
+ fetchMock.mockImplementation((input, init) =>
+ String(input) === "/api/dashboard/wallets?view=summary&includeBalances=true"
+ ? Promise.resolve(Response.json({ data: { wallets: sourceWallets } }))
+ : fallback(input, init)
+ );
+ const { result } = renderWizard(vi.fn(), sourceWallets);
+ await resolveAccounts();
+ act(() => {
+ result.current.setField("accountId", cryptoAccount.id);
+ result.current.selectWallet("managed");
+ });
+ act(() => {
+ result.current.setField("asset", SECOND_MINT);
+ result.current.setField("amount", "0.5");
+ });
+ expect(result.current.readySubmission?.token).toBe(SECOND_MINT);
+ act(() => result.current.selectWallet("byok"));
+ expect(result.current.assetOptions.map((option) => option.value)).toEqual([
+ SOL_MINT,
+ WELL_KNOWN_TOKENS.USDC.mints.devnet.address,
+ ]);
+ expect(result.current.fields.asset).toBe(SOL_MINT);
+ act(() => result.current.setField("asset", SECOND_MINT));
+ expect(result.current.selectedAssetBalance).toBeNull();
+ expect(result.current.readySubmission).toBeNull();
+ expect(transferPosts()).toHaveLength(0);
+ });
+
it("loads active crypto destinations and holds the details step until it is complete", async () => {
const { result, onExit } = renderWizard();
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.ts b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.ts
index 5789df88a1..12cff01d65 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.ts
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.ts
@@ -3,6 +3,7 @@
import { compareDecimalAmounts } from "@sdp/solana/amount";
import type { CounterpartyAccount } from "@sdp/types/counterparties";
import type { PaymentsDashboardWallet, PaymentTransferSummary } from "@sdp/types/payments";
+import { CLUSTER_BY_SDP_ENVIRONMENT, type SolanaCluster } from "@sdp/types/well-known-tokens";
import { address } from "@solana/kit";
import { CoinsIcon, DollarSignIcon, WalletIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -20,19 +21,21 @@ import {
onchainDetailsSchema,
onchainSendSchema,
} from "@/app/dashboard/[projectId]/payments/ramps/schema";
-import { walletBalanceAssetOptions } from "@/app/dashboard/[projectId]/payments/ramps/wallet-options";
+import { onchainPaymentAssetOptions } from "@/app/dashboard/[projectId]/payments/ramps/wallet-options";
import {
optionalDetail,
summaryAmount,
} from "@/app/dashboard/[projectId]/payments/ramps/wizard-summary";
import { sendTransferUnderKey } from "@/app/dashboard/[projectId]/payments/transfer-idempotency";
import type { WizardSummaryDetail } from "@/app/dashboard/[projectId]/payments/wizard-summary-list";
+import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context";
import type { MessageKey } from "@/i18n/messages";
import { useLocale, useTranslations } from "@/i18n/provider";
import type { TranslationValues } from "@/i18n/translate";
import { isMovementInFlight } from "@/lib/idempotency";
import { useProjectHref } from "@/lib/use-dashboard-project";
import { useZodForm } from "@/lib/use-zod-form";
+import { useFireblocksPaymentAdvisory } from "./use-fireblocks-payment-advisory";
import { usePaymentsActionWallets } from "./use-payments-action-wallets";
import type { RampWizardStep } from "./use-ramp-wizard";
@@ -187,6 +190,24 @@ function useOnchainSubmission(signingUnavailable: boolean, t: Translate) {
try {
const { transfer } = await sendTransferUnderKey(submission, t);
setTransferResult(transfer);
+ if (transfer.status === "failed") {
+ toast.error(t("DashboardPayments.onchainSend.transferFailed"), {
+ id: toastId,
+ description: transfer.error ?? undefined,
+ });
+ return;
+ }
+ if (transfer.fireblocks && !transfer.signature) {
+ toast.info(t("DashboardPayments.onchainSend.transferSubmitted"), {
+ id: toastId,
+ description: t(
+ transfer.fireblocks.transactionId
+ ? "DashboardPayments.onchainSend.fireblocksPending"
+ : "DashboardPayments.onchainSend.fireblocksUnknown"
+ ),
+ });
+ return;
+ }
toast.success(t("DashboardPayments.onchainSend.transferSubmitted"), {
id: toastId,
description: submittedTransferDescription(transfer, t),
@@ -213,11 +234,12 @@ function useOnchainAssetSelection(
selectedWallet: PaymentsDashboardWallet | null,
fields: OnchainSendFields,
issuedTokenSymbolsByMint: Record,
- t: Translate
+ t: Translate,
+ cluster: SolanaCluster
) {
const assetOptions = useMemo(
- () => walletBalanceAssetOptions(selectedWallet, issuedTokenSymbolsByMint, t),
- [issuedTokenSymbolsByMint, selectedWallet, t]
+ () => onchainPaymentAssetOptions(selectedWallet, issuedTokenSymbolsByMint, t, cluster),
+ [issuedTokenSymbolsByMint, selectedWallet, t, cluster]
);
const selectedAsset = useMemo(() => {
const asset = assetOptions.find((candidate) => candidate.value === fields.asset);
@@ -225,12 +247,16 @@ function useOnchainAssetSelection(
}, [assetOptions, fields.asset]);
const selectedAssetBalance = useMemo(() => {
- if (selectedWallet === null || selectedWallet.balances === undefined) {
+ if (
+ selectedAsset === null ||
+ selectedWallet === null ||
+ selectedWallet.balances === undefined
+ ) {
return null;
}
const balance = selectedWallet.balances.find((candidate) => candidate.mint === fields.asset);
return balance === undefined ? null : balance;
- }, [selectedWallet, fields.asset]);
+ }, [selectedWallet, fields.asset, selectedAsset]);
let availableAmount: string | null;
if (selectedWallet === null) {
@@ -256,6 +282,8 @@ export function useOnchainSendWizard({
const href = useProjectHref();
const t = useTranslations();
const locale = useLocale();
+ const { sdpEnvironment } = useDashboardWorkspace();
+ const cluster = CLUSTER_BY_SDP_ENVIRONMENT[sdpEnvironment];
const steps = getOnchainSendSteps(t);
const [stepIndex, setStepIndex] = useState(0);
const { values: fields, setField } = useZodForm(onchainSendSchema, {
@@ -306,13 +334,24 @@ export function useOnchainSendWizard({
t
);
const { assetOptions, selectedAsset, selectedAssetBalance, availableAmount, exceedsBalance } =
- useOnchainAssetSelection(selectedWallet, fields, issuedTokenSymbolsByMint, t);
+ useOnchainAssetSelection(selectedWallet, fields, issuedTokenSymbolsByMint, t, cluster);
+ useFireblocksPaymentAdvisory({
+ wallet: selectedWallet,
+ signingUnavailable,
+ submitting,
+ finished,
+ walletId: fields.walletId,
+ counterpartyId,
+ account: selectedAccount,
+ balance: selectedAssetBalance,
+ address: destinationAddress,
+ });
const selectWallet = (walletId: string) => {
setField("walletId", walletId);
const matchingWallet = liveWallets.find((wallet) => wallet.id === walletId);
const nextWallet = matchingWallet === undefined ? null : matchingWallet;
- const nextAssets = walletBalanceAssetOptions(nextWallet, issuedTokenSymbolsByMint, t);
+ const nextAssets = onchainPaymentAssetOptions(nextWallet, issuedTokenSymbolsByMint, t, cluster);
const nextAsset = nextAssetAfterWalletChange(fields.asset, nextAssets);
if (nextAsset !== fields.asset) {
setField("asset", nextAsset);
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.unit.test.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.unit.test.tsx
index 13f41f766e..bb38bff440 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.unit.test.tsx
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/use-onchain-send-wizard.unit.test.tsx
@@ -2,7 +2,7 @@
import type { CounterpartyAccount } from "@sdp/types/counterparties";
import type { PaymentsDashboardWallet, PaymentTransferSummary } from "@sdp/types/payments";
-import { SOL_MINT } from "@sdp/types/well-known-tokens";
+import { SOL_MINT, WELL_KNOWN_TOKENS } from "@sdp/types/well-known-tokens";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { SWRConfig } from "swr";
@@ -116,14 +116,13 @@ function wrapper({ children }: { children: ReactNode }) {
);
}
-function renderWizard() {
- const onExit = vi.fn();
+function renderWizard(onExit = vi.fn(), sourceWallets = wallets) {
return {
onExit,
...renderHook(
() =>
useOnchainSendWizard({
- wallets,
+ wallets: sourceWallets,
walletsError: null,
issuedTokenSymbolsByMint: {},
counterpartyId: "counterparty-test",
@@ -209,6 +208,56 @@ afterEach(() => {
});
describe("useOnchainSendWizard", () => {
+ it("resets unsupported assets when switching to Fireblocks BYOK and rejects stale selections", async () => {
+ const balances = [SOL_MINT, WELL_KNOWN_TOKENS.USDC.mints.devnet.address, SECOND_MINT].map(
+ (mint) => ({
+ mint,
+ token: mint === SOL_MINT ? "SOL" : "USDC",
+ amount: "1000000",
+ uiAmount: "1",
+ decimals: 6,
+ })
+ );
+ const sourceWallets: PaymentsDashboardWallet[] = [
+ { ...wallets[0], id: "managed", provider: "fireblocks", balances },
+ {
+ ...wallets[0],
+ id: "byok",
+ provider: "fireblocks",
+ custodyConnectionId: "cconn_fb",
+ balances,
+ },
+ ];
+ const fallback = fetchMock.getMockImplementation();
+ if (!fallback) throw new Error("Missing fetch fixture");
+ fetchMock.mockImplementation((input, init) =>
+ String(input) === "/api/dashboard/wallets?view=summary&includeBalances=true"
+ ? Promise.resolve(Response.json({ data: { wallets: sourceWallets } }))
+ : fallback(input, init)
+ );
+ const { result } = renderWizard(vi.fn(), sourceWallets);
+ await resolveAccounts();
+ act(() => {
+ result.current.setField("accountId", cryptoAccount.id);
+ result.current.selectWallet("managed");
+ });
+ act(() => {
+ result.current.setField("asset", SECOND_MINT);
+ result.current.setField("amount", "0.5");
+ });
+ expect(result.current.readySubmission?.token).toBe(SECOND_MINT);
+ act(() => result.current.selectWallet("byok"));
+ expect(result.current.assetOptions.map((option) => option.value)).toEqual([
+ SOL_MINT,
+ WELL_KNOWN_TOKENS.USDC.mints.devnet.address,
+ ]);
+ expect(result.current.fields.asset).toBe(SOL_MINT);
+ act(() => result.current.setField("asset", SECOND_MINT));
+ expect(result.current.selectedAssetBalance).toBeNull();
+ expect(result.current.readySubmission).toBeNull();
+ expect(transferPosts()).toHaveLength(0);
+ });
+
it("loads active crypto destinations and navigates destination steps", async () => {
const { result, onExit } = renderWizard();
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/wallet-signing.behavior.unit.test.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/wallet-signing.behavior.unit.test.tsx
index 9025733353..94ae0622b8 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/wallet-signing.behavior.unit.test.tsx
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/wallet-signing.behavior.unit.test.tsx
@@ -15,6 +15,9 @@ import { useOnchainSendWizard } from "./use-onchain-send-wizard";
vi.mock("next/navigation", () => import("@/test/next-navigation"));
vi.mock("@/contexts/dashboard-workspace-context", () => ({
+ useOptionalDashboardWorkspace: () => ({
+ dashboardCacheScope: { orgId: "org_test", userId: "user_test" },
+ }),
useDashboardWorkspace: () => ({
dashboardCacheScope: { orgId: "org_test", userId: "user_test" },
selectedProjectId: "proj_test",
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/wallet-signing.redesign.behavior.unit.test.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/wallet-signing.redesign.behavior.unit.test.tsx
index fe29a0912e..5e55aa80c9 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/wallet-signing.redesign.behavior.unit.test.tsx
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/hooks/wallet-signing.redesign.behavior.unit.test.tsx
@@ -14,6 +14,9 @@ import { useOnchainSendWizard } from "./use-onchain-send-wizard.redesign";
vi.mock("next/navigation", () => import("@/test/next-navigation"));
vi.mock("@/contexts/dashboard-workspace-context", () => ({
+ useOptionalDashboardWorkspace: () => ({
+ dashboardCacheScope: { orgId: "org_test", userId: "user_test" },
+ }),
useDashboardWorkspace: () => ({
dashboardCacheScope: { orgId: "org_test", userId: "user_test" },
selectedProjectId: "proj_test",
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/wallet-options.ts b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/wallet-options.ts
index f3dc8850f9..bce61779ea 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/wallet-options.ts
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/wallet-options.ts
@@ -1,5 +1,7 @@
import { compareDecimalAmounts, isDecimalString } from "@sdp/solana/amount";
+import { isFireblocksPaymentMint } from "@sdp/types/fireblocks";
import type { PaymentsDashboardWallet } from "@sdp/types/payments";
+import type { SolanaCluster } from "@sdp/types/well-known-tokens";
import type { ComboboxOption } from "@sdp/ui/combobox";
import {
formatCurrencyAmount,
@@ -111,6 +113,19 @@ export function walletPickerLabel(
return wallet.label ?? wallet.publicKey;
}
+/** Native Fireblocks BYOK payments have narrower asset support than wallet balances. */
+export function onchainPaymentAssetOptions(
+ wallet: PaymentsDashboardWallet | null,
+ issuedTokenSymbolsByMint: IssuedTokenSymbolsByMint,
+ t: Translate,
+ cluster: SolanaCluster
+): ComboboxOption[] {
+ const options = walletBalanceAssetOptions(wallet, issuedTokenSymbolsByMint, t);
+ return wallet?.provider === "fireblocks" && wallet.custodyConnectionId
+ ? options.filter((option) => isFireblocksPaymentMint(option.value, cluster))
+ : options;
+}
+
export function walletComboboxOptions(
wallets: PaymentsDashboardWallet[],
signingUnavailableLabel?: string,
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/wallet-options.unit.test.ts b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/wallet-options.unit.test.ts
index 7e49def6a2..7177bbf1b5 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/wallet-options.unit.test.ts
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/ramps/wallet-options.unit.test.ts
@@ -1,6 +1,7 @@
import type { PaymentsDashboardWallet } from "@sdp/types/payments";
+import { SOL_MINT, SOLANA_CLUSTERS, WELL_KNOWN_TOKENS } from "@sdp/types/well-known-tokens";
import { describe, expect, it } from "vitest";
-import { walletComboboxOptions } from "./wallet-options";
+import { onchainPaymentAssetOptions, walletComboboxOptions } from "./wallet-options";
describe("walletComboboxOptions", () => {
it("uses exact wallet ids and keeps duplicate Provider ids selectable", () => {
@@ -80,3 +81,33 @@ describe("walletComboboxOptions", () => {
expect(walletComboboxOptions(wallets, "Restricted")[1]).not.toHaveProperty("disabled");
});
});
+
+describe("onchainPaymentAssetOptions", () => {
+ it.each(SOLANA_CLUSTERS)("limits Fireblocks BYOK to canonical SOL and USDC on %s", (cluster) => {
+ const usdc = WELL_KNOWN_TOKENS.USDC.mints[cluster].address;
+ const wrongClusterUsdc =
+ WELL_KNOWN_TOKENS.USDC.mints[cluster === "devnet" ? "mainnet-beta" : "devnet"].address;
+ const wallet: PaymentsDashboardWallet = {
+ id: "cwlt_fireblocks",
+ walletId: "fb_0",
+ publicKey: "address",
+ label: "Treasury",
+ provider: "fireblocks",
+ custodyConnectionId: "cconn_fireblocks",
+ isRuntimeExecutionAllowed: true,
+ balances: [SOL_MINT, usdc, wrongClusterUsdc, "unsupported-mint"].map((mint) => ({
+ mint,
+ token: mint === SOL_MINT ? "SOL" : "USDC",
+ amount: "1000000000",
+ uiAmount: "1",
+ decimals: 9,
+ })),
+ };
+ const options = (value: PaymentsDashboardWallet) =>
+ onchainPaymentAssetOptions(value, {}, (key) => key, cluster).map((option) => option.value);
+ expect(options(wallet)).toEqual([SOL_MINT, usdc]);
+ // Preserve other custody paths, including managed Fireblocks and Privy BYOK.
+ expect(options({ ...wallet, custodyConnectionId: undefined })).toHaveLength(4);
+ expect(options({ ...wallet, provider: "privy" })).toHaveLength(4);
+ });
+});
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/transaction-detail-workspace.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/transaction-detail-workspace.tsx
index b30b1ecacb..4f92fdfc96 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/transaction-detail-workspace.tsx
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/transaction-detail-workspace.tsx
@@ -41,6 +41,7 @@ import { RampOpenSale, useLiveRampTransfer } from "./ramp-open-sale";
import type { PaymentTransferDetail } from "./transaction-detail.data";
import { TRANSACTION_MODULE_HREFS, walletHref } from "./transaction-module-hrefs.redesign";
import { isPaymentTransferStatus, useTransactionStatus } from "./transaction-status";
+import { usePendingPaymentRefresh } from "./use-pending-payment-refresh";
type Translate = ReturnType;
@@ -89,6 +90,12 @@ function paymentWhy(
): string {
switch (status) {
case "processing":
+ if (transfer?.fireblocks)
+ return t(
+ transfer.fireblocks.transactionId
+ ? "DashboardPayments.onchainSend.fireblocksPending"
+ : "DashboardPayments.onchainSend.fireblocksUnknown"
+ );
return t("DashboardPayments.transactionDetail.why.processing");
case "settling":
return t("DashboardPayments.transactionDetail.why.settling");
@@ -495,6 +502,7 @@ function TransactionRecord({
signature: liveTransfer.signature,
}
: initialTransaction;
+ usePendingPaymentRefresh(transaction, transfer?.type);
const summary = useTransactionSummary(
transaction,
transfer,
@@ -541,6 +549,24 @@ function TransactionRecord({
direction={summary.direction}
contact={summary.contact}
/>
+ {transfer?.fireblocks ? (
+
+
+ {transfer.fireblocks.transactionId ? (
+
+ {transfer.fireblocks.transactionId}
+
+ ) : null}
+
+ {transfer.fireblocks.status}
+ {transfer.fireblocks.subStatus ? ` · ${transfer.fireblocks.subStatus}` : ""}
+
+
+
+ ) : null}
{transfer?.provider ? (
) : null}
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/transaction-detail.data.ts b/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/transaction-detail.data.ts
index 7f09d09b73..ce3251769d 100644
--- a/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/transaction-detail.data.ts
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/transaction-detail.data.ts
@@ -54,6 +54,19 @@ export async function fetchTransactionDetail(
);
const envelope = response.ok ? ((await response.json()) as PaymentTransferEnvelope) : null;
const transfer = (envelope?.data?.transfer as PaymentTransferDetail | undefined) ?? null;
+ // The provider read can advance the payment after the ledger query above.
+ if (transfer?.fireblocks)
+ transaction = {
+ ...transaction,
+ moduleStatus: transfer.status,
+ signature: transfer.signature,
+ status:
+ transfer.status === "failed"
+ ? "failed"
+ : ["confirmed", "finalized"].includes(transfer.status)
+ ? "succeeded"
+ : "pending",
+ };
return { status: "found", transaction, transfer };
} catch {
return { status: "found", transaction, transfer: null };
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/use-pending-payment-refresh.ts b/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/use-pending-payment-refresh.ts
new file mode 100644
index 0000000000..835cfd6d41
--- /dev/null
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/use-pending-payment-refresh.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { isRampTransferType, type PaymentTransferType } from "@sdp/types/payments";
+import type { UnifiedTransaction } from "@sdp/types/unified-transactions";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+/** Refresh pending payments; ramps already poll through useLiveRampTransfer. */
+export function usePendingPaymentRefresh(
+ transaction: Pick,
+ transferType: PaymentTransferType | undefined
+): void {
+ const router = useRouter();
+ const hasRampPoller = transferType !== undefined && isRampTransferType(transferType);
+ const waitingForPayment =
+ transaction.module === "payments" && transaction.status === "pending" && !hasRampPoller;
+
+ useEffect(() => {
+ if (!waitingForPayment) return;
+ const timer = setInterval(() => {
+ if (document.visibilityState === "visible") router.refresh();
+ }, 15_000);
+ return () => clearInterval(timer);
+ }, [waitingForPayment, router]);
+}
diff --git a/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/use-pending-payment-refresh.unit.test.tsx b/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/use-pending-payment-refresh.unit.test.tsx
new file mode 100644
index 0000000000..4b60d2b6f8
--- /dev/null
+++ b/apps/sdp-web/src/app/dashboard/[projectId]/payments/transactions/use-pending-payment-refresh.unit.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+
+import type { UnifiedTransaction } from "@sdp/types/unified-transactions";
+import { act, cleanup, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { dashboardRouter, resetDashboardNavigation } from "@/test/dashboard-navigation";
+import { usePendingPaymentRefresh } from "./use-pending-payment-refresh";
+
+vi.mock("next/navigation", () => import("@/test/next-navigation"));
+
+const pending: Pick = {
+ module: "payments",
+ status: "pending",
+};
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ resetDashboardNavigation();
+ vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe("pending payment refresh", () => {
+ it("refreshes a pending transfer until settlement, then stops", () => {
+ const view = renderHook((transaction) => usePendingPaymentRefresh(transaction, "transfer"), {
+ initialProps: pending,
+ });
+ act(() => vi.advanceTimersByTime(15_000));
+ expect(dashboardRouter.refresh).toHaveBeenCalledOnce();
+
+ view.rerender({ ...pending, status: "succeeded" });
+ act(() => vi.advanceTimersByTime(30_000));
+ expect(dashboardRouter.refresh).toHaveBeenCalledOnce();
+ });
+
+ it("keeps recovering a pending payment when its transfer details are unavailable", () => {
+ renderHook(() => usePendingPaymentRefresh(pending, undefined));
+ act(() => vi.advanceTimersByTime(15_000));
+ expect(dashboardRouter.refresh).toHaveBeenCalledOnce();
+ });
+
+ it.each(["onramp", "offramp"] as const)(
+ "leaves %s refreshes to the existing ramp poller",
+ (type) => {
+ renderHook(() => usePendingPaymentRefresh(pending, type));
+ act(() => vi.advanceTimersByTime(30_000));
+ expect(dashboardRouter.refresh).not.toHaveBeenCalled();
+ }
+ );
+
+ it("does not refresh another module's pending transaction", () => {
+ renderHook(() => usePendingPaymentRefresh({ ...pending, module: "issuance" }, undefined));
+ act(() => vi.advanceTimersByTime(30_000));
+ expect(dashboardRouter.refresh).not.toHaveBeenCalled();
+ });
+
+ it("pauses while hidden and stops after leaving the transaction page", () => {
+ const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
+ const view = renderHook(() => usePendingPaymentRefresh(pending, "transfer"));
+ act(() => vi.advanceTimersByTime(15_000));
+ expect(dashboardRouter.refresh).not.toHaveBeenCalled();
+
+ visibility.mockReturnValue("visible");
+ act(() => vi.advanceTimersByTime(15_000));
+ expect(dashboardRouter.refresh).toHaveBeenCalledOnce();
+
+ view.unmount();
+ act(() => vi.advanceTimersByTime(30_000));
+ expect(dashboardRouter.refresh).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/sdp-web/src/lib/api-playground-catalog.generated.json b/apps/sdp-web/src/lib/api-playground-catalog.generated.json
index 5debb65aad..9e9804cf6a 100644
--- a/apps/sdp-web/src/lib/api-playground-catalog.generated.json
+++ b/apps/sdp-web/src/lib/api-playground-catalog.generated.json
@@ -575,6 +575,11 @@
"expectedResponse": {
"data": {
"transfer": {
+ "fireblocks": {
+ "transactionId": "transaction_example",
+ "status": "example",
+ "subStatus": "example"
+ },
"id": "xfr_example",
"organizationId": "org_example",
"custodyWalletId": "cwlt_example",
@@ -858,6 +863,11 @@
"expectedResponse": {
"data": [
{
+ "fireblocks": {
+ "transactionId": "transaction_example",
+ "status": "example",
+ "subStatus": "example"
+ },
"id": "xfr_example",
"organizationId": "org_example",
"custodyWalletId": "cwlt_example",
@@ -956,6 +966,11 @@
"expectedResponse": {
"data": {
"transfer": {
+ "fireblocks": {
+ "transactionId": "transaction_example",
+ "status": "example",
+ "subStatus": "example"
+ },
"id": "xfr_example",
"organizationId": "org_example",
"custodyWalletId": "cwlt_example",
@@ -1125,6 +1140,11 @@
],
"transfers": [
{
+ "fireblocks": {
+ "transactionId": "transaction_example",
+ "status": "example",
+ "subStatus": "example"
+ },
"id": "xfr_example",
"organizationId": "org_example",
"custodyWalletId": "cwlt_example",
@@ -1364,6 +1384,11 @@
],
"transfers": [
{
+ "fireblocks": {
+ "transactionId": "transaction_example",
+ "status": "example",
+ "subStatus": "example"
+ },
"id": "xfr_example",
"organizationId": "org_example",
"custodyWalletId": "cwlt_example",
@@ -1930,6 +1955,11 @@
"updatedAt": "2025-01-01T00:00:00.000Z"
},
"transfer": {
+ "fireblocks": {
+ "transactionId": "transaction_example",
+ "status": "example",
+ "subStatus": "example"
+ },
"id": "xfr_example",
"organizationId": "org_example",
"custodyWalletId": "cwlt_example",
diff --git a/apps/sdp-web/src/lib/use-fireblocks-warning.ts b/apps/sdp-web/src/lib/use-fireblocks-warning.ts
new file mode 100644
index 0000000000..822ca8635f
--- /dev/null
+++ b/apps/sdp-web/src/lib/use-fireblocks-warning.ts
@@ -0,0 +1,48 @@
+"use client";
+
+import { useCallback, useSyncExternalStore } from "react";
+import { useOptionalDashboardWorkspace } from "@/contexts/dashboard-workspace-context";
+import { getDashboardCacheScopeKey } from "@/lib/dashboard-cache-scope";
+import { useProjectId } from "@/lib/use-dashboard-project";
+
+const CHANGE = "sdp:fireblocks-warning-dismissed";
+function subscribe(onChange: () => void) {
+ window.addEventListener(CHANGE, onChange);
+ window.addEventListener("storage", onChange);
+ return () => {
+ window.removeEventListener(CHANGE, onChange);
+ window.removeEventListener("storage", onChange);
+ };
+}
+
+/** No expiry; preferences belong to this user, organization and project on this browser. */
+export function useFireblocksWarning(kind: "whitelist" | "policy") {
+ const workspace = useOptionalDashboardWorkspace();
+ const projectId = useProjectId();
+ const key =
+ workspace && projectId
+ ? `sdp:fireblocks-warning:v1:${getDashboardCacheScopeKey(workspace.dashboardCacheScope, { projectId })}:${kind}`
+ : null;
+ const suppressed = useSyncExternalStore(
+ subscribe,
+ () => {
+ try {
+ return key !== null && window.localStorage.getItem(key) === "dismissed";
+ } catch {
+ return false;
+ }
+ },
+ () => false
+ );
+ const suppress = useCallback((): boolean => {
+ if (!key) return false;
+ try {
+ window.localStorage.setItem(key, "dismissed");
+ window.dispatchEvent(new Event(CHANGE));
+ return true;
+ } catch {
+ return false;
+ }
+ }, [key]);
+ return { suppressed, suppress, canSuppress: key !== null, scopeKey: key };
+}
diff --git a/docs/decisions/0007-money-admission.md b/docs/decisions/0007-money-admission.md
index 3899063300..cd39596b78 100644
--- a/docs/decisions/0007-money-admission.md
+++ b/docs/decisions/0007-money-admission.md
@@ -106,3 +106,41 @@ included:
- **Deactivating custody rows on deletion only**: blocks exits and covers only
deletion, not entitlement.
- **Hand-kept route lists or source-text tests**: drift silently (HOO-1990).
+
+## Proposed exception: Fireblocks native transfers (PR #2455)
+
+**Status: proposed; requires named security-owner approval before merge.** This
+section does not change the accepted signing rule above and is not evidence of
+security sign-off. The new `fireblocks-native-transfer` allowlist entry in
+`scripts/check-value-movement.mjs` needs the same explicit approval.
+
+Fireblocks BYOK uses native `TRANSFER` for SOL and USDC. SDP checks the initiating
+principal's live status, grants, wallet scope, organization, project entitlement
+and connection before creating the transfer. Fireblocks then owns construction,
+approval, signing, fee payment and broadcast. SDP has no execution callback at
+which to re-run `decideMovement` immediately before Fireblocks signs.
+
+The proposed boundary treats an accepted Fireblocks request as an externally
+committed payment: later SDP key revocation, membership removal, organization
+suspension, entitlement changes or connection deactivation prevent new SDP
+submissions but do not revoke that already accepted instruction. An unknown POST
+outcome may also have been accepted and must be treated the same way. Customers
+must reject or cancel pending transfers in Fireblocks when revoking access to
+contain an incident. A cancellation request can race signing or broadcasting and
+cannot guarantee that funds remain in the source vault.
+
+Compensating controls are tenant-scoped server-held API credentials, an Editor
+API user, Fireblocks-managed approval rules and designated signers, a durable
+external transaction ID with no automatic resubmission, exact settlement-intent
+checks, beta gating, and audit logs. These controls do not close the revocation
+race. SDP does not claim that its API user's RSA authentication key approves or
+signs blockchain transfers, and does not change Fireblocks policies for users.
+
+Alternatives: disable native transfers; use a provider signing flow with a
+supported enforceable execution callback; or add best-effort cancellation on SDP
+revocation as an additional mitigation. Cancellation alone does not satisfy the
+accepted atomic pre-sign admission requirement.
+
+Approval record (must be completed by the security owner): reviewer name,
+review date, PR/comment link, accepted exposure, and approval of both this
+exception and the `fireblocks-native-transfer` allowlist entry.
diff --git a/docs/ops/fireblocks-byok.md b/docs/ops/fireblocks-byok.md
new file mode 100644
index 0000000000..c7876a981a
--- /dev/null
+++ b/docs/ops/fireblocks-byok.md
@@ -0,0 +1,324 @@
+# Fireblocks BYOK
+
+Fireblocks **BYOK is beta**. Set `SDP_RELEASE_CHANNEL=beta` (or `experimental`) on the
+API deployment to offer it. The dashboard consumes the API's provider availability;
+stable refuses new registrations, installation, credential rotation, wallet creation,
+signing, and payment submission. Existing payments still reconcile through read-only
+provider requests, and cancellation/deactivation remain available. Managed Fireblocks
+custody is also beta on current main. Release channel and Solana network are independent:
+a beta deployment can connect to mainnet.
+
+Fireblocks connections use the same project-scoped encrypted credential lifecycle as Privy:
+installation, credential checks, replacement, rotation, rollback, deactivation, and secret
+retirement. Organization entitlement to Fireblocks is still required. Deploy the Fireblocks migrations `0153`, `0154`, `0156` and `0157` before the API. They extend
+the GCP reservation and cleanup constraints, enforce one unfinished Fireblocks installation,
+and add tenant-isolated, encrypted CSR registration storage.
+
+If a local development database already applied `0152_fireblocks_byok.sql` from an
+earlier PR revision, record its new filename before running migrations. Its SQL is
+unchanged; `main` now uses `0152` for recurring-payment expiry. This prevents replaying
+the Fireblocks constraint changes after their validation migration already ran:
+
+```sql
+UPDATE schema_migrations
+SET version = '0154_fireblocks_byok.sql'
+WHERE version = '0152_fireblocks_byok.sql'
+ AND NOT EXISTS (
+ SELECT 1 FROM schema_migrations WHERE version = '0154_fireblocks_byok.sql'
+ );
+```
+
+If a development database already applied an earlier PR version of `0153` without
+the status column, apply this compatibility update before starting the new API.
+Preserve the database and payment receipts used for mainnet testing.
+
+```sql
+ALTER TABLE fireblocks_csr_requests
+ ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'
+ CHECK (status IN ('active', 'archived'));
+UPDATE fireblocks_csr_requests SET status = 'archived'
+ WHERE encrypted_private_key IS NULL;
+```
+
+## Connect a workspace
+
+In the dashboard, choose **Generate and download CSR**. SDP generates a dedicated RSA-4096
+key on the server for this organization, project, and registration attempt; only the public
+PKCS#10 CSR is returned. No signing key or CSR is shared across customer organizations.
+The dashboard presents this registration walkthrough inline:
+
+1. In Fireblocks Console, open **Developer Center → API Users → Add API user**.
+2. Name the API user **SDP** and choose **Editor**. Keep the customer's designated signer in
+ the transaction policy. A new API Co-signer is not required for this integration.
+3. Upload the downloaded CSR, select **Add user**, and complete the workspace's required
+ approvals. Wait for the API user to become active.
+4. Copy **API User (ID)** (also called the API key) into SDP and choose **Load vaults**.
+5. Select the vaults to connect by name, then choose **Connect and verify**. SDP imports each
+ selected Solana wallet and defaults its label to the Fireblocks vault name. Users can rename
+ these wallets afterward. No manual vault-ID entry is needed.
+
+Never import a customer's private key. This follows Fireblocks' [third-party integration
+guidance](https://developers.fireblocks.com/docs/integrate-fireblocks-with-third-party-service-providers).
+
+Discovery uses the project's generated signing key and API user ID to call the real
+`GET /v1/vault/accounts_paged` endpoint, filtered to the project's Solana asset, with explicit
+50-vault pagination. It returns only IDs, names, and Solana availability. It does not consume
+the CSR or persist the API user ID. Only configured server endpoints are contacted; provider
+pagination URLs are never followed. Up to 20 vaults can be selected per connection. Completion
+re-reads their names, verifies every address through solana-keychain, and imports the entire
+selection atomically under one credential. Vaults already connected in the project are refused.
+
+The API accepts only a generated signing-key reference, API user ID, and selected vault IDs. Generation
+is authenticated, entitlement-gated, rate-limited, audited, and idempotent. Registrations are
+bound to their organization and project, expire after seven days, and can be resumed after a
+reload. The private key is encrypted using the custody cipher (KMS in managed deployments;
+local AES for self-hosted deployments). It moves into the existing credential secret lifecycle
+on submission; the staging copy is erased after durable storage. The secret-retirement worker
+erases expired staging copies in bounded batches. Responses expose only public CSR data and,
+for stored credentials, the API user ID suffix. Rotations generate a new key and CSR.
+
+The project environment selects the asset: `SOL_TEST` for sandbox and `SOL` for production.
+The encrypted credential pins that choice. The API endpoint comes only from the deployment's
+`FIREBLOCKS_API_BASE_URL`; credential submissions cannot override either the endpoint or asset.
+The endpoint must serve the workspace owning the API user.
+
+Verification reads each vault address using `FireblocksSigner` from
+`@solana/keychain-fireblocks` and attaches those existing wallets. It does not create a vault,
+propose a transaction, or transfer funds. Later wallet creation uses the same connection's
+credentials to create another vault and Solana asset wallet.
+
+Rotation and rollback verify the pinned primary vault, network, and every attached wallet's
+Solana address, including inactive wallets. Each distinct vault is read once through
+solana-keychain using the replacement credential. Missing permissions or changed addresses
+leave the current credential in place; temporary provider failures remain retryable. Before
+committing, SDP locks and rechecks the wallet set so a concurrent import or address change
+cannot bypass verification. API user keys may change, but a credential cannot silently
+redirect the connection to another vault or workspace. Each signing request also checks the
+requested wallet's ownership and signer address. Deactivation in SDP does not revoke the
+API user in Fireblocks.
+
+## Transaction approval
+
+Direct SOL and USDC payments through `POST /v1/payments/transfers` use Fireblocks'
+[TRANSFER operation](https://developers.fireblocks.com/api-reference/transactions/create-a-new-transaction).
+SDP resolves the exact project wallet and its encrypted BYOK credential. Fireblocks builds the
+transaction, applies the workspace's approvals, signs and broadcasts. The source vault needs
+SOL for fees (also when sending USDC); SDP's local/Kora fee sponsor is not involved.
+No RAW-signing permission or SDP Signer check is required for these payments.
+
+USDC is resolved by its exact Solana mint (`issuerAddress`) and `nativeAsset` in Fireblocks'
+asset catalogue, never by symbol alone. The Pay token picker offers only native SOL and the
+current network's canonical USDC mint for Fireblocks BYOK. Other tokens and ramp-deposit
+reuse are rejected by this direct-payment path. Managed Fireblocks wallets retain their existing sponsored
+signing path. Batch and custom-program signing still use
+the existing solana-keychain adapter; they have not been converted to native transfers.
+
+SDP sends the exact selected address using `ONE_TIME_ADDRESS`. Fireblocks resolves matching
+whitelisted destinations in its workspace; unmatched destinations require one-time transfers
+to be allowed by the workspace. The read-only SDP checks do not change the submitted address.
+An SDP contact alone does not whitelist an address. Manage whitelisting in Fireblocks. The SDP memo becomes a Fireblocks note, not a Solana memo instruction.
+
+Before submission, SDP durably stores the intent and atomically claims a single create attempt.
+The SDP transfer ID is Fireblocks' permanent `externalTxId`; transaction creation does not
+send Fireblocks' time-limited `Idempotency-Key` header. SDP separately deduplicates the caller's
+request using its own idempotency key.
+Immediately before the provider POST, SDP resolves custody access again and checks the current
+connection, account, vault, source address and network against the stored intent, then rechecks
+the caller's live money authorization. A local refusal marks the intent failed without a POST;
+previously decrypted credentials are never carried from preparation into submission. This adds
+no Fireblocks or Solana RPC requests.
+The request returns `processing` while approval is pending. Dashboard transfer reads and the
+pending-transfer job reconcile with GET by external ID; they never POST. Unknown outcomes,
+including a timeout, missing proposal, or unavailable credentials, stay processing. Do not
+create a replacement until the original has been reconciled. A crash before the create call
+may require manual reconciliation; automatic resubmission is deliberately not implemented.
+
+Connection deactivation or a failed connection check blocks new payments but does not stop
+these status reads. Recovery verifies the stored connection, provider account, vault and source
+address before reading the retained active credential. It does not require an active wallet or
+a healthy connection, and it stops polling once the payment settles. Credential revocation or
+loss of provider access still requires operator intervention; disabling a connection does not
+cancel a transfer already accepted by Fireblocks.
+
+Fireblocks transaction ID, status and substatus are visible in SDP. COMPLETED with a valid
+Solana signature records confirmation only after the external ID, vault, asset, destination,
+and exact decimal payment/net amounts match the stored intent. Missing or mismatched details
+stay processing for reconciliation. The existing chain-finality job checks finalization.
+Terminal provider failures record their reason. The transaction details page refreshes every
+15 seconds while pending. The five-minute local signing timeout does not apply to these intents.
+Existing failed RAW attempts remain failed and are never retried by this implementation.
+
+Privy and other signing operations retain solana-keychain. Fireblocks' separate Signer check
+still proposes RAW signing and simulates without broadcasting; it requires RAW enabled and a
+funded SDP fee sponsor and has the keychain adapter's synchronous approval window.
+
+## Request budget
+
+All operations below make **zero Solana RPC calls** in the custody adapter:
+
+| Operation | Before | After |
+| --- | --- | --- |
+| CSR generation / download | Unsupported | Zero Fireblocks requests |
+| Named vault discovery | Manual vault-ID entry | One Fireblocks GET per page of up to 50 vaults |
+| Initial BYOK verification | One address GET for a manually entered vault | Two Fireblocks GETs per selected vault: current metadata and address; no duplicate primary-address read. Legacy single-ID submissions retain one address GET |
+| Credential check | Unsupported | One Fireblocks address GET |
+| Rotation or rollback verification | One primary-vault address GET | One Fireblocks address GET per distinct vault across the primary and attached wallets; reuse the primary result |
+| New vault provisioning | Three Fireblocks requests for managed wallets | Same three requests for managed and BYOK: vault POST, asset POST, address GET; reuse the returned address |
+| Native SOL payment | Address GET, RAW POST, approval polls, local blockhash/sponsorship/submission/confirmation RPC | One TRANSFER POST, zero Solana RPC at send; one GET per status poll, throttled to once per 15 seconds per transfer |
+| Native USDC payment | Same RAW path plus live mint/token-account reads | Same as SOL plus one static catalogue GET on a five-minute cache miss; concurrent reads shared, failures not cached |
+| First signature for a wallet | Managed: address GET, proposal POST, approval polls | Same path for managed and BYOK |
+| Subsequent signatures on a cached adapter | Managed: proposal POST and approval polls | Same path for managed and BYOK; no duplicate address GET |
+
+Credential-version changes select a fresh adapter. No provider-account address is inferred from
+deployment credentials or another connection's signer cache.
+
+Payment RPC budget for non-native-provider signing: single-payment builds still use one `getLatestBlockhash` call, moved after
+instruction preparation. Fast signing adds zero reads. Signing lasting at least 15 seconds
+adds one `isBlockhashValid` call per signed transaction (including each slow batch chunk),
+whose result gates sponsorship and broadcast. There is no automatic rebuild or signing retry.
+
+## Opt-in live tests on local mainnet
+
+`scripts/fireblocks-live.mjs` exercises a running local SDP API using an SDP API key. The
+`signer` reaches Fireblocks RAW through solana-keychain; `payment` uses native TRANSFER through SDP;
+the runner never receives or exports a Fireblocks private key. These commands do not run in CI.
+
+Before testing:
+
+1. Use a production SDP project, a mainnet RPC, and a Fireblocks endpoint matching the customer's
+ workspace region. Complete CSR registration and connect the existing SOL vault in the
+ dashboard. Enable the connection for runtime execution. Allow TRANSFER initiation and the intended one-time destination in the workspace policy.
+ Keep SOL in the source vault for network fees. RAW is only needed for the optional signer check.
+2. Fund the source vault with the payment asset and enough SOL for fees and any token-account
+ rent. Fund SDP's separate local fee payer only if running the optional RAW signer check.
+3. Create a production SDP API key bound to the selected wallet. Grant `projects:read`,
+ `wallets:read`, and `wallets:write`; payment testing additionally requires `payments:write`
+ `payments:read`, and authorization for `payment_transfer_execute`. Keep the API key locally.
+4. Copy `docs/ops/fireblocks-live.example.json` to an ignored private path, such as
+ `.secrets/fireblocks-mainnet/live-test.json` (directory mode 700, file mode 600). Fill in the
+ project ID, SDP custody wallet ID, matching mainnet RPC, and SDP fee payer. For a payment,
+ explicitly choose a recipient, mint (`SOL` for native SOL), and decimal amount. The custody
+ wallet ID is the SDP row ID, not the numeric Fireblocks vault ID.
+
+From the repository root, enter the SDP key without putting it in shell history (macOS zsh):
+
+```sh
+read -rs 'SDP_LIVE_API_KEY?SDP API key: '
+export SDP_LIVE_API_KEY
+node scripts/fireblocks-live.mjs preflight --config .secrets/fireblocks-mainnet/live-test.json
+# Optional: RAW signer check, unrelated to native TRANSFER payments.
+# node scripts/fireblocks-live.mjs signer --config .secrets/fireblocks-mainnet/live-test.json
+```
+
+`preflight` reads the project and wallet from SDP, verifies the mainnet genesis hash, and reads
+the source vault account. It does not contact Fireblocks. Only the optional signer mode
+checks the separate SDP fee payer, in the same account batch. `signer`
+additionally invokes SDP's signer check, creates a real Fireblocks RAW memo proposal, waits
+for approval, verifies the signature, and simulates it. It never broadcasts or moves funds.
+Have Fireblocks open and approve promptly because of the synchronous approval limit above.
+
+Only after reviewing the configured destination and amount, explicitly send one real payment:
+
+```sh
+node scripts/fireblocks-live.mjs payment \
+ --config .secrets/fireblocks-mainnet/live-test.json \
+ --send-mainnet --receipt .secrets/fireblocks-mainnet/payment-01.json
+unset SDP_LIVE_API_KEY
+```
+
+This mode persists the exact payment intent and an idempotency key before submitting it,
+then checks SDP's response and the mainnet signature status. It uses the same transfer route
+as the dashboard. Approve the proposal in Fireblocks; Fireblocks signs and broadcasts afterward. A pending
+result is expected while you approve. Run `node scripts/fireblocks-live.mjs status --config
+ --receipt ` to reconcile without submitting again.
+Preserve the receipt if the request times out or has an unknown outcome. Reusing the same
+command and receipt reuses the same idempotency key; a changed payment is refused. Do not
+delete or replace the local database between retries. Use a new receipt path only for an
+intentional new payment. Any SDP policy approval must also be completed in the dashboard.
+
+`node --test scripts/fireblocks-live.test.mjs` tests the runner's guards with a fake transport;
+it is not evidence of a successful live Fireblocks integration. Report live checks separately.
+
+## Verification
+
+CSR tests independently verify RSA-4096 PKCS#10 signatures with OpenSSL and check that the
+actual keychain JWT verifies against the generated CSR public key. Route tests exercise tenant
+and project isolation, idempotent generation, expiry, key retirement, encrypted storage, replay,
+network pinning, provider isolation, signed paginated vault discovery, multi-vault imports,
+provider and database failure rollback, duplicate-vault rejection,
+credential rejection, rotation and rollback, wallet ownership, additional vault provisioning,
+and GCP cleanup reservations against PostgreSQL. The signing test uses the actual keychain
+implementation with mocked Fireblocks HTTP responses and a locally generated Ed25519 signature.
+Dashboard tests cover submission, rotation, and the existing Privy lifecycle. These tests do not
+replace an approval-policy check against a real Fireblocks workspace.
+
+## Read-only checks on Pay
+
+The minimal integration retains server-generated RSA-4096 keys, CSR download, API user ID
+connection, named vault selection and native SOL/USDC transfers. SDP does not create whitelist
+entries, edit or publish Fireblocks policies, or automatically import Fireblocks contacts.
+Customers manage their address books and transfer policies in Fireblocks. Previously imported
+SDP contacts remain intact. The unused address-book and policy-proposal migrations are not
+part of this release.
+
+Selecting a destination checks its exact address, asset and network against internal and external
+Fireblocks wallets. Only a successful lookup confirming absence shows the whitelist warning.
+Pending and approved entries do not prompt. Sending uses the selected address, subject to
+Fireblocks' whitelist resolution and the customer's workspace settings.
+
+The recommended Fireblocks Editor API user cannot read active policies. Pay therefore shows
+local, dismissible guidance to configure the API user's transfer policy, approvers and designated
+signer in Fireblocks. SDP does not call the administrator-only active-policy endpoint or infer
+that a policy is missing. Customers do not need to give SDP administrative access.
+
+Each warning category has a “Don’t show again” action. The preference has no expiry and is stored
+in this browser, scoped to the signed-in user, organization and project. It survives reloads and
+sign-ins; clearing browser storage restores warnings. It neither changes Fireblocks policy nor
+suppresses actual payment failures. No backend preferences or extra management permissions are
+needed. If browser storage is unavailable, SDP reports that the preference could not be saved.
+
+The internal destination check requires a signed-in human session, project context, exact wallet access and contact read
+permission. Contact addresses are resolved on the server and compared with the selected address.
+The former whitelist, policy-read, policy setup, draft and publish endpoints are absent, including for admins.
+
+Request budget: a cold destination check makes two parallel GETs (internal/external wallets),
+shared for 30 seconds. USDC adds one asset-catalogue GET on a cold five-minute cache. Failed
+reads are never cached. Automatic policy reads are removed: one fewer provider request per
+cold policy check. Suppressed whitelist warnings skip their advisory checks; payment-time
+validation remains active. These changes add zero Solana RPC calls.
+
+## Submission recovery and access revocation
+
+HTTP 400, 401, 403, 422 and 429 create refusals, and immediate terminal Fireblocks failures,
+return `TRANSACTION_FAILED`. Retrying the same SDP key/idempotency key does not create a
+second provider transaction. Fireblocks transaction creation uses the permanent `externalTxId`
+deduplication mechanism, without an `Idempotency-Key` header; vault and asset creation use
+separate stable idempotency keys.
+
+An unknown submission is polled by its SDP transfer ID (`externalTxId`). For an unacknowledged
+intent, two authenticated 404 lookups separated by at least 24 hours close it as failed. A
+provider timeout or authorization error never counts as proof of absence. A transfer with a
+known Fireblocks transaction ID is never failed merely because a later lookup returns 404.
+There is no automatic replacement POST.
+
+Operators should search Fireblocks by the `externalTxId` or recorded transaction ID, confirm the
+source vault and destination, and inspect `fireblocks_transfer_submission_outcome_unknown`,
+`fireblocks_transfer_status_unavailable`, `fireblocks_known_transaction_missing`, and
+`fireblocks_unaccepted_transfer_expired` events. Errors include their sanitized name/code.
+Repair expired credentials or connection access to resume reconciliation. Settlement mismatches
+remain processing for investigation; never replace a payment while its outcome is unknown.
+A newly initiated payment needs explicit user intent and a new SDP idempotency key.
+
+The live runner's `status` mode reads the existing transfer directly from its receipt, including
+when the source wallet is no longer in the operational wallet list. Receipt/key and exact intent
+checks still apply. It skips execution eligibility and funding checks; `preflight`, `signer` and
+`payment` retain them. Compared with the previous status command, this removes one SDP wallet-list
+GET and one Solana `getMultipleAccounts` request; mainnet and confirmed-signature checks remain.
+
+Native transfers can execute in Fireblocks after SDP access is revoked. Cancel or reject pending
+transfers in Fireblocks as part of revocation/incident response; cancellation can race execution.
+See the **proposed, not yet approved** [ADR 0007 exception](../decisions/0007-money-admission.md#proposed-exception-fireblocks-native-transfers-pr-2455).
+
+Live-test receipts bind the payment to the SDP API key via a slow one-way fingerprint. Keep the
+same key and receipt when retrying; a changed key or legacy unbound receipt is refused. The
+result receipt stores only a validated SDP transfer ID, never a provider response or secret.
diff --git a/docs/ops/fireblocks-live.example.json b/docs/ops/fireblocks-live.example.json
new file mode 100644
index 0000000000..a46a281452
--- /dev/null
+++ b/docs/ops/fireblocks-live.example.json
@@ -0,0 +1,13 @@
+{
+ "baseUrl": "http://127.0.0.1:8787",
+ "network": "mainnet-beta",
+ "projectId": "REPLACE_WITH_PRODUCTION_PROJECT_ID",
+ "custodyWalletId": "REPLACE_WITH_CUSTODY_WALLET_ID",
+ "rpcUrl": "https://api.mainnet-beta.solana.com",
+ "feePayer": "REPLACE_WITH_LOCAL_FEE_PAYER_ADDRESS",
+ "payment": {
+ "destination": "REPLACE_WITH_YOUR_RECIPIENT_ADDRESS",
+ "token": "SOL",
+ "amount": "0.00001"
+ }
+}
diff --git a/packages/sdp-custody/package.json b/packages/sdp-custody/package.json
index 11660a50e1..8fa64e3188 100644
--- a/packages/sdp-custody/package.json
+++ b/packages/sdp-custody/package.json
@@ -93,6 +93,31 @@
"types": "./src/signing.ts",
"import": "./src/signing.ts",
"default": "./src/signing.ts"
+ },
+ "./provisioning/fireblocks-address-book": {
+ "types": "./src/provisioning/fireblocks-address-book.ts",
+ "import": "./src/provisioning/fireblocks-address-book.ts",
+ "default": "./src/provisioning/fireblocks-address-book.ts"
+ },
+ "./provisioning/fireblocks-http": {
+ "types": "./src/provisioning/fireblocks-http.ts",
+ "import": "./src/provisioning/fireblocks-http.ts",
+ "default": "./src/provisioning/fireblocks-http.ts"
+ },
+ "./provisioning/fireblocks-transfers": {
+ "types": "./src/provisioning/fireblocks-transfers.ts",
+ "import": "./src/provisioning/fireblocks-transfers.ts",
+ "default": "./src/provisioning/fireblocks-transfers.ts"
+ },
+ "./provisioning/fireblocks-vaults": {
+ "types": "./src/provisioning/fireblocks-vaults.ts",
+ "import": "./src/provisioning/fireblocks-vaults.ts",
+ "default": "./src/provisioning/fireblocks-vaults.ts"
+ },
+ "./provisioning/fireblocks-credential": {
+ "types": "./src/provisioning/fireblocks-credential.ts",
+ "import": "./src/provisioning/fireblocks-credential.ts",
+ "default": "./src/provisioning/fireblocks-credential.ts"
}
},
"imports": {
@@ -121,6 +146,7 @@
"@solana/keychain-utila": "1.4.0",
"@solana/kit": "catalog:",
"@solana/signers": "catalog:",
- "jose": "6.2.12"
+ "jose": "6.2.12",
+ "zod": "4.6.5"
}
}
diff --git a/packages/sdp-custody/src/keychain/keychain-fireblocks.adapter.ts b/packages/sdp-custody/src/keychain/keychain-fireblocks.adapter.ts
index 967d42e27a..0510c8f849 100644
--- a/packages/sdp-custody/src/keychain/keychain-fireblocks.adapter.ts
+++ b/packages/sdp-custody/src/keychain/keychain-fireblocks.adapter.ts
@@ -10,17 +10,11 @@
* - Polling for transaction completion
*/
-import { scrubTelemetry } from "@sdp/redaction/scrub";
import { FireblocksSigner } from "@solana/keychain-fireblocks";
import type { Address } from "@solana/kit";
import { BaseKeychainAdapter } from "./base-keychain.adapter";
import type { KeychainFireblocksConfig } from "./types";
-type FireblocksSignerDebugHooks = {
- __sdpDebugPatched__?: boolean;
- request?: (method: string, uri: string, body?: unknown) => Promise;
-};
-
// ═══════════════════════════════════════════════════════════════════════════
// Adapter Implementation
// ═══════════════════════════════════════════════════════════════════════════
@@ -77,61 +71,12 @@ export class KeychainFireblocksAdapter extends BaseKeychainAdapter {
pollIntervalMs: this.config.pollIntervalMs,
maxPollAttempts: this.config.maxPollAttempts,
requestDelayMs: this.config.requestDelayMs,
- // Always use RAW signing - we handle broadcast separately via Kora
+ // RAW returns a signature; SDP owns fee sponsorship and broadcast.
useProgramCall: false,
});
- this.attachDebugLogging(signer);
await signer.init();
return signer;
}
-
- private attachDebugLogging(fireblocksSigner: FireblocksSigner): void {
- const signer = fireblocksSigner as unknown as FireblocksSignerDebugHooks;
-
- if (signer.__sdpDebugPatched__ || typeof signer.request !== "function") {
- return;
- }
-
- const originalRequest = signer.request.bind(fireblocksSigner) as (
- method: string,
- uri: string,
- body?: unknown
- ) => Promise;
-
- signer.request = (async (method: string, uri: string, body?: unknown): Promise => {
- try {
- console.info("sdp_fireblocks_api_request", scrubTelemetry({ method, uri, body }));
-
- const response = await originalRequest(method, uri, body);
-
- console.info(
- "sdp_fireblocks_api_response",
- scrubTelemetry({ method, uri, body, response })
- );
-
- return response;
- } catch (error) {
- console.error(
- "sdp_fireblocks_api_error",
- scrubTelemetry({
- method,
- uri,
- body,
- error:
- error instanceof Error
- ? {
- message: error.message,
- stack: error.stack,
- }
- : String(error),
- })
- );
- throw error;
- }
- }) as typeof signer.request;
-
- signer.__sdpDebugPatched__ = true;
- }
}
function denormalizeFireblocksWalletId(walletId: string): string {
diff --git a/packages/sdp-custody/src/provisioning/fireblocks-address-book.ts b/packages/sdp-custody/src/provisioning/fireblocks-address-book.ts
new file mode 100644
index 0000000000..519347ee3a
--- /dev/null
+++ b/packages/sdp-custody/src/provisioning/fireblocks-address-book.ts
@@ -0,0 +1,111 @@
+import { FIREBLOCKS_ADDRESS_STATUSES, type FireblocksAddressStatus } from "@sdp/types/fireblocks";
+import { z } from "zod";
+import { SigningError } from "#/signing";
+import { assertHttpsBaseUrl } from "./common";
+import { fireblocksRequest } from "./fireblocks-http";
+import type { FireblocksTransferAccess } from "./fireblocks-transfers";
+import type { CustodyProvisioningRuntime } from "./runtime";
+
+export interface FireblocksAddressEntry {
+ walletId: string;
+ walletType: "EXTERNAL_WALLET" | "INTERNAL_WALLET";
+ name: string;
+ assetId: string;
+ address: string;
+ status: FireblocksAddressStatus;
+ activationTime: string | null;
+}
+const bookSchema = z
+ .array(
+ z.object({
+ id: z.string().min(1),
+ name: z.string(),
+ assets: z.array(
+ z.object({
+ id: z.string(),
+ address: z.string(),
+ status: z.enum(FIREBLOCKS_ADDRESS_STATUSES),
+ activationTime: z.string().nullish(),
+ })
+ ),
+ })
+ )
+ .max(10_000);
+const books = new Map<
+ string,
+ { expires: number; pending: boolean; entries: Promise }
+>();
+function config(access: FireblocksTransferAccess) {
+ return {
+ ...access,
+ apiBaseUrl: assertHttpsBaseUrl(access.apiBaseUrl ?? "https://api.fireblocks.io", "Fireblocks"),
+ };
+}
+function cacheKey(access: FireblocksTransferAccess) {
+ return `${access.apiBaseUrl}:${access.apiKey}`;
+}
+export function invalidateFireblocksAddressBook(access: FireblocksTransferAccess) {
+ books.delete(cacheKey(config(access)));
+}
+function invalid(): never {
+ throw new SigningError("Invalid Fireblocks address book response", "NETWORK_ERROR");
+}
+function parseBook(
+ value: unknown,
+ walletType: FireblocksAddressEntry["walletType"]
+): FireblocksAddressEntry[] {
+ const parsed = bookSchema.safeParse(value);
+ if (!parsed.success) return invalid();
+ return parsed.data.flatMap((wallet) =>
+ wallet.assets.map((asset) => ({
+ walletId: wallet.id,
+ walletType,
+ name: wallet.name.slice(0, 200),
+ assetId: asset.id,
+ address: asset.address,
+ status: asset.status,
+ activationTime: asset.activationTime ?? null,
+ }))
+ );
+}
+/** Two independent reads, shared across concurrent checks; failed reads are never cached. */
+export async function readFireblocksAddressBook(
+ runtime: CustodyProvisioningRuntime,
+ access: FireblocksTransferAccess,
+ fresh = false
+): Promise {
+ const credentials = config(access);
+ const key = cacheKey(credentials);
+ let cached = books.get(key);
+ if (!cached || (!cached.pending && (fresh || cached.expires <= runtime.now()))) {
+ if (books.size >= 100) {
+ // SAFETY: the size check guarantees a first entry; this synchronous read cannot interleave with another eviction.
+ books.delete(books.keys().next().value as string);
+ }
+ const entries = Promise.all([
+ fireblocksRequest(runtime, {
+ ...credentials,
+ method: "GET",
+ uri: "/v1/external_wallets",
+ }).then((value) => parseBook(value, "EXTERNAL_WALLET")),
+ fireblocksRequest(runtime, {
+ ...credentials,
+ method: "GET",
+ uri: "/v1/internal_wallets",
+ }).then((value) => parseBook(value, "INTERNAL_WALLET")),
+ ]).then(([external, internal]) => [...external, ...internal]);
+ cached = { expires: runtime.now() + 30_000, pending: true, entries };
+ books.set(key, cached);
+ const current = cached;
+ void entries.then(
+ () => {
+ current.pending = false;
+ current.expires = runtime.now() + 30_000;
+ },
+ () => {
+ if (books.get(key) === current) books.delete(key);
+ }
+ );
+ }
+ return cached.entries;
+}
diff --git a/packages/sdp-custody/src/provisioning/fireblocks-boundaries.test.ts b/packages/sdp-custody/src/provisioning/fireblocks-boundaries.test.ts
new file mode 100644
index 0000000000..f82f914e5a
--- /dev/null
+++ b/packages/sdp-custody/src/provisioning/fireblocks-boundaries.test.ts
@@ -0,0 +1,114 @@
+import { generateKeyPairSync, webcrypto } from "node:crypto";
+import { describe, expect, it } from "vitest";
+import { readFireblocksAddressBook } from "./fireblocks-address-book";
+import { readFireblocksTransfer, resolveFireblocksTransferAsset } from "./fireblocks-transfers";
+import { listFireblocksVaults } from "./fireblocks-vaults";
+import { provisionFireblocksVaultAccount } from "./index";
+import type { CustodyProvisioningRuntime } from "./runtime";
+
+const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
+function access() {
+ return {
+ apiKey: crypto.randomUUID(),
+ apiSecretPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
+ apiBaseUrl: "https://fireblocks.example.test",
+ };
+}
+function runtime(fetch: CustodyProvisioningRuntime["fetch"]): CustodyProvisioningRuntime {
+ return {
+ fetch,
+ sleep: async () => {},
+ now: Date.now,
+ randomUUID: () => crypto.randomUUID(),
+ getRandomValues: (data) => webcrypto.getRandomValues(new Uint8Array(data)),
+ sha256: async (data) => webcrypto.subtle.digest("SHA-256", new Uint8Array(data)),
+ };
+}
+describe("Fireblocks provider boundaries", () => {
+ it("allows unrelated catalogue assets without optional metadata, requiring the selected token's decimals", async () => {
+ const token = { id: "USDC_SOL", type: "SOL_ASSET", nativeAsset: "SOL", issuerAddress: "mint" };
+ const catalogue = (decimals: number | undefined) =>
+ runtime(async () =>
+ Response.json([
+ { id: "BTC", type: "BASE_ASSET" },
+ { ...token, decimals },
+ ])
+ );
+ await expect(
+ resolveFireblocksTransferAsset(catalogue(6), access(), "SOL", "mint")
+ ).resolves.toEqual({ id: "USDC_SOL", decimals: 6 });
+ await expect(
+ resolveFireblocksTransferAsset(catalogue(undefined), access(), "SOL", "mint")
+ ).rejects.toThrow("not uniquely supported");
+ });
+ it.each([
+ { source: [] },
+ { amountInfo: { netAmount: "-1" } },
+ { feeInfo: { networkFee: 1 } },
+ { status: "untrusted status" },
+ ])("rejects malformed transfer detail %j", async (patch) => {
+ const rt = runtime(async () => Response.json({ id: "tx", status: "COMPLETED", ...patch }));
+ await expect(readFireblocksTransfer(rt, access(), "external-id")).rejects.toThrow(
+ "Invalid Fireblocks transfer response"
+ );
+ });
+ it("does not cache malformed address-book replies", async () => {
+ let malformed = true;
+ let reads = 0;
+ const rt = runtime(async () => {
+ reads++;
+ return Response.json([
+ {
+ id: "wallet",
+ name: "Recipient",
+ assets: [
+ { id: "SOL", address: "address", status: malformed ? "UNRECOGNIZED" : "APPROVED" },
+ ],
+ },
+ ]);
+ });
+ const credential = access();
+ await expect(readFireblocksAddressBook(rt, credential)).rejects.toThrow(
+ "Invalid Fireblocks address book response"
+ );
+ malformed = false;
+ expect(await readFireblocksAddressBook(rt, credential)).toHaveLength(2);
+ expect(reads).toBe(4);
+ });
+ it("rejects malformed vault IDs and pagination without following provider URLs", async () => {
+ for (const page of [
+ { accounts: [{ id: "../other", name: "Vault", assets: [] }] },
+ { accounts: [], paging: { after: 7 } },
+ ]) {
+ await expect(
+ listFireblocksVaults(
+ runtime(async () => Response.json(page)),
+ { ...access(), assetId: "SOL" }
+ )
+ ).rejects.toThrow("Invalid Fireblocks vault page");
+ }
+ });
+ it("uses distinct stable idempotency keys for vault and asset creation", async () => {
+ const writes: Array<{ uri: string; key: string | null }> = [];
+ const rt = runtime(async (url, init) => {
+ const uri = new URL(String(url)).pathname;
+ if (init?.method === "POST") {
+ writes.push({ uri, key: new Headers(init.headers).get("Idempotency-Key") });
+ return Response.json({ id: "0" });
+ }
+ return Response.json({ addresses: [{ address: "11111111111111111111111111111111" }] });
+ });
+ for (let attempt = 0; attempt < 2; attempt++)
+ await provisionFireblocksVaultAccount(
+ rt,
+ { ...access(), assetId: "SOL" },
+ { orgId: "org", orgSlug: "project", idempotencyKey: "create-wallet" }
+ );
+ expect(writes).toEqual([
+ { uri: "/v1/vault/accounts", key: "create-wallet:vault" },
+ { uri: "/v1/vault/accounts/0/SOL", key: "create-wallet:asset" },
+ { uri: "/v1/vault/accounts", key: "create-wallet:vault" },
+ { uri: "/v1/vault/accounts/0/SOL", key: "create-wallet:asset" },
+ ]);
+ });
+});
diff --git a/packages/sdp-custody/src/provisioning/fireblocks-credential.ts b/packages/sdp-custody/src/provisioning/fireblocks-credential.ts
new file mode 100644
index 0000000000..1f02df8864
--- /dev/null
+++ b/packages/sdp-custody/src/provisioning/fireblocks-credential.ts
@@ -0,0 +1,21 @@
+import { SignerError, SignerErrorCode } from "@solana/keychain-core";
+import { FireblocksSigner } from "@solana/keychain-fireblocks";
+
+/** One authenticated address read. Never exposes a signer or proposes a transaction. */
+export async function checkFireblocksCredential(
+ input: Parameters[0]
+): Promise<{ status: "success"; address: string } | { status: "failed" | "retry_unknown" }> {
+ try {
+ const signer = await FireblocksSigner.create(input);
+ return { status: "success", address: signer.address };
+ } catch (error) {
+ if (
+ error instanceof SignerError &&
+ (error.code === SignerErrorCode.INVALID_PRIVATE_KEY ||
+ (error.code === SignerErrorCode.REMOTE_API_ERROR &&
+ [401, 403, 404].includes(Number(error.context?.status))))
+ )
+ return { status: "failed" };
+ return { status: "retry_unknown" };
+ }
+}
diff --git a/packages/sdp-custody/src/provisioning/fireblocks-http.test.ts b/packages/sdp-custody/src/provisioning/fireblocks-http.test.ts
new file mode 100644
index 0000000000..d3a3f05a23
--- /dev/null
+++ b/packages/sdp-custody/src/provisioning/fireblocks-http.test.ts
@@ -0,0 +1,90 @@
+import assert from "node:assert/strict";
+import { generateKeyPairSync, webcrypto } from "node:crypto";
+import { describe, it } from "vitest";
+import { FireblocksApiError, fireblocksRequest } from "./fireblocks-http";
+import type { CustodyProvisioningRuntime } from "./runtime";
+
+const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
+const params = {
+ apiBaseUrl: "https://fireblocks.example.test",
+ apiKey: "test-api-user",
+ apiSecretPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
+ method: "POST" as const,
+ uri: "/v1/transactions",
+ body: { operation: "TRANSFER", assetId: "SOL" },
+};
+function runtime(fetch: CustodyProvisioningRuntime["fetch"]): CustodyProvisioningRuntime {
+ return {
+ fetch,
+ sleep: async () => {},
+ now: Date.now,
+ randomUUID: () => crypto.randomUUID(),
+ getRandomValues: (data) => webcrypto.getRandomValues(new Uint8Array(data)),
+ sha256: async (data) => webcrypto.subtle.digest("SHA-256", new Uint8Array(data)),
+ };
+}
+
+describe("Fireblocks HTTP contract", () => {
+ it("keeps retries stable and separate requests distinct within the 40-character limit", async () => {
+ const keys: string[] = [];
+ const rt = runtime(async (_url, init) => {
+ const key = new Headers(init?.headers).get("Idempotency-Key");
+ assert.ok(key && key.length <= 40, "Fireblocks rejects longer keys with HTTP 400 / 9003");
+ assert.deepEqual(JSON.parse(String(init?.body)), params.body);
+ keys.push(key);
+ return Response.json({ id: "transaction" });
+ });
+ const request = "payment-cf0c4bee-ff03-4112-9ebf-2b84be8126a6";
+ for (const suffix of ["first", "first", "second"])
+ await fireblocksRequest(rt, { ...params, idempotencyKey: `${request}-${suffix}` });
+ assert.equal(keys[0], keys[1]);
+ assert.notEqual(keys[0], keys[2]);
+ // Existing valid payment keys must not change after deploying this fix.
+ const paymentKey = "xfr_9246a4cf-86f4-415b-b91c-1d9f4d11eed8";
+ await fireblocksRequest(rt, { ...params, idempotencyKey: paymentKey });
+ assert.equal(keys[3], paymentKey);
+ });
+
+ it("retains numeric error codes and request IDs without retaining provider message text", async () => {
+ await assert.rejects(
+ fireblocksRequest(
+ runtime(async () =>
+ Response.json(
+ { code: 9003, message: "sensitive provider text" },
+ { status: 400, headers: { "x-request-id": "provider-request-123" } }
+ )
+ ),
+ params
+ ),
+ (error: unknown) => {
+ assert.ok(error instanceof FireblocksApiError);
+ assert.equal(error.status, 400);
+ assert.equal(error.providerCode, 9003);
+ assert.equal(error.requestId, "provider-request-123");
+ assert.ok(!JSON.stringify(error).includes("sensitive provider text"));
+ assert.ok(!error.message.includes("sensitive provider text"));
+ return true;
+ }
+ );
+ });
+
+ it("preserves HTTP failure classification when error details are missing or malformed", async () => {
+ for (const response of [
+ new Response("upstream error", { status: 403 }),
+ Response.json({ code: "sensitive provider text" }, { status: 403 }),
+ ]) {
+ await assert.rejects(
+ fireblocksRequest(
+ runtime(async () => response),
+ params
+ ),
+ (error) => {
+ assert.ok(error instanceof FireblocksApiError);
+ assert.equal(error.status, 403);
+ assert.equal(error.providerCode, undefined);
+ return true;
+ }
+ );
+ }
+ });
+});
diff --git a/packages/sdp-custody/src/provisioning/fireblocks-http.ts b/packages/sdp-custody/src/provisioning/fireblocks-http.ts
new file mode 100644
index 0000000000..63e410d669
--- /dev/null
+++ b/packages/sdp-custody/src/provisioning/fireblocks-http.ts
@@ -0,0 +1,103 @@
+import { importPKCS8, SignJWT } from "jose";
+import { SigningError } from "#/signing";
+import { parseJsonResponse, sha256Hex } from "./common";
+import type { CustodyProvisioningRuntime } from "./runtime";
+
+export class FireblocksApiError extends SigningError {
+ constructor(
+ readonly status: number,
+ readonly providerCode?: number,
+ readonly requestId?: string
+ ) {
+ super(
+ `Fireblocks API request failed (HTTP ${status}${providerCode === undefined ? "" : `, code ${providerCode}`})`,
+ [401, 403, 404].includes(status) ? "PROVIDER_CREDENTIAL_INVALID" : "PROVIDER_NOT_CONFIGURED"
+ );
+ }
+}
+
+interface FireblocksRequestParams {
+ apiBaseUrl: string;
+ apiKey: string;
+ apiSecretPem: string;
+ method: "GET" | "POST";
+ uri: string;
+ body?: unknown;
+ allowStatuses?: number[];
+ idempotencyKey?: string;
+}
+
+export async function fireblocksRequest(
+ runtime: CustodyProvisioningRuntime,
+ params: FireblocksRequestParams
+): Promise {
+ // Fireblocks limits Idempotency-Key to 40 characters. Hash the complete key
+ // (including operation suffixes) so retries stay stable and steps stay distinct.
+ // Preserve previously valid keys, including existing payment externalTxIds.
+ const idempotencyKey =
+ params.idempotencyKey && params.idempotencyKey.length > 40
+ ? (await sha256Hex(runtime, params.idempotencyKey)).slice(0, 40)
+ : params.idempotencyKey;
+ const bodyStr = params.body ? JSON.stringify(params.body) : "";
+ const token = await createFireblocksJwt(
+ runtime,
+ params.apiKey,
+ params.apiSecretPem,
+ params.uri,
+ bodyStr
+ );
+
+ const headers: Record = {
+ Authorization: `Bearer ${token}`,
+ "X-API-Key": params.apiKey,
+ };
+ if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
+ if (params.body) headers["Content-Type"] = "application/json";
+ const response = await runtime.fetch(`${params.apiBaseUrl}${params.uri}`, {
+ method: params.method,
+ redirect: "error",
+ signal: AbortSignal.timeout(10_000),
+ headers,
+ body: params.body ? bodyStr : undefined,
+ });
+
+ if (!response.ok && !(params.allowStatuses ?? []).includes(response.status)) {
+ // Keep diagnostic identifiers, never arbitrary provider text or response bodies.
+ const error = await response.json().catch(() => null);
+ const rawCode = error && typeof error === "object" && "code" in error ? error.code : undefined;
+ const code =
+ typeof rawCode === "number"
+ ? rawCode
+ : typeof rawCode === "string" && /^-?\d{1,10}$/.test(rawCode)
+ ? Number(rawCode)
+ : undefined;
+ const requestId = response.headers.get("x-request-id");
+ throw new FireblocksApiError(
+ response.status,
+ Number.isSafeInteger(code) ? code : undefined,
+ requestId && /^[a-zA-Z0-9:_-]{1,128}$/.test(requestId) ? requestId : undefined
+ );
+ }
+
+ return parseJsonResponse(response);
+}
+
+async function createFireblocksJwt(
+ runtime: CustodyProvisioningRuntime,
+ apiKey: string,
+ privateKeyPem: string,
+ uri: string,
+ body: string
+): Promise {
+ const privateKey = await importPKCS8(privateKeyPem, "RS256");
+ const bodyHash = await sha256Hex(runtime, body);
+ const nonce = runtime.randomUUID();
+ const now = Math.floor(runtime.now() / 1000);
+
+ return new SignJWT({ bodyHash, nonce, uri })
+ .setProtectedHeader({ alg: "RS256" })
+ .setSubject(apiKey)
+ .setIssuedAt(now)
+ .setExpirationTime(now + 30)
+ .sign(privateKey);
+}
diff --git a/packages/sdp-custody/src/provisioning/fireblocks-transfers.ts b/packages/sdp-custody/src/provisioning/fireblocks-transfers.ts
new file mode 100644
index 0000000000..cc9c105d3e
--- /dev/null
+++ b/packages/sdp-custody/src/provisioning/fireblocks-transfers.ts
@@ -0,0 +1,170 @@
+import { z } from "zod";
+import { SigningError } from "#/signing";
+import { assertHttpsBaseUrl } from "./common";
+import { FireblocksApiError, fireblocksRequest } from "./fireblocks-http";
+import type { CustodyProvisioningRuntime } from "./runtime";
+
+export interface FireblocksTransferAccess {
+ apiKey: string;
+ apiSecretPem: string;
+ apiBaseUrl?: string;
+}
+
+export interface FireblocksTransferRequest {
+ operation: "TRANSFER";
+ externalTxId: string;
+ assetId: string;
+ source: { type: "VAULT_ACCOUNT"; id: string };
+ destination: { type: "ONE_TIME_ADDRESS"; oneTimeAddress: { address: string } };
+ amount: string;
+ treatAsGrossAmount: false;
+ note: string;
+}
+
+const optionalText = z
+ .string()
+ .max(256)
+ .nullish()
+ .transform((value) => value ?? undefined);
+const optionalAmount = z
+ .string()
+ .max(100)
+ .regex(/^\d+(\.\d+)?$/)
+ .nullish()
+ .transform((value) => value ?? undefined);
+const transferResultSchema = z.object({
+ id: z.string().min(1).max(256),
+ status: z.string().regex(/^[A-Z_]{1,100}$/),
+ subStatus: optionalText,
+ txHash: optionalText,
+ externalTxId: optionalText,
+ operation: optionalText,
+ assetId: optionalText,
+ source: z
+ .object({ type: z.string().max(256), id: z.string().max(256) })
+ .nullish()
+ .transform((value) => value ?? undefined),
+ destinationAddress: optionalText,
+ amountInfo: z
+ .object({ amount: optionalAmount, requestedAmount: optionalAmount, netAmount: optionalAmount })
+ .nullish()
+ .transform((value) => value ?? undefined),
+ feeCurrency: optionalText,
+ feeInfo: z
+ .object({ networkFee: optionalAmount })
+ .nullish()
+ .transform((value) => value ?? undefined),
+});
+export type FireblocksTransferResult = z.infer;
+const assetSchema = z.object({
+ id: z.string().min(1).max(256),
+ type: z.string(),
+ nativeAsset: optionalText,
+ issuerAddress: optionalText,
+ decimals: z.number().int().min(0).max(255).optional(),
+});
+type Asset = z.infer;
+const catalogues = new Map }>();
+
+function config(access: FireblocksTransferAccess) {
+ return {
+ ...access,
+ apiBaseUrl: assertHttpsBaseUrl(access.apiBaseUrl ?? "https://api.fireblocks.io", "Fireblocks"),
+ };
+}
+function invalid(): never {
+ throw new SigningError("Invalid Fireblocks transfer response", "NETWORK_ERROR");
+}
+function result(value: unknown): FireblocksTransferResult {
+ const parsed = transferResultSchema.safeParse(value);
+ return parsed.success ? parsed.data : invalid();
+}
+
+/** Static catalogue only: share concurrent reads, cache successes for five minutes. */
+export async function resolveFireblocksTransferAsset(
+ runtime: CustodyProvisioningRuntime,
+ access: FireblocksTransferAccess,
+ nativeAsset: "SOL" | "SOL_TEST",
+ mint: string | null
+): Promise<{ id: string; decimals: number }> {
+ if (mint === null) return { id: nativeAsset, decimals: 9 };
+ const credentials = config(access);
+ const cacheKey = `${credentials.apiBaseUrl}:${credentials.apiKey}`;
+ let entry = catalogues.get(cacheKey);
+ if (!entry || entry.expires <= runtime.now()) {
+ if (catalogues.size >= 100) {
+ // SAFETY: the size check guarantees a first entry; this synchronous read cannot interleave with another eviction.
+ catalogues.delete(catalogues.keys().next().value as string);
+ }
+ const value = fireblocksRequest(runtime, {
+ ...credentials,
+ method: "GET",
+ uri: "/v1/supported_assets",
+ }).then((assets) => {
+ const parsed = z.array(assetSchema).max(100_000).safeParse(assets);
+ return parsed.success ? parsed.data : invalid();
+ });
+ entry = { expires: runtime.now() + 300_000, value };
+ catalogues.set(cacheKey, entry);
+ const current = entry;
+ value.catch(() => {
+ if (catalogues.get(cacheKey) === current) catalogues.delete(cacheKey);
+ });
+ }
+ const matches = (await entry.value).filter(
+ (asset) =>
+ asset?.type === "SOL_ASSET" &&
+ asset.nativeAsset === nativeAsset &&
+ asset.issuerAddress === mint
+ );
+ if (
+ matches.length !== 1 ||
+ typeof matches[0]?.id !== "string" ||
+ typeof matches[0].decimals !== "number" ||
+ !Number.isInteger(matches[0].decimals) ||
+ matches[0].decimals < 0 ||
+ matches[0].decimals > 18
+ ) {
+ throw new SigningError(
+ "The token mint is not uniquely supported by Fireblocks on this Solana network",
+ "INVALID_REQUEST"
+ );
+ }
+ return { id: matches[0].id, decimals: matches[0].decimals };
+}
+
+/** A native transfer: Fireblocks builds, approves, signs and broadcasts. No RAW fallback. */
+export async function createFireblocksTransfer(
+ runtime: CustodyProvisioningRuntime,
+ access: FireblocksTransferAccess,
+ body: FireblocksTransferRequest
+): Promise {
+ return result(
+ await fireblocksRequest(runtime, {
+ ...config(access),
+ method: "POST",
+ uri: "/v1/transactions",
+ body,
+ })
+ );
+}
+
+/** Recovery is read-only, including after timeouts and unknown submission outcomes. */
+export async function readFireblocksTransfer(
+ runtime: CustodyProvisioningRuntime,
+ access: FireblocksTransferAccess,
+ externalTxId: string
+): Promise {
+ try {
+ return result(
+ await fireblocksRequest(runtime, {
+ ...config(access),
+ method: "GET",
+ uri: `/v1/transactions/external_tx_id/${encodeURIComponent(externalTxId)}`,
+ })
+ );
+ } catch (error) {
+ if (error instanceof FireblocksApiError && error.status === 404) return null;
+ throw error;
+ }
+}
diff --git a/packages/sdp-custody/src/provisioning/fireblocks-vaults.ts b/packages/sdp-custody/src/provisioning/fireblocks-vaults.ts
new file mode 100644
index 0000000000..6e60117271
--- /dev/null
+++ b/packages/sdp-custody/src/provisioning/fireblocks-vaults.ts
@@ -0,0 +1,97 @@
+import { z } from "zod";
+import { SigningError } from "#/signing";
+import { assertHttpsBaseUrl } from "./common";
+import { fireblocksRequest } from "./fireblocks-http";
+import type { CustodyProvisioningRuntime } from "./runtime";
+
+export interface FireblocksVault {
+ id: string;
+ name: string;
+ hasSolanaWallet: boolean;
+}
+export interface FireblocksVaultPage {
+ vaults: FireblocksVault[];
+ nextCursor: string | null;
+}
+interface VaultAccess {
+ apiKey: string;
+ apiSecretPem: string;
+ apiBaseUrl?: string;
+ assetId: "SOL" | "SOL_TEST";
+}
+
+const vaultSchema = z.object({
+ id: z.string().regex(/^\d{1,100}$/),
+ name: z.string(),
+ assets: z.array(z.object({ id: z.string() })),
+});
+const pageSchema = z.object({
+ accounts: z.array(vaultSchema).max(50),
+ paging: z.object({ after: z.string().max(512).nullish() }).nullish(),
+});
+function vault(value: unknown, assetId: string): FireblocksVault {
+ const parsed = vaultSchema.safeParse(value);
+ if (!parsed.success)
+ throw new SigningError("Invalid Fireblocks vault response", "PROVIDER_NOT_CONFIGURED");
+ const item = parsed.data;
+ return {
+ id: item.id,
+ name: item.name.trim().slice(0, 256) || `Vault ${item.id}`,
+ hasSolanaWallet: item.assets.some((asset) => asset.id === assetId),
+ };
+}
+function requestConfig(config: VaultAccess) {
+ return {
+ apiBaseUrl: assertHttpsBaseUrl(config.apiBaseUrl ?? "https://api.fireblocks.io", "Fireblocks"),
+ apiKey: config.apiKey,
+ apiSecretPem: config.apiSecretPem,
+ method: "GET" as const,
+ };
+}
+
+/** One bounded provider read. Never follow provider-supplied pagination URLs. */
+export async function listFireblocksVaults(
+ runtime: CustodyProvisioningRuntime,
+ config: VaultAccess,
+ options: { after?: string; namePrefix?: string } = {}
+): Promise {
+ const query = new URLSearchParams({ limit: "50", assetId: config.assetId });
+ if (options.after) query.set("after", options.after);
+ if (options.namePrefix) query.set("namePrefix", options.namePrefix);
+ const parsed = pageSchema.safeParse(
+ await fireblocksRequest(runtime, {
+ ...requestConfig(config),
+ uri: `/v1/vault/accounts_paged?${query}`,
+ })
+ );
+ if (!parsed.success)
+ throw new SigningError("Invalid Fireblocks vault page", "PROVIDER_NOT_CONFIGURED");
+ const result = parsed.data;
+ const after = result.paging?.after;
+ return {
+ vaults: result.accounts
+ .map((item) => vault(item, config.assetId))
+ .filter((item) => item.hasSolanaWallet),
+ nextCursor:
+ typeof after === "string" && after.length > 0 && after !== options.after ? after : null,
+ };
+}
+
+/** Confirm the selected vault's current name and network before importing it. */
+export async function getFireblocksVault(
+ runtime: CustodyProvisioningRuntime,
+ config: VaultAccess,
+ id: string
+): Promise {
+ if (!/^\d{1,100}$/.test(id))
+ throw new SigningError("Invalid vault ID", "PROVIDER_NOT_CONFIGURED");
+ const result = vault(
+ await fireblocksRequest(runtime, {
+ ...requestConfig(config),
+ uri: `/v1/vault/accounts/${id}`,
+ }),
+ config.assetId
+ );
+ if (result.id !== id) throw new SigningError("Fireblocks vault identity mismatch", "CONFLICT");
+ return result;
+}
diff --git a/packages/sdp-custody/src/provisioning/index.ts b/packages/sdp-custody/src/provisioning/index.ts
index 8e375cd8c5..87690a8e71 100644
--- a/packages/sdp-custody/src/provisioning/index.ts
+++ b/packages/sdp-custody/src/provisioning/index.ts
@@ -5,6 +5,7 @@
*/
import { summarizeUpstreamErrorBody } from "@sdp/redaction/upstream-error";
+import { isAddress } from "@solana/addresses";
import type { VaultAddressesResponse } from "@solana/keychain-fireblocks";
import { ApiKeyStamper } from "@solana/keychain-turnkey";
import { importPKCS8, SignJWT } from "jose";
@@ -23,9 +24,9 @@ import {
parseJsonResponse,
randomHex,
readErrorResponseText,
- sha256Hex,
trimTrailingHyphens,
} from "./common";
+import { fireblocksRequest } from "./fireblocks-http";
import {
buildParaUserIdentifier,
type ParaWalletResponse,
@@ -81,6 +82,7 @@ interface TurnkeyActivityResponse {
}
export interface ProvisionFireblocksOptions {
+ idempotencyKey?: string;
orgId: string;
orgSlug: string;
vaultAccountId?: string;
@@ -94,6 +96,7 @@ export interface FireblocksProvisioningConfig {
}
export interface ProvisionFireblocksResult {
+ publicKey: string;
vaultAccountId: string;
assetId: string;
apiBaseUrl: string;
@@ -242,6 +245,7 @@ export async function provisionFireblocksVaultAccount(
apiSecretPem,
method: "POST",
uri: "/v1/vault/accounts",
+ idempotencyKey: options.idempotencyKey ? `${options.idempotencyKey}:vault` : undefined,
body: {
name,
customerRefId: options.orgId,
@@ -262,6 +266,7 @@ export async function provisionFireblocksVaultAccount(
apiSecretPem,
method: "POST",
uri: `/v1/vault/accounts/${vaultAccountId}/${assetId}`,
+ idempotencyKey: options.idempotencyKey ? `${options.idempotencyKey}:asset` : undefined,
body: {},
allowStatuses: [409],
});
@@ -275,14 +280,15 @@ export async function provisionFireblocksVaultAccount(
assetId,
});
- if (!addresses?.addresses?.length) {
+ const publicKey = addresses?.addresses?.[0]?.address;
+ if (!publicKey || !isAddress(publicKey)) {
throw new SigningError(
"Fireblocks vault wallet created, but no addresses are available",
"PROVIDER_NOT_CONFIGURED"
);
}
- return { vaultAccountId, assetId, apiBaseUrl };
+ return { vaultAccountId, assetId, apiBaseUrl, publicKey };
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ponytail: keep legacy and credential modes in one Provider boundary; split if a third mode is added.
@@ -746,16 +752,6 @@ async function safeReadUtilaError(response: Response): Promise {
}
}
-interface FireblocksRequestParams {
- apiBaseUrl: string;
- apiKey: string;
- apiSecretPem: string;
- method: "GET" | "POST";
- uri: string;
- body?: unknown;
- allowStatuses?: number[];
-}
-
interface FireblocksAddressesParams {
apiBaseUrl: string;
apiKey: string;
@@ -801,60 +797,6 @@ async function fetchFireblocksAddressesWithRetry(
return { addresses: [] };
}
-async function fireblocksRequest(
- runtime: CustodyProvisioningRuntime,
- params: FireblocksRequestParams
-): Promise {
- const bodyStr = params.body ? JSON.stringify(params.body) : "";
- const token = await createFireblocksJwt(
- runtime,
- params.apiKey,
- params.apiSecretPem,
- params.uri,
- bodyStr
- );
-
- const response = await runtime.fetch(`${params.apiBaseUrl}${params.uri}`, {
- method: params.method,
- headers: {
- Authorization: `Bearer ${token}`,
- "X-API-Key": params.apiKey,
- ...(params.body ? { "Content-Type": "application/json" } : {}),
- },
- body: params.body ? bodyStr : undefined,
- });
-
- if (!response.ok && !(params.allowStatuses ?? []).includes(response.status)) {
- const errorText = await readErrorResponseText(response);
- throw new SigningError(
- `Fireblocks API error: ${response.status} - ${errorText}`,
- "PROVIDER_NOT_CONFIGURED"
- );
- }
-
- return parseJsonResponse(response);
-}
-
-async function createFireblocksJwt(
- runtime: CustodyProvisioningRuntime,
- apiKey: string,
- privateKeyPem: string,
- uri: string,
- body: string
-): Promise {
- const privateKey = await importPKCS8(privateKeyPem, "RS256");
- const bodyHash = await sha256Hex(runtime, body);
- const nonce = runtime.randomUUID();
- const now = Math.floor(runtime.now() / 1000);
-
- return new SignJWT({ bodyHash, nonce, uri })
- .setProtectedHeader({ alg: "RS256" })
- .setSubject(apiKey)
- .setIssuedAt(now)
- .setExpirationTime(now + 30)
- .sign(privateKey);
-}
-
interface PrivyRequestParams {
apiBaseUrl: string;
authHeader: string;
diff --git a/packages/sdp-types/package.json b/packages/sdp-types/package.json
index 53426cdd1c..e73312ebb6 100644
--- a/packages/sdp-types/package.json
+++ b/packages/sdp-types/package.json
@@ -233,6 +233,11 @@
"types": "./src/wisdomtree-programs.ts",
"import": "./src/wisdomtree-programs.ts",
"default": "./src/wisdomtree-programs.ts"
+ },
+ "./fireblocks": {
+ "types": "./src/fireblocks.ts",
+ "import": "./src/fireblocks.ts",
+ "default": "./src/fireblocks.ts"
}
},
"scripts": {
diff --git a/packages/sdp-types/src/custody.ts b/packages/sdp-types/src/custody.ts
index b17bda642c..ce094b78c5 100644
--- a/packages/sdp-types/src/custody.ts
+++ b/packages/sdp-types/src/custody.ts
@@ -25,7 +25,10 @@ export const CUSTODY_MODES = ["managed", "byok"] as const;
export type CustodyMode = (typeof CUSTODY_MODES)[number];
/** Providers with a BYOK runtime: the only ones a custody connection can name. */
-export const BYOK_CUSTODY_PROVIDERS = ["privy"] as const satisfies readonly CustodyProvider[];
+export const BYOK_CUSTODY_PROVIDERS = [
+ "privy",
+ "fireblocks",
+] as const satisfies readonly CustodyProvider[];
export type ByokCustodyProvider = (typeof BYOK_CUSTODY_PROVIDERS)[number];
/**
@@ -34,7 +37,7 @@ export type ByokCustodyProvider = (typeof BYOK_CUSTODY_PROVIDERS)[number];
* @param provider - The custody provider to classify.
* @returns True when a custody connection may name `provider`.
*/
-export function isByokCustodyProvider(provider: CustodyProvider): provider is ByokCustodyProvider {
+export function isByokCustodyProvider(provider: string): provider is ByokCustodyProvider {
return BYOK_CUSTODY_PROVIDERS.some((byokProvider) => byokProvider === provider);
}
@@ -361,7 +364,39 @@ export const CUSTODY_PROVIDER_CATALOG_BY_ID = {
visible: true,
technicalCapabilities: CUSTODY_PROVIDER_CAPABILITIES.fireblocks,
useCases: DEFAULT_CUSTODY_PROVIDER_USE_CASES,
- storedCredentialSetup: { mode: "none" },
+ storedCredentialSetup: {
+ mode: "self_service",
+ fields: [
+ {
+ key: "credentialLabel",
+ labelKey: "DashboardCustody.providerCredentialLabel",
+ helpTextKey: "DashboardCustody.providerCredentialLabelDescription",
+ kind: "text",
+ required: true,
+ defaultValue: "Fireblocks credential",
+ valueHandling: "plain",
+ },
+ {
+ key: "apiKey",
+ labelKey: "DashboardCustody.providerFireblocksApiKey",
+ helpTextKey: "DashboardCustody.providerFireblocksApiKeyDescription",
+ kind: "text",
+ required: true,
+ valueHandling: "redacted_metadata",
+ redactionKind: "suffix",
+ },
+ {
+ key: "vaultAccountId",
+ // biome-ignore lint/security/noSecrets: translation key, not a credential.
+ labelKey: "DashboardCustody.providerFireblocksVaultAccountId",
+ // biome-ignore lint/security/noSecrets: translation key, not a credential.
+ helpTextKey: "DashboardCustody.providerFireblocksVaultAccountIdDescription",
+ kind: "text",
+ required: true,
+ valueHandling: "plain",
+ },
+ ],
+ },
},
coinbase_cdp: {
id: "coinbase_cdp",
diff --git a/packages/sdp-types/src/fireblocks.ts b/packages/sdp-types/src/fireblocks.ts
new file mode 100644
index 0000000000..dc5b04ee9f
--- /dev/null
+++ b/packages/sdp-types/src/fireblocks.ts
@@ -0,0 +1,40 @@
+import { SOL_MINT, type SolanaCluster, wellKnownMint } from "./well-known-tokens";
+
+/** The native TRANSFER path supports only SOL and this cluster's canonical USDC mint. */
+export function isFireblocksPaymentMint(mint: string, cluster: SolanaCluster): boolean {
+ return mint === SOL_MINT || mint === wellKnownMint("USDC", cluster);
+}
+
+/** Provider states shared by parsing, persistence and presentation. */
+export const FIREBLOCKS_FAILED_TRANSACTION_STATUSES = [
+ "FAILED",
+ "REJECTED",
+ "CANCELLED",
+ "CANCELED",
+ "BLOCKED",
+] as const;
+const failedTransactions: ReadonlySet = new Set(FIREBLOCKS_FAILED_TRANSACTION_STATUSES);
+export function isFireblocksTransactionFailed(status: string): boolean {
+ return failedTransactions.has(status);
+}
+export const FIREBLOCKS_ADDRESS_STATUSES = [
+ "WAITING_FOR_APPROVAL",
+ "APPROVED",
+ "CANCELLED",
+ "REJECTED",
+ "FAILED",
+] as const;
+export type FireblocksAddressStatus = (typeof FIREBLOCKS_ADDRESS_STATUSES)[number];
+
+/** COMPLETED requires verified settlement details; unfamiliar provider states remain pending. */
+export function fireblocksPaymentStatus(
+ status: string,
+ settlementVerified: boolean
+): "failed" | "confirmed" | "processing" {
+ switch (status) {
+ case "COMPLETED":
+ return settlementVerified ? "confirmed" : "processing";
+ default:
+ return isFireblocksTransactionFailed(status) ? "failed" : "processing";
+ }
+}
diff --git a/packages/sdp-types/src/idempotency-required-routes.ts b/packages/sdp-types/src/idempotency-required-routes.ts
index de8a5b4b17..458be4d0d7 100644
--- a/packages/sdp-types/src/idempotency-required-routes.ts
+++ b/packages/sdp-types/src/idempotency-required-routes.ts
@@ -16,3 +16,8 @@ export const IDEMPOTENCY_REQUIRED_ROUTES = [
] as const;
export type IdempotencyRequiredRoute = (typeof IDEMPOTENCY_REQUIRED_ROUTES)[number];
+
+/** Dashboard-only key generation; deliberately absent from public API surfaces. */
+export const INTERNAL_IDEMPOTENCY_REQUIRED_ROUTES = [
+ "POST /internal/dashboard/custody/fireblocks/csr",
+] as const;
diff --git a/packages/sdp-types/src/payments.ts b/packages/sdp-types/src/payments.ts
index d5e762aea5..b00281c457 100644
--- a/packages/sdp-types/src/payments.ts
+++ b/packages/sdp-types/src/payments.ts
@@ -540,6 +540,11 @@ export interface PaymentTransferSummary {
counterpartyId?: string;
counterpartyDisplayName?: string;
providerReference?: string;
+ fireblocks?: {
+ transactionId: string | null;
+ status: string;
+ subStatus: string | null;
+ };
deliveryMode?: PaymentRampQuoteDeliveryMode;
fiatCurrency?: string;
fiatAmount?: string;
diff --git a/packages/sdp-types/src/release-channels.ts b/packages/sdp-types/src/release-channels.ts
index 890dd1da34..e404beefbd 100644
--- a/packages/sdp-types/src/release-channels.ts
+++ b/packages/sdp-types/src/release-channels.ts
@@ -86,6 +86,7 @@ export const SDP_MANAGED_CUSTODY_PROVIDER_STAGES = {
export const SDP_BYOK_CUSTODY_PROVIDER_STAGES = {
privy: "stable",
+ fireblocks: "beta",
} as const satisfies Record;
export type SdpRampProviderStages = Record;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1630eec5a0..343bb963e7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -232,6 +232,12 @@ importers:
'@asteasolutions/zod-to-openapi':
specifier: 9.1.0
version: 9.1.0(zod@4.6.5)
+ '@solana/keychain-core':
+ specifier: 1.4.0
+ version: 1.4.0(@solana/addresses@7.1.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/codecs-core@8.4.0(typescript@6.0.3))(@solana/codecs-strings@8.4.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/keys@7.1.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/signers@7.1.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/transactions@7.1.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))
+ '@solana/keychain-fireblocks':
+ specifier: 1.4.0
+ version: 1.4.0(@solana/addresses@7.1.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/codecs-core@8.4.0(typescript@6.0.3))(@solana/codecs-strings@8.4.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/keys@7.1.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/signers@7.1.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@solana/transactions@7.1.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))
'@testcontainers/postgresql':
specifier: 12.1.0
version: 12.1.0
@@ -582,6 +588,9 @@ importers:
jose:
specifier: 6.2.12
version: 6.2.12
+ zod:
+ specifier: 4.6.5
+ version: 4.6.5
devDependencies:
'@types/node':
specifier: 26.6.2
diff --git a/scripts/check-value-movement.mjs b/scripts/check-value-movement.mjs
index d0a1cd6ece..4686d302d5 100644
--- a/scripts/check-value-movement.mjs
+++ b/scripts/check-value-movement.mjs
@@ -77,6 +77,18 @@ const KEYCHAIN_SDKS = [
/** @type {Capability[]} */
export const CAPABILITIES = [
+ {
+ id: "fireblocks-native-transfer",
+ why: "Creates a provider-signed payment; only the admitted Fireblocks submission service may.",
+ symbols: [
+ {
+ file: "packages/sdp-custody/src/provisioning/fireblocks-transfers.ts",
+ name: "createFireblocksTransfer",
+ },
+ ],
+ owners: [CUSTODY_PACKAGE],
+ allow: [`${API}/services/payments/fireblocks-transfer.ts`],
+ },
{
id: "config-signing-adapter",
why: "Builds a signing adapter from an encrypted custody config; only the custody signer waist may.",
@@ -98,6 +110,10 @@ export const CAPABILITIES = [
file: `${API}/services/domain/signing/provider-adapter-factory.ts`,
name: "createPrivyAdapterFromCredential",
},
+ {
+ file: `${API}/services/domain/signing/provider-adapter-factory.ts`,
+ name: "createFireblocksAdapterFromCredential",
+ },
],
owners: [`${API}/services/domain/signing/provider-adapter-factory.ts`],
allow: [`${API}/services/domain/signing/custody-runtime-target.ts`],
diff --git a/scripts/fireblocks-live.mjs b/scripts/fireblocks-live.mjs
new file mode 100644
index 0000000000..e5f65e79a9
--- /dev/null
+++ b/scripts/fireblocks-live.mjs
@@ -0,0 +1,394 @@
+#!/usr/bin/env node
+// Opt-in live tests through SDP. No Fireblocks private key is exported or accepted here.
+import assert from "node:assert/strict";
+import { createHash, randomUUID, scryptSync } from "node:crypto";
+import { link, mkdir, open, readFile, unlink, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { parseArgs } from "node:util";
+
+// biome-ignore lint/security/noSecrets: Public Solana mainnet genesis hash.
+const MAINNET_GENESIS = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d";
+// biome-ignore lint/security/noSecrets: Public wrapped SOL mint address.
+const SOL_MINT = "So11111111111111111111111111111111111111112";
+const ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
+
+export function validateConfig(config) {
+ const base = new URL(config.baseUrl);
+ assert(
+ ["127.0.0.1", "localhost", "[::1]"].includes(base.hostname),
+ "SDP must be a local instance"
+ );
+ assert.equal(base.protocol, "http:", "Use the local HTTP API directly");
+ assert(
+ !base.username && !base.password && !base.search && !base.hash && base.pathname === "/",
+ "baseUrl must be an origin"
+ );
+ const rpc = new URL(config.rpcUrl);
+ assert.equal(rpc.protocol, "https:", "Use your HTTPS mainnet RPC");
+ assert(!rpc.username && !rpc.password && !rpc.hash, "Invalid RPC URL");
+ for (const key of ["projectId", "custodyWalletId"]) {
+ assert.equal(typeof config[key], "string", `${key} is required`);
+ assert(/^[a-zA-Z0-9_-]{1,128}$/.test(config[key]), `Invalid ${key}`);
+ }
+ if (config.feePayer !== undefined)
+ assert(ADDRESS.test(config.feePayer), "Invalid fee payer address");
+ assert.equal(config.network, "mainnet-beta", "This runner is explicitly for mainnet-beta");
+ return {
+ baseUrl: base.origin,
+ rpcUrl: rpc.href,
+ projectId: config.projectId,
+ custodyWalletId: config.custodyWalletId,
+ network: "mainnet-beta",
+ feePayer: config.feePayer,
+ payment: config.payment,
+ };
+}
+
+function paymentBody(config) {
+ const payment = config.payment;
+ assert(payment && ADDRESS.test(payment.destination), "Set an explicit payment destination");
+ assert(
+ payment.token === "SOL" || ADDRESS.test(payment.token),
+ "Use SOL or an explicit token mint"
+ );
+ assert(
+ typeof payment.amount === "string" &&
+ /^(0|[1-9]\d*)(\.\d+)?$/.test(payment.amount) &&
+ /[1-9]/.test(payment.amount),
+ "Set a positive decimal payment amount as a string"
+ );
+ assert(payment.amount.length <= 40, "Payment amount is too long");
+ assert(
+ payment.memo === undefined || (typeof payment.memo === "string" && payment.memo.length <= 256),
+ "Invalid memo"
+ );
+ return {
+ sourceCustodyWalletId: config.custodyWalletId,
+ destination: payment.destination,
+ token: payment.token,
+ amount: payment.amount,
+ memo: payment.memo,
+ };
+}
+
+/** Persist intent BEFORE the first request. Retries never choose a fresh payment key. */
+export async function paymentAttempt(path, intent, apiKey) {
+ assert(
+ typeof apiKey === "string" && apiKey.startsWith("sk_"),
+ "A receipt must be bound to an SDP API key"
+ );
+ const apiKeyFingerprint = scryptSync(apiKey, "sdp-fireblocks-live-receipt-v1", 32).toString(
+ "hex"
+ );
+ const fingerprint = createHash("sha256").update(JSON.stringify(intent)).digest("hex");
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
+ const proposed = {
+ fingerprint,
+ apiKeyFingerprint,
+ idempotencyKey: randomUUID(),
+ createdAt: new Date().toISOString(),
+ };
+ const temporary = `${path}.${randomUUID()}.tmp`;
+ const file = await open(temporary, "wx", 0o600);
+ try {
+ await file.writeFile(`${JSON.stringify(proposed, null, 2)}\n`);
+ await file.sync();
+ } finally {
+ await file.close();
+ }
+ try {
+ // Publish a complete file atomically without replacing a concurrent attempt.
+ await link(temporary, path);
+ return proposed;
+ } catch (error) {
+ if (error.code !== "EEXIST") throw error;
+ const existing = JSON.parse(await readFile(path, "utf8"));
+ assert.equal(
+ existing.fingerprint,
+ fingerprint,
+ "This receipt belongs to a different payment. Review it; choose a new receipt path only for an intentional new payment."
+ );
+ assert.equal(
+ existing.apiKeyFingerprint,
+ apiKeyFingerprint,
+ "This receipt belongs to a different SDP API key (or predates key binding). Do not resend. Inspect the original transfer in SDP and Fireblocks."
+ );
+ assert.match(existing.idempotencyKey, /^[a-f0-9-]{36}$/);
+ assert.equal(typeof existing.createdAt, "string");
+ assert(Number.isFinite(Date.parse(existing.createdAt)), "Invalid receipt creation date");
+ return {
+ fingerprint,
+ apiKeyFingerprint,
+ idempotencyKey: existing.idempotencyKey,
+ createdAt: existing.createdAt,
+ };
+ } finally {
+ await unlink(temporary);
+ }
+}
+
+export async function runLive(rawConfig, mode, options = {}) {
+ const config = validateConfig(rawConfig);
+ assert(
+ ["preflight", "signer", "payment", "status"].includes(mode),
+ "Choose preflight, signer, payment, or status"
+ );
+ if (mode === "signer") assert(ADDRESS.test(config.feePayer), "Signer check needs feePayer");
+ if (mode === "status")
+ assert(options.receiptPath, "Status requires --receipt from the payment attempt");
+ const fetcher = options.fetchImpl ?? fetch;
+ const log = options.log ?? console.log;
+ // biome-ignore lint/suspicious/noUndeclaredEnvVars: Standalone opt-in runner, never a cached Turbo task.
+ const apiKey = options.apiKey ?? process.env.SDP_LIVE_API_KEY;
+ assert(
+ apiKey?.startsWith("sk_"),
+ "Set SDP_LIVE_API_KEY to a production SDP API key bound to this wallet"
+ );
+ if (mode === "payment") {
+ assert.equal(
+ options.sendMainnet,
+ true,
+ "Payment requires --send-mainnet; it spends real funds"
+ );
+ assert(options.receiptPath, "Payment requires --receipt to preserve its idempotency key");
+ paymentBody(config);
+ }
+ const api = async (path, body, idempotencyKey) => {
+ const headers = {
+ Authorization: `Bearer ${apiKey}`,
+ "X-Project-ID": config.projectId,
+ "Content-Type": "application/json",
+ };
+ if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
+ let response;
+ try {
+ response = await fetcher(`${config.baseUrl}${path}`, {
+ method: body === undefined ? "GET" : "POST",
+ redirect: "error",
+ headers,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: AbortSignal.timeout(180_000),
+ });
+ } catch {
+ throw new Error(
+ "SDP request did not return a response. Its outcome may be unknown. Preserve the payment receipt and retry with the same command; do not create another payment."
+ );
+ }
+ const envelope = await response.json();
+ if (!response.ok)
+ throw new Error(
+ `SDP HTTP ${response.status}: ${envelope.error?.code ?? "request_failed"}. Inspect the dashboard for details; keep the same receipt for any retry.`
+ );
+ return envelope.data;
+ };
+ const rpc = async (requests) => {
+ let response;
+ try {
+ response = await fetcher(config.rpcUrl, {
+ method: "POST",
+ redirect: "error",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(
+ requests.map(([method, params], id) => ({ jsonrpc: "2.0", id, method, params }))
+ ),
+ signal: AbortSignal.timeout(20_000),
+ });
+ } catch {
+ throw new Error("Mainnet RPC request failed");
+ }
+ assert(response.ok, `Mainnet RPC HTTP ${response.status}`);
+ const results = await response.json();
+ return requests.map((_, id) => {
+ const entry = results.find((item) => item.id === id);
+ assert(entry && !entry.error, `Mainnet RPC operation ${id} failed`);
+ return entry.result;
+ });
+ };
+ const project = (await api(`/v1/projects/${encodeURIComponent(config.projectId)}`)).project;
+ assert.equal(project.environment, "production", "The SDP project must be production");
+ let wallet;
+ let result;
+ if (mode === "status") {
+ // The operational wallet list omits deactivated connections. Recover the
+ // existing transfer directly, then verify its receipt and exact intent below.
+ const saved = JSON.parse(await readFile(`${options.receiptPath}.result.json`, "utf8"));
+ assert(
+ typeof saved.transferId === "string" && /^xfr_[a-zA-Z0-9_-]{1,100}$/.test(saved.transferId),
+ "Receipt has an invalid transfer ID"
+ );
+ result = await api(`/v1/payments/transfers/${encodeURIComponent(saved.transferId)}`);
+ assert(result?.transfer, "SDP did not return the recorded transfer");
+ assert.equal(result.transfer.id, saved.transferId, "Transfer does not match the receipt");
+ wallet = { id: config.custodyWalletId, publicKey: result.transfer.source };
+ } else {
+ const query = new URLSearchParams({
+ provider: "fireblocks",
+ view: "summary",
+ includeBalances: "false",
+ });
+ const wallets = (await api(`/v1/wallets?${query}`)).wallets;
+ wallet = wallets.find((item) => item.id === config.custodyWalletId);
+ assert(
+ wallet && wallet.provider === "fireblocks" && wallet.custodyConnectionId,
+ "Select a Fireblocks BYOK wallet in this project"
+ );
+ assert(
+ wallet.status === "active" && wallet.isRuntimeExecutionAllowed === true,
+ "The Fireblocks connection must be active and enabled"
+ );
+ }
+ assert(ADDRESS.test(wallet.publicKey), "Invalid source address");
+ const preflightReads = [["getGenesisHash", []]];
+ if (mode !== "status")
+ preflightReads.push([
+ "getMultipleAccounts",
+ [
+ [wallet.publicKey, ...(mode === "signer" ? [config.feePayer] : [])],
+ { encoding: "base64", commitment: "confirmed" },
+ ],
+ ]);
+ const [genesis, accounts] = await rpc(preflightReads);
+ assert.equal(genesis, MAINNET_GENESIS, "RPC is not Solana mainnet-beta");
+ const payerLamports = mode === "signer" ? (accounts.value[1]?.lamports ?? 0) : null;
+ const sourceLamports = mode === "status" ? null : (accounts.value[0]?.lamports ?? 0);
+ log(
+ JSON.stringify({
+ check: "preflight",
+ network: "mainnet-beta",
+ wallet: wallet.publicKey,
+ feePayer: config.feePayer,
+ feePayerLamports: payerLamports,
+ sourceLamports,
+ })
+ );
+ if (mode !== "status")
+ assert(
+ sourceLamports >= 10_000,
+ "Fund the source Fireblocks vault with SOL for network fees, including USDC transfers."
+ );
+ if (mode === "preflight") return { wallet, payerLamports, sourceLamports };
+ if (mode === "signer") {
+ assert(
+ payerLamports >= 10_000,
+ "Fund the local fee payer before the optional RAW signer check."
+ );
+ log(
+ "Approve the memo signer check in Fireblocks now. This test never broadcasts or transfers funds."
+ );
+ const result = await api("/v1/wallets/signer-check", { walletId: wallet.walletId });
+ assert.equal(result.walletAddress, wallet.publicKey);
+ assert.equal(result.feePayer, config.feePayer);
+ assert.equal(result.simulated, true);
+ assert(typeof result.signature === "string" && result.signature.length > 0);
+ log("PASS: real Fireblocks signature verified and memo simulated; nothing broadcast.");
+ return result;
+ }
+ const body = paymentBody(config);
+ assert.notEqual(
+ body.destination,
+ wallet.publicKey,
+ "Choose a distinct recipient for the payment test"
+ );
+ const attempt = await paymentAttempt(
+ options.receiptPath,
+ {
+ baseUrl: config.baseUrl,
+ projectId: config.projectId,
+ source: wallet.publicKey,
+ body,
+ },
+ apiKey
+ );
+ log(
+ JSON.stringify({
+ action: mode === "status" ? "read-payment-status" : "send-mainnet-payment",
+ source: wallet.publicKey,
+ ...body,
+ idempotencyKey: attempt.idempotencyKey,
+ })
+ );
+ if (mode !== "status") {
+ log(
+ "Approve the payment in Fireblocks. Fireblocks builds, signs and broadcasts; the source vault pays fees."
+ );
+ result = await api("/v1/payments/transfers", body, attempt.idempotencyKey);
+ }
+ if (!result?.transfer) {
+ throw new Error(
+ "SDP did not return a transfer; the payment may be awaiting an SDP policy approval. Check Approvals in the dashboard. Preserve this receipt."
+ );
+ }
+ const transfer = result.transfer;
+ assert.equal(transfer.custodyWalletId, wallet.id);
+ assert.equal(transfer.projectId, config.projectId);
+ assert.equal(transfer.source, wallet.publicKey);
+ assert.equal(transfer.destination, body.destination);
+ assert.equal(transfer.amount, body.amount);
+ assert.equal(transfer.token, body.token === "SOL" ? SOL_MINT : body.token);
+ assert(
+ typeof transfer.id === "string" && /^xfr_[a-zA-Z0-9_-]{1,100}$/.test(transfer.id),
+ "Invalid transfer ID in SDP response"
+ );
+ // The local receipt only needs this validated identifier for status recovery.
+ // Do not persist arbitrary response fields, provider metadata or credentials.
+ await writeFile(
+ `${options.receiptPath}.result.json`,
+ `${JSON.stringify({ transferId: transfer.id }, null, 2)}\n`,
+ { mode: 0o600 }
+ );
+ if (transfer.status === "processing") {
+ log(
+ `PENDING: ${transfer.id}; Fireblocks ${transfer.fireblocks?.status ?? "status unavailable"}. Approve in Fireblocks, then run status with the same --receipt. Do not create another payment.`
+ );
+ return { transferId: transfer.id, status: transfer.status, fireblocks: transfer.fireblocks };
+ }
+ assert(
+ ["confirmed", "finalized"].includes(transfer.status),
+ `Payment is ${transfer.status}; inspect SDP before taking further action. Preserve the receipt.`
+ );
+ assert(transfer.signature, "Confirmed payment is missing its signature");
+ const [statuses] = await rpc([
+ ["getSignatureStatuses", [[transfer.signature], { searchTransactionHistory: true }]],
+ ]);
+ const chain = statuses.value[0];
+ assert(
+ chain && chain.err === null && ["confirmed", "finalized"].includes(chain.confirmationStatus),
+ "Payment not confirmed by mainnet RPC yet. Preserve the receipt and recheck; do not send another payment."
+ );
+ log(`PASS: mainnet payment confirmed: https://explorer.solana.com/tx/${transfer.signature}`);
+ return { transferId: transfer.id, signature: transfer.signature, status: transfer.status };
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ try {
+ const { values, positionals } = parseArgs({
+ options: {
+ config: { type: "string" },
+ receipt: { type: "string" },
+ "send-mainnet": { type: "boolean" },
+ help: { type: "boolean" },
+ },
+ allowPositionals: true,
+ });
+ if (values.help) {
+ console.log(
+ // biome-ignore lint/security/noSecrets: CLI usage text, not a credential.
+ "Usage: node scripts/fireblocks-live.mjs --config ",
+ "[--send-mainnet --receipt ]"
+ );
+ console.log("Set SDP_LIVE_API_KEY locally. See docs/ops/fireblocks-byok.md.");
+ console.log("No tests run implicitly.");
+ } else {
+ assert(values.config && positionals.length === 1, "Provide a mode and --config; use --help");
+ const config = JSON.parse(await readFile(resolve(values.config), "utf8"));
+ await runLive(config, positionals[0], {
+ sendMainnet: values["send-mainnet"],
+ receiptPath: values.receipt ? resolve(values.receipt) : undefined,
+ });
+ }
+ } catch (error) {
+ console.error(`FAIL: ${error.message}`);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/fireblocks-live.test.mjs b/scripts/fireblocks-live.test.mjs
new file mode 100644
index 0000000000..e2314d70a9
--- /dev/null
+++ b/scripts/fireblocks-live.test.mjs
@@ -0,0 +1,412 @@
+import assert from "node:assert/strict";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { paymentAttempt, runLive, validateConfig } from "./fireblocks-live.mjs";
+
+const source = "11111111111111111111111111111111";
+// biome-ignore lint/security/noSecrets: Public wrapped SOL mint used as a synthetic recipient.
+const recipient = "So11111111111111111111111111111111111111112";
+const config = {
+ baseUrl: "http://127.0.0.1:8787",
+ network: "mainnet-beta",
+ projectId: "prj_live",
+ custodyWalletId: "cwlt_live",
+ rpcUrl: "https://rpc.example.test/",
+ feePayer: recipient,
+ payment: { destination: recipient, token: "SOL", amount: "0.00001" },
+};
+const wallet = {
+ id: config.custodyWalletId,
+ walletId: "fb_0",
+ provider: "fireblocks",
+ custodyConnectionId: "cconn_live",
+ status: "active",
+ isRuntimeExecutionAllowed: true,
+ publicKey: source,
+};
+const transfer = {
+ id: "xfr_live",
+ custodyWalletId: wallet.id,
+ projectId: config.projectId,
+ source,
+ destination: recipient,
+ amount: "0.00001",
+ token: recipient,
+ status: "confirmed",
+ signature: "signature",
+};
+
+function transport(overrides = {}) {
+ const calls = [];
+ const fetchImpl = async (url, init) => {
+ calls.push({ url, ...init });
+ assert.equal(init.redirect, "error");
+ if (url === config.rpcUrl)
+ return Response.json(
+ JSON.parse(init.body).map(({ id, method }) => ({
+ id,
+ result: {
+ // biome-ignore lint/security/noSecrets: Public Solana mainnet genesis hash.
+ getGenesisHash: overrides.genesis ?? "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
+ getMultipleAccounts: {
+ value: [
+ { lamports: overrides.source ?? 2_000_000 },
+ { lamports: overrides.payer ?? 1_000_000 },
+ ],
+ },
+ getSignatureStatuses: { value: [{ err: null, confirmationStatus: "confirmed" }] },
+ }[method],
+ }))
+ );
+ if (url.endsWith(`/v1/projects/${config.projectId}`))
+ return Response.json({
+ data: { project: { environment: overrides.environment ?? "production" } },
+ });
+ if (url.includes("/v1/wallets?"))
+ return Response.json({
+ data: { wallets: overrides.walletMissing ? [] : [{ ...wallet, ...overrides.wallet }] },
+ });
+ if (url.endsWith("/signer-check"))
+ return Response.json({
+ data: {
+ walletAddress: source,
+ feePayer: config.feePayer,
+ simulated: true,
+ signature: "memo-signature",
+ },
+ });
+ if (url.endsWith("/v1/payments/transfers")) {
+ if (overrides.unknown) throw new Error("socket closed");
+ return Response.json({
+ data: {
+ transfer: overrides.pending
+ ? {
+ ...transfer,
+ status: "processing",
+ signature: null,
+ fireblocks: {
+ transactionId: "fb_proposal",
+ status: "PENDING_AUTHORIZATION",
+ subStatus: null,
+ },
+ }
+ : { ...transfer, ...overrides.transfer },
+ },
+ });
+ }
+ if (url.endsWith(`/v1/payments/transfers/${transfer.id}`))
+ return Response.json({ data: { transfer: { ...transfer, ...overrides.transfer } } });
+ throw new Error("Unexpected request");
+ };
+ return { calls, fetchImpl, apiKey: "sk_test_runner", log: () => {} };
+}
+
+test("refuses remote SDP origins and malformed config before sending credentials", () => {
+ assert.throws(
+ () => validateConfig({ ...config, baseUrl: "https://remote.example.test" }),
+ /local instance/
+ );
+ assert.throws(
+ () => validateConfig({ ...config, baseUrl: "http://localhost:8787/redirect" }),
+ /origin/
+ );
+ assert.throws(() => validateConfig({ ...config, network: "devnet" }), /mainnet/);
+});
+
+test("payment cannot execute without explicit spending opt-in", async () => {
+ const t = transport();
+ await assert.rejects(runLive(config, "payment", t), /--send-mainnet/);
+ assert.equal(t.calls.length, 0);
+});
+
+test("preflight makes only reads and refuses devnet, non-BYOK, disabled wallets and unfunded source vaults", async () => {
+ for (const overrides of [
+ { genesis: "devnet" },
+ { environment: "sandbox" },
+ { wallet: { custodyConnectionId: null } },
+ { wallet: { isRuntimeExecutionAllowed: false } },
+ { source: 0 },
+ ]) {
+ const t = transport(overrides);
+ await assert.rejects(runLive(config, "preflight", t));
+ assert.equal(
+ t.calls.filter((call) => call.method === "POST" && call.url !== config.rpcUrl).length,
+ 0
+ );
+ }
+ await runLive(config, "preflight", transport());
+});
+
+test("signer test invokes only the no-broadcast SDP signer check", async () => {
+ const t = transport();
+ await runLive(config, "signer", t);
+ const writes = t.calls.filter((call) => call.method === "POST" && call.url !== config.rpcUrl);
+ assert.equal(writes.length, 1);
+ assert(writes[0].url.endsWith("/v1/wallets/signer-check"));
+ assert.deepEqual(JSON.parse(writes[0].body), { walletId: "fb_0" });
+});
+
+test("payment and signer modes still refuse disabled wallets before starting work", async () => {
+ for (const mode of ["payment", "signer"]) {
+ const t = transport({ wallet: { isRuntimeExecutionAllowed: false } });
+ await assert.rejects(
+ runLive(config, mode, { ...t, sendMainnet: true, receiptPath: "unused-receipt.json" }),
+ /must be active and enabled/
+ );
+ assert.equal(t.calls.filter((call) => call.method === "POST").length, 0);
+ }
+});
+
+test("status recovers an accepted transfer without requiring an operational wallet or funds", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-disabled-status-"));
+ try {
+ const receiptPath = join(dir, "payment.json");
+ await runLive(config, "payment", {
+ ...transport({ pending: true }),
+ receiptPath,
+ sendMainnet: true,
+ });
+ for (const overrides of [
+ { wallet: { isRuntimeExecutionAllowed: false }, source: 0 },
+ { walletMissing: true, source: 0 },
+ ]) {
+ const t = transport(overrides);
+ const result = await runLive(config, "status", { ...t, receiptPath });
+ assert.equal(result.status, "confirmed");
+ assert.equal(
+ t.calls.some((call) => call.url.includes("/v1/wallets")),
+ false
+ );
+ assert.equal(
+ t.calls.filter((call) => call.method === "POST" && call.url !== config.rpcUrl).length,
+ 0
+ );
+ const rpcMethods = t.calls
+ .filter((call) => call.url === config.rpcUrl)
+ .flatMap((call) => JSON.parse(call.body).map((request) => request.method));
+ assert.deepEqual(rpcMethods, ["getGenesisHash", "getSignatureStatuses"]);
+ }
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("status still binds the recovered transfer to the receipt, key, project, wallet and payment", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-status-intent-"));
+ try {
+ const receiptPath = join(dir, "payment.json");
+ await runLive(config, "payment", {
+ ...transport({ pending: true }),
+ receiptPath,
+ sendMainnet: true,
+ });
+ for (const patch of [
+ { id: "xfr_other" },
+ { custodyWalletId: "cwlt_other" },
+ { projectId: "prj_other" },
+ { source: recipient },
+ { destination: source },
+ { amount: "1" },
+ { token: source },
+ ]) {
+ const t = transport({ walletMissing: true, transfer: patch });
+ await assert.rejects(runLive(config, "status", { ...t, receiptPath }));
+ assert.equal(
+ t.calls.filter((call) => call.method === "POST" && call.url !== config.rpcUrl).length,
+ 0
+ );
+ assert.deepEqual(JSON.parse(await readFile(`${receiptPath}.result.json`, "utf8")), {
+ transferId: transfer.id,
+ });
+ }
+ await assert.rejects(
+ runLive(config, "status", {
+ ...transport({ walletMissing: true }),
+ apiKey: "sk_other",
+ receiptPath,
+ }),
+ /different SDP API key/
+ );
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("unknown payment outcome preserves the exact intent and key on retry", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-live-runner-"));
+ try {
+ const receiptPath = join(dir, "payment.json");
+ const first = transport({ unknown: true });
+ await assert.rejects(
+ runLive(config, "payment", { ...first, receiptPath, sendMainnet: true }),
+ /outcome may be unknown/
+ );
+ const second = transport();
+ await runLive(config, "payment", { ...second, receiptPath, sendMainnet: true });
+ const before = first.calls.find((call) => call.url.endsWith("/transfers"));
+ const after = second.calls.find((call) => call.url.endsWith("/transfers"));
+ assert.equal(before.headers["Idempotency-Key"], after.headers["Idempotency-Key"]);
+ assert.equal(before.body, after.body);
+ const result = JSON.parse(await readFile(`${receiptPath}.result.json`, "utf8"));
+ assert.deepEqual(result, { transferId: transfer.id });
+ await assert.rejects(
+ paymentAttempt(receiptPath, { different: "intent" }, "sk_test"),
+ /different payment/
+ );
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("concurrent runner starts share the same durable payment key", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-live-race-"));
+ try {
+ const path = join(dir, "intent.json");
+ const [first, ...retries] = await Promise.all([
+ paymentAttempt(path, { amount: "1" }, "sk_test"),
+ paymentAttempt(path, { amount: "1" }, "sk_test"),
+ paymentAttempt(path, { amount: "1" }, "sk_test"),
+ ]);
+ assert(retries.every((attempt) => attempt.idempotencyKey === first.idempotencyKey));
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("native payment returns pending and status mode reconciles by GET without another payment POST", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-native-runner-"));
+ try {
+ const receiptPath = join(dir, "payment.json");
+ const first = transport({ pending: true });
+ const pending = await runLive({ ...config, feePayer: undefined }, "payment", {
+ ...first,
+ receiptPath,
+ sendMainnet: true,
+ });
+ assert.equal(pending.status, "processing");
+ const read = transport();
+ const confirmed = await runLive({ ...config, feePayer: undefined }, "status", {
+ ...read,
+ receiptPath,
+ });
+ assert.equal(confirmed.status, "confirmed");
+ assert.equal(
+ read.calls.filter((call) => call.method === "POST" && call.url !== config.rpcUrl).length,
+ 0
+ );
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("refuses a new API key against an existing receipt before submitting", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-key-binding-"));
+ try {
+ const receiptPath = join(dir, "payment.json");
+ const first = transport();
+ await runLive(config, "payment", {
+ ...first,
+ apiKey: "sk_original",
+ receiptPath,
+ sendMainnet: true,
+ });
+ const second = transport();
+ await assert.rejects(
+ runLive(config, "payment", {
+ ...second,
+ apiKey: "sk_rotated",
+ receiptPath,
+ sendMainnet: true,
+ }),
+ /different SDP API key/
+ );
+ assert.equal(
+ second.calls.filter((call) => call.url.endsWith("/v1/payments/transfers")).length,
+ 0
+ );
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("only explicit config and receipt fields reach local SDP; RPC never receives the SDP credential", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-file-boundary-"));
+ try {
+ const receiptPath = join(dir, "payment.json");
+ const marker = "unexpected-private-file-content";
+ const input = {
+ ...config,
+ unexpected: marker,
+ payment: { ...config.payment, unexpected: marker },
+ };
+ const first = transport();
+ await runLive(input, "payment", { ...first, receiptPath, sendMainnet: true });
+ const receipt = JSON.parse(await readFile(receiptPath, "utf8"));
+ await writeFile(receiptPath, JSON.stringify({ ...receipt, unexpected: marker }));
+ const retry = transport();
+ await runLive(input, "payment", { ...retry, receiptPath, sendMainnet: true });
+ assert(!JSON.stringify(retry.calls).includes(marker));
+ for (const call of retry.calls) {
+ if (call.url === config.rpcUrl) {
+ assert(!JSON.stringify(call).includes(retry.apiKey));
+ assert.equal(call.headers.Authorization, undefined);
+ } else {
+ assert.equal(new URL(call.url).origin, config.baseUrl);
+ assert.equal(call.headers.Authorization, `Bearer ${retry.apiKey}`);
+ }
+ }
+ const result = JSON.parse(await readFile(`${receiptPath}.result.json`, "utf8"));
+ assert.deepEqual(result, { transferId: transfer.id });
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("invalid network identifiers are never persisted to the result receipt", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-response-boundary-"));
+ try {
+ const receiptPath = join(dir, "payment.json");
+ const t = transport({ transfer: { id: "../../untrusted-path" } });
+ await assert.rejects(
+ runLive(config, "payment", { ...t, receiptPath, sendMainnet: true }),
+ /Invalid transfer ID/
+ );
+ await assert.rejects(readFile(`${receiptPath}.result.json`), { code: "ENOENT" });
+ assert(JSON.parse(await readFile(receiptPath, "utf8")).idempotencyKey);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("response metadata is not copied into the local result receipt", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-response-projection-"));
+ try {
+ const receiptPath = join(dir, "payment.json");
+ const t = transport({ transfer: { unexpected: "provider-secret-or-untrusted-data" } });
+ await runLive(config, "payment", { ...t, receiptPath, sendMainnet: true });
+ assert.deepEqual(JSON.parse(await readFile(`${receiptPath}.result.json`, "utf8")), {
+ transferId: transfer.id,
+ });
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("tampered status identifiers cannot change the local SDP request path", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "fireblocks-status-boundary-"));
+ try {
+ const receiptPath = join(dir, "payment.json");
+ await runLive(config, "payment", { ...transport(), receiptPath, sendMainnet: true });
+ await writeFile(
+ `${receiptPath}.result.json`,
+ JSON.stringify({ transferId: "xfr_good/../../api-keys" })
+ );
+ const t = transport();
+ await assert.rejects(runLive(config, "status", { ...t, receiptPath }), /invalid transfer ID/);
+ assert(!t.calls.some((call) => call.url.includes("/v1/payments/transfers")));
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});