Skip to content
Merged
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
27 changes: 20 additions & 7 deletions apps/sdp-api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SigningError } from "@sdp/custody/signing";
import { SdpPaymentsError } from "@sdp/payments/errors";
import { SdpRpcError } from "@sdp/rpc/errors";
import { SDP_RAMP_PROVIDER_STAGES } from "@sdp/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { createApp, type SdpPlugin } from "@/app";
Expand Down Expand Up @@ -52,7 +53,7 @@ function makeObservability(): {
}

function buildApp(observability: Observability) {
const app = createApp({ observability });
const app = createApp({ rampProviderStages: SDP_RAMP_PROVIDER_STAGES, observability });
// Mount a route that throws after createApp returns, so we exercise the
// onError path without modifying the production createApp surface.
app.all(THROW_PATH, () => {
Expand Down Expand Up @@ -168,7 +169,11 @@ describe("createApp plugin registration", () => {
v1.get("/test-plugin", (c) => c.json({ ok: true }));
},
};
const app = createApp({ observability: obs, plugins: [plugin] });
const app = createApp({
rampProviderStages: SDP_RAMP_PROVIDER_STAGES,
observability: obs,
plugins: [plugin],
});

const res = await app.request("/v1/test-plugin", {}, baseEnv);

Expand All @@ -185,7 +190,11 @@ describe("createApp plugin registration", () => {
v1.post("/body-limit-test", (c) => c.json({ ok: true }));
},
};
const app = createApp({ observability: obs, plugins: [plugin] });
const app = createApp({
rampProviderStages: SDP_RAMP_PROVIDER_STAGES,
observability: obs,
plugins: [plugin],
});

const res = await app.request(
"/v1/body-limit-test",
Expand All @@ -204,7 +213,7 @@ describe("createApp plugin registration", () => {

it("returns 404 for an unregistered route when no plugins are passed", async () => {
const { obs } = makeObservability();
const app = createApp({ observability: obs });
const app = createApp({ rampProviderStages: SDP_RAMP_PROVIDER_STAGES, observability: obs });

const res = await app.request("/v1/test-plugin", {}, baseEnv);

Expand All @@ -215,9 +224,13 @@ describe("createApp plugin registration", () => {
const { obs } = makeObservability();
const make = (name: string): SdpPlugin => ({ name, register: () => {} });

expect(() => createApp({ observability: obs, plugins: [make("dup"), make("dup")] })).toThrow(
/dup/
);
expect(() =>
createApp({
rampProviderStages: SDP_RAMP_PROVIDER_STAGES,
observability: obs,
plugins: [make("dup"), make("dup")],
})
).toThrow(/dup/);
});
});

Expand Down
10 changes: 10 additions & 0 deletions apps/sdp-api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SdpRampProviderStages } from "@sdp/types";
/**
* SDP API — Hono application factory.
*
Expand Down Expand Up @@ -33,6 +34,7 @@ import { kvStoreMiddleware } from "@/middleware/kv-store";
import { skipRateLimitPaths } from "@/middleware/rate-limit";
import { requestIdMiddleware } from "@/middleware/request-id";
import { requestTracingMiddleware } from "@/middleware/request-tracing";
import { requireModule } from "@/middleware/require-module";
import allowlist from "@/routes/allowlist";
import apiKeys from "@/routes/api-keys";
import assetProfiles from "@/routes/asset-profiles";
Expand Down Expand Up @@ -76,6 +78,12 @@ export interface SdpPlugin {
export interface AppDeps {
observability: Observability;
plugins?: SdpPlugin[];
/**
* Ramp provider stages to run the release channel against. Production passes
* `SDP_RAMP_PROVIDER_STAGES`; tests may put one provider in a release channel and
* leave another out, which today's all-`experimental` stages cannot show.
*/
rampProviderStages: SdpRampProviderStages;
}

// Routes that need no KV bindings. Shared by kvStoreMiddleware (skip the
Expand Down Expand Up @@ -306,6 +314,7 @@ export function createApp(deps: AppDeps): Hono<{ Bindings: Env }> {

app.use("*", async (c, next) => {
c.set("observability", deps.observability);
c.set("rampProviderStages", deps.rampProviderStages);
await next();
});

Expand Down Expand Up @@ -394,6 +403,7 @@ export function createApp(deps: AppDeps): Hono<{ Bindings: Env }> {
// Asset profiles live under the issuance namespace, as a sibling of
// /issuance/tokens. The router is self-contained (own auth + feature-flag + project
// middleware).
v1.use("/issuance/*", requireModule("issuance"));
v1.route("/issuance/asset-profiles", assetProfiles);
v1.route("/issuance", issuance);
v1.route("/wallets", wallets);
Expand Down
5 changes: 3 additions & 2 deletions apps/sdp-api/src/cron/pending-transfers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SDP_RAMP_PROVIDER_STAGES } from "@sdp/types";
/**
* Pending-transfers reconciliation entrypoint.
*
Expand Down Expand Up @@ -29,8 +30,8 @@ export function runPendingTransfersReconciliation(deps: PendingTransfersReconcil
await Promise.all([
trackPendingTransfers(deps.env),
reconcileSponsorshipBudgets(deps.env),
replayRampWebhookEvents(deps.env),
reconcileBvnkOnrampPayouts(deps.env),
replayRampWebhookEvents(deps.env, SDP_RAMP_PROVIDER_STAGES),
reconcileBvnkOnrampPayouts(deps.env, SDP_RAMP_PROVIDER_STAGES),
]);
};

Expand Down
31 changes: 26 additions & 5 deletions apps/sdp-api/src/db/repositories/ramp-webhook-event.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface ClaimReplayableRampWebhookEventsInput {
createdBefore: string;
maxAttempts: number;
limit: number;
/** Rows from these providers are never claimed (their release channel leaves them out). */
excludedProviders: readonly RampProviderId[];
}

export interface RecordRampWebhookEventFailureInput {
Expand All @@ -57,6 +59,14 @@ export interface ParkExhaustedRampWebhookEventsInput {
updatedBefore: string;
maxAttempts: number;
appRevision: string;
/** Rows from these providers are never parked. */
excludedProviders: readonly RampProviderId[];
}

export interface RearmRampWebhookEventsInput {
appRevision: string;
/** Rows from these providers are never re-armed. */
excludedProviders: readonly RampProviderId[];
}

export interface RampWebhookEventsRepository {
Expand Down Expand Up @@ -90,7 +100,7 @@ export interface RampWebhookEventsRepository {
* revision stays parked — retrying the same code against the same payload
* only burns attempts.
*/
rearmParkedByOtherRevisions(appRevision: string): Promise<RampWebhookEventRow[]>;
rearmParkedByOtherRevisions(input: RearmRampWebhookEventsInput): Promise<RampWebhookEventRow[]>;
}

function mapRow(row: Record<string, unknown>): RampWebhookEventRow {
Expand Down Expand Up @@ -172,13 +182,20 @@ export function createPostgresRampWebhookEventsRepository(db: AppDb): RampWebhoo
WHERE id IN (
SELECT id FROM ramp_webhook_events
WHERE status = 'pending' AND created_at <= ? AND updated_at <= ? AND attempts < ?
AND provider <> ALL(?::text[])
ORDER BY created_at ASC
LIMIT ?
FOR UPDATE SKIP LOCKED
)
RETURNING *`
)
.bind(input.createdBefore, input.createdBefore, input.maxAttempts, input.limit)
.bind(
input.createdBefore,
input.createdBefore,
input.maxAttempts,
[...input.excludedProviders],
input.limit
)
.all<Record<string, unknown>>();
return result.results.map(mapRow);
},
Expand All @@ -189,14 +206,17 @@ export function createPostgresRampWebhookEventsRepository(db: AppDb): RampWebhoo
`UPDATE ramp_webhook_events
SET status = 'failed', parked_app_revision = ?, updated_at = sdp_iso_now()
WHERE status = 'pending' AND attempts >= ? AND updated_at <= ?
AND provider <> ALL(?::text[])
RETURNING *`
)
.bind(input.appRevision, input.maxAttempts, input.updatedBefore)
.bind(input.appRevision, input.maxAttempts, input.updatedBefore, [
...input.excludedProviders,
])
.all<Record<string, unknown>>();
return result.results.map(mapRow);
},

async rearmParkedByOtherRevisions(appRevision) {
async rearmParkedByOtherRevisions(input) {
// Same batch and lock discipline as the claim path: bounded per pass —
// the parked set is small by nature, and the next minutely pass takes
// the rest — and SKIP LOCKED so overlapping passes never fight over a
Expand All @@ -209,13 +229,14 @@ export function createPostgresRampWebhookEventsRepository(db: AppDb): RampWebhoo
WHERE id IN (
SELECT id FROM ramp_webhook_events
WHERE status = 'failed' AND NOT terminal AND parked_app_revision IS DISTINCT FROM ?
AND provider <> ALL(?::text[])
ORDER BY created_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKED
)
RETURNING *`
)
.bind(appRevision)
.bind(input.appRevision, [...input.excludedProviders])
.all<Record<string, unknown>>();
return result.results.map(mapRow);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export function createPostgresUnifiedTransactionsRepository(
const values: unknown[] = [input.organizationId];
clauses.push(`module IN (${buildInClause(input.modules.length)})`);
values.push(...input.modules);
if (input.excludedRampProviders.length > 0) {
clauses.push(
`NOT (module = 'payments' AND EXISTS (SELECT 1 FROM payment_transfers pt WHERE pt.id = unified_transactions.module_id AND pt.provider IN (${buildInClause(input.excludedRampProviders.length)})))`
);
values.push(...input.excludedRampProviders);
}
if (input.projectId !== null) {
clauses.push("project_id IS NOT DISTINCT FROM ?");
values.push(input.projectId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments"],
excludedRampProviders: [],
module: "payments",
kind: "batch_pay",
status: "succeeded",
Expand All @@ -132,6 +133,7 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments"] as UnifiedTransactionModule[],
excludedRampProviders: [],
limit: 25,
};

Expand Down Expand Up @@ -165,6 +167,7 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments"],
excludedRampProviders: [],
counterpartyId: "cpty_match",
limit: 25,
});
Expand Down Expand Up @@ -201,6 +204,7 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments"],
excludedRampProviders: [],
limit: 25,
});

Expand Down Expand Up @@ -237,13 +241,15 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments", "issuance"],
excludedRampProviders: [],
limit: 1,
});
if (first.nextCursor === null) throw new Error("first page did not produce a cursor");
const second = await repository.list({
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments", "issuance"],
excludedRampProviders: [],
cursor: first.nextCursor,
limit: 1,
});
Expand Down Expand Up @@ -279,6 +285,7 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments"],
excludedRampProviders: [],
moduleWalletScopes: [{ module: "payments", custodyWalletIds: [CUSTODY_WALLET] }],
limit: 25,
});
Expand All @@ -304,6 +311,7 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments", "earn"],
excludedRampProviders: [],
moduleWalletScopes: [
{ module: "payments", custodyWalletIds: [CUSTODY_WALLET] },
{ module: "earn", custodyWalletIds: [OTHER_CUSTODY_WALLET] },
Expand All @@ -325,6 +333,7 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments"],
excludedRampProviders: [],
moduleWalletScopes: [{ module: "payments", custodyWalletIds: [] }],
limit: 25,
});
Expand Down Expand Up @@ -362,6 +371,7 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments"],
excludedRampProviders: [],
limit: 25,
});

Expand All @@ -381,6 +391,7 @@ describe("UnifiedTransactionsRepository (postgres)", () => {
organizationId: TEST_ORG.id,
projectId: PROJECT,
modules: ["payments"],
excludedRampProviders: [],
cursor: encodeKeysetCursor(JSON.stringify({ createdAt: CREATED_AT }), "missing-fields"),
limit: 25,
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
RampProviderId,
UnifiedTransaction,
UnifiedTransactionModule,
UnifiedTransactionStatus,
Expand All @@ -15,6 +16,8 @@ export interface ListUnifiedTransactionsInput {
projectId: string | null;
moduleWalletScopes?: readonly UnifiedTransactionModuleWalletScope[];
modules: readonly UnifiedTransactionModule[];
/** Ramp providers the release channel leaves out: their Payments rows are not listed. */
excludedRampProviders: readonly RampProviderId[];
module?: UnifiedTransactionModule;
kind?: string;
status?: UnifiedTransactionStatus;
Expand Down
6 changes: 5 additions & 1 deletion apps/sdp-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
* application factory.
*/

import { SDP_RAMP_PROVIDER_STAGES } from "@sdp/types";
import { createApp } from "@/app";
import { noopObservability } from "@/runtime/observability";

const app = createApp({ observability: noopObservability });
const app = createApp({
observability: noopObservability,
rampProviderStages: SDP_RAMP_PROVIDER_STAGES,
});

export default app;
Loading
Loading