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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sdp-api/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ KORA_DEVNET_RPC_URL=https://devnet.helius-rpc.com/?api-key=<your-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

Expand Down
4 changes: 3 additions & 1 deletion apps/sdp-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 3 additions & 0 deletions apps/sdp-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
3 changes: 3 additions & 0 deletions apps/sdp-api/src/cron/secret-retirements.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -14,6 +16,7 @@ export const secretRetirementsCron = defineCron({
effect: "observes",
enabled: alwaysEnabled,
run: async (env) => {
await eraseExpiredFireblocksCsrs(getDb(env));
await retireOrphanedSecrets(env);
},
});
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
cutbow7 marked this conversation as resolved.
);
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));
18 changes: 18 additions & 0 deletions apps/sdp-api/src/db/migrations/postgres/0154_fireblocks_byok.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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');
Original file line number Diff line number Diff line change
@@ -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;
67 changes: 67 additions & 0 deletions apps/sdp-api/src/db/repositories/payments.repository.postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(?)");
Expand Down Expand Up @@ -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<PaymentTransferProjectionRow>();
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<PaymentTransferProjectionRow>();
return row ? mapTransferRow(row) : null;
},

async listTransfersByStatus({
environment,
statuses,
fireblocks,
types,
hasSignature,
createdBefore,
Expand All @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions apps/sdp-api/src/db/repositories/payments.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<PaymentTransferRow | null>;
/** 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<PaymentTransferRow | null>;
createTransfer(input: CreatePaymentTransferInput): Promise<PaymentTransferRow | null>;
/**
* Claims an awaiting off-ramp row for its crypto deposit transaction through
Expand Down
16 changes: 13 additions & 3 deletions apps/sdp-api/src/lib/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isByokCustodyProvider,
} from "@sdp/types/custody";
import {
isCustodyProviderInReleaseChannel,
isModuleInReleaseChannel,
isRampProviderInReleaseChannel,
resolveSdpReleaseChannel,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)(
Expand Down
4 changes: 4 additions & 0 deletions apps/sdp-api/src/openapi/route-actions.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions apps/sdp-api/src/openapi/schemas/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
18 changes: 16 additions & 2 deletions apps/sdp-api/src/openapi/spec.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -985,7 +988,11 @@ function operationFor(document: ReturnType<typeof createOpenApiDocument>, 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<string, ReturnType<typeof declaredIdempotency>>();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading