diff --git a/.agents/skills/integrate-estimate/SKILL.md b/.agents/skills/integrate-estimate/SKILL.md index 29fd71b0d2..66cfb77255 100644 --- a/.agents/skills/integrate-estimate/SKILL.md +++ b/.agents/skills/integrate-estimate/SKILL.md @@ -12,11 +12,11 @@ Choose the closest implementation in `packages/sdp-payments/src/ramps/providers/ ## Contract -Both methods are required on `RampProvider` (`packages/sdp-payments/src/ramps/types.ts`), even when one direction is unsupported: +Each direction the provider runs has its estimate member on `RampProvider` (`packages/sdp-payments/src/ramps/types.ts`); a direction it doesn't run has none: ```ts -estimateOnramp(ctx: RampRuntimeContext, input: RampEstimateOnrampInput): Promise -estimateOfframp(ctx: RampRuntimeContext, input: RampEstimateOfframpInput): Promise +estimateOnramp: (ctx: RampRuntimeContext, input: RampEstimateOnrampInput) => Promise; +estimateOfframp: (ctx: RampRuntimeContext, input: RampEstimateOfframpInput) => Promise; ``` Inputs (`packages/sdp-payments/src/ramps/types.ts`): @@ -37,7 +37,7 @@ Output `PaymentRampEstimate` (`@sdp/types`, `packages/sdp-types/src/payments.ts` ## How to build it -`ctx` is `{ env, mode }` — read your config with the mode-keyed reader from `register-provider`, then HTTP only. Convert the asset rail with `getCryptoRailAssetLabel` from `@sdp/types/payment-rails`; convert minor units with `parseDecimalAmount` / `formatDecimalAmount` from `@sdp/solana/amount`. +`ctx` is `{ mode, cluster, credentials }`: read the keys with `rampCredentials(this, ctx)`, then HTTP only. Convert the asset rail with `getCryptoRailAssetLabel` from `@sdp/types/payment-rails`; convert minor units with `parseDecimalAmount` / `formatDecimalAmount` from `@sdp/solana/amount`. A common shape: GET the corridor's exchange rate once to learn decimals, again with the amount to get the quote, then map into `PaymentRampEstimate`. diff --git a/.agents/skills/integrate-ramp-provider/SKILL.md b/.agents/skills/integrate-ramp-provider/SKILL.md index 5ba0230be5..c81db119ba 100644 --- a/.agents/skills/integrate-ramp-provider/SKILL.md +++ b/.agents/skills/integrate-ramp-provider/SKILL.md @@ -22,9 +22,9 @@ Complete the [ramp intake](https://solanafoundation.typeform.com/to/sxTGbwXt) an ## Sequence -Do them in this order. For unsupported directions, skip business-flow implementation but still satisfy the required `RampProvider` methods with empty rail/entity support and explicit typed rejection; only `createOnrampQuote` is optional. `fetchTransferStatus` is optional on the contract, but the status sweep requires it for every registered provider: a provider SDP cannot look up leaves a transfer open forever when its webhook is lost. +Do them in this order. For an unsupported direction, leave out both of its members and declare empty rail/entity support with an explicit typed rejection. `fetchTransferStatus` is required: a provider SDP cannot look up leaves a transfer open forever when its webhook is lost. -1. **register-provider** — add the id, package client, API schemas/dispatch, availability, setup registry, mode-keyed config, webhook registration decision, and dashboard catalog. Make the skeleton compile. +1. **register-provider** — add the id, package client, API schemas/dispatch, managed sandbox keys, availability, setup registry, webhook registration decision, and dashboard catalog. Make the skeleton compile. 2. **rail-discovery** — declare which fiat/crypto rails you support. 3. **integrate-estimate** — rate preview; the cheapest live end-to-end check (no DB, no KYC). 4. **counterparty-requirements** — required readiness contract for every provider, including providers that immediately return `ready` or reject an unsupported direction. @@ -55,8 +55,8 @@ Manual-instruction and multi-step onboarding providers were removed; one that re ## Rules that aren't optional (shared by every step) - **Do not default required credentials or required upstream fields.** Explicit product defaults and optional-field fallbacks are acceptable only when their semantics are deliberate and tested; never swallow an upstream failure. -- **HTTP in the provider; DB in the route handler.** Providers read creds from the passed `env` keyed by `mode` and never touch the database. -- **Secrets are environment variables**, mode-keyed where the upstream separates sandbox and production; a missing one throws `providerNotConfigured` → HTTP 503. Never commit credentials. +- **HTTP in the provider; DB in the route handler.** Providers get their keys in `ctx.credentials`, never the process env, and never touch the database. +- **SDP's managed keys are sandbox env vars**, resolved once in `apps/sdp-api/src/services/ramps/runtime.ts`; production projects bring their own account. Missing keys, or keys from the other mode, answer `PROVIDER_NOT_CONFIGURED` → HTTP 503. Never commit credentials. - **Webhooks are fully typed** — parse the raw body as `unknown` only at the signature boundary, then narrow. - **One status mapping per provider, in the package** — the provider's transaction object becomes a `RampSettlementEvent` in `providers//*-status-mapping.ts`; the webhook parser and `fetchTransferStatus` both call it, so a pulled observation and a delivered one cannot diverge. - **Strong typing** — no `any`, no `enum`, finite sets are `as const satisfies Record<…>`. diff --git a/.agents/skills/integrate-webhook/SKILL.md b/.agents/skills/integrate-webhook/SKILL.md index 134fc1a214..2056d88388 100644 --- a/.agents/skills/integrate-webhook/SKILL.md +++ b/.agents/skills/integrate-webhook/SKILL.md @@ -30,8 +30,9 @@ The same typed processor handles the background apply and replay. ## verify -`verify(context: RampWebhookValidationContext): Promise`. `context` = -`{ env, environment, headers, rawBody, requestUrl? }`. Read the mode-keyed +`verify(context: RampWebhookValidationContext): Promise` (defined beside +`WebhookProcessor` in `apps/sdp-api/src/services/ramps/webhooks/processor.ts`). +`context` = `{ env, environment, headers, rawBody, requestUrl? }`. Read the mode-keyed verification key, verify the signature over the **raw** body, and return that body. Throw `UNAUTHORIZED` on a missing or invalid signature. Use `apps/sdp-api/src/lib/webhook-signature.ts` with the provider-signed timestamp. diff --git a/.agents/skills/rail-discovery/SKILL.md b/.agents/skills/rail-discovery/SKILL.md index 06bc72e758..ee35312575 100644 --- a/.agents/skills/rail-discovery/SKILL.md +++ b/.agents/skills/rail-discovery/SKILL.md @@ -39,15 +39,18 @@ For discovery-backed support, add an entry to `RAMP_RAIL_DUMPS` in `packages/sdp ## Step 2 — `discoverCurrencyAndRails` -One contract method does both halves. Unless `context.offline`, read **sandbox** creds from `context.env` with `requireEnv`, fetch each upstream endpoint with the injected `context.fetchJson`, and persist the raw answer with `context.writeDump`. Then always read the dumps back with `context.readDump` and distill them, so an offline run re-distills the dumps already on disk. For static support, return the snapshot and read no credentials. +One contract method does both halves. Unless `context.offline`, take SDP's **sandbox** keys from `context.credentials` (the discovery script resolves them; refuse when it says why there are none), fetch each upstream endpoint with the injected `context.fetchJson`, and persist the raw answer with `context.writeDump`. Then always read the dumps back with `context.readDump` and distill them, so an offline run re-distills the dumps already on disk. For static support, return the snapshot and read no credentials. ```ts async discoverCurrencyAndRails(context: RampDiscoveryContext): Promise { if (!context.offline) { - const apiKey = requireEnv(context.env, "_SANDBOX_API_KEY"); + if (!context.credentials.ok) { + throw providerNotConfigured(context.credentials.reason); + } + const { publicKey } = context.credentials.credentials; await context.writeDump( RAMP_RAIL_DUMPS..currencies.name, - await context.fetchJson(this.id, "GET /currencies", `https://.../currencies?apiKey=${apiKey}`) + await context.fetchJson(this.id, "GET /currencies", `https://.../currencies?apiKey=${publicKey}`) ); } return distillRailSupport(await context.readDump(RAMP_RAIL_DUMPS..currencies.file)); @@ -138,7 +141,7 @@ Commit `.ramp-support/.currency.json` and the regenerated `ramp.generated.ts - HTTP only in `discoverCurrencyAndRails` — no DB, no business logic. - `distillRailSupport` is pure over the dumps — no fetching. A malformed dump should throw, not silently yield empty support; unsupported currency/country codes get reported in `droppedCurrencyCodes`/`droppedCountryCodes`, not swallowed. -- No fallbacks: a missing cred throws via `requireEnv`. +- No fallbacks: missing keys throw `providerNotConfigured` with the resolver's reason. - Strong typing: the declared-support const is `as const satisfies ProviderDeclaredRailSupport`; no `any`. - Your provider must already be registered (`register-provider`) — the codegen iterates `RAMP_PROVIDERS` and will fail if a client is missing. - Verify with `pnpm --filter @sdp/api ramp-support:drift`, plus `@sdp/payments` typecheck, lint, and tests. diff --git a/.agents/skills/register-provider/SKILL.md b/.agents/skills/register-provider/SKILL.md index 91c50c8617..2fd8ddd232 100644 --- a/.agents/skills/register-provider/SKILL.md +++ b/.agents/skills/register-provider/SKILL.md @@ -38,11 +38,11 @@ export class RampClient implements RampProvider { } ``` -`RampProvider` currently requires both estimates, rail discovery/distillation, counterparty validation, and an off-ramp method. An unsupported direction must use empty declared/discovered support plus a typed unsupported requirement or payments error; it must not pretend to work. `createOnrampQuote` is optional. `fetchTransferStatus` is optional on the contract but required in practice: the status sweep's `RAMP_STATUS_PULLS` table fails typecheck without it. It makes one request per lookup and returns the events the provider's webhook would carry, oldest first, through the package status mapping the webhook parser also calls; an empty answer must mean the provider holds no transaction, never that SDP could not ask. +`RampProvider`'s members are function-typed, so an implementation is checked strictly. Every provider has an `id`, a `label` (its name in SDP's messages), declared rail support, `checkCredentials`, rail discovery/distillation, counterparty validation and `fetchTransferStatus`. A direction the provider runs has both of its members (`estimateOnramp` + `createOnrampQuote`, `estimateOfframp` + `createOfframpQuote`); a direction it doesn't run has neither, plus empty declared/discovered support and a typed unsupported requirement. `supportsRampDirection` and `rampClientFor` read member presence, and the contract suite fails a provider with half a direction. `cancelOfframpSale` is optional. `fetchTransferStatus` makes one request per lookup and returns the events the provider's webhook would carry, oldest first, through the package status mapping the webhook parser also calls; an empty answer must mean the provider holds no transaction, never that SDP could not ask. -Use one mode-aware config reader over the passed `env`. Provider code performs HTTP only and imports neither `AppContext` nor database modules. Use `providerFetchJson` for provider requests, with a zod schema of exactly the response fields the adapter reads (a mismatch fails closed as `PROVIDER_RESPONSE_INVALID`, 502), and payments-package errors such as `providerNotConfigured`, `providerUnavailable`, and `estimateNotAvailable`. An error's message and `details` are returned to callers, so they carry only SDP's own wording; the provider's message, codes and field errors go in its `diagnostics` (ADR 0008), which the API logs and never returns. Add `providers//test-kit.ts` (recorded exchanges per direction, `transferStatus` lookups including the provider's answer for a transfer it holds nothing for, the mode-credential check, one malformed reply) and register it in `KITS` in `packages/sdp-payments/src/ramps/contract.test.ts`; typecheck fails until you do, and the suite pins one request per status lookup. +Every call gets a `RampRuntimeContext` of `{ mode, cluster, credentials }` (the account's `publicKey` and `secretKey`); the API resolves it, so provider code never sees the process env. Read the keys with `rampCredentials(this, ctx)` from `#/ramps/shared`: it runs `checkCredentials` first, so a key from the other mode never makes a request. When the provider's keys name their mode, `checkCredentials` is `checkRampKeyFormats(this.label, _KEY_FORMATS, credentials, mode)` over a `RampKeyFormats` table. Hosts follow `ctx.mode` inside the adapter. Provider code performs HTTP only and imports neither `AppContext` nor database modules. Use `providerFetchJson` for provider requests, with a zod schema of exactly the response fields the adapter reads (a mismatch fails closed as `PROVIDER_RESPONSE_INVALID`, 502), and payments-package errors such as `providerNotConfigured`, `providerUnavailable`, and `estimateNotAvailable`. An error's message and `details` are returned to callers, so they carry only SDP's own wording; the provider's message, codes and field errors go in its `diagnostics` (ADR 0008), which the API logs and never returns. Add `providers//test-kit.ts` (test-mode `credentials` and `liveCredentials`, recorded exchanges per direction, `transferStatus` lookups including the provider's answer for a transfer it holds nothing for, the credential checks on requests and quotes, one malformed reply) and register it in `KITS` in `packages/sdp-payments/src/ramps/contract.test.ts`; typecheck fails until you do, and the suite pins one request per status lookup and refuses each mode's calls on the other mode's keys. -If API-side handlers import provider-specific public helpers or types through package subpaths, add explicit exports in `packages/sdp-payments/package.json`. +Register the client in `RAMP_PROVIDER_CLIENTS` and its status-pull event type in `RampProviderSettlementEvents` (`packages/sdp-payments/src/ramps/index.ts`). Callers reach it only through the contract. Outside the package, only the provider's webhook processor imports its modules, and only its status mapping (export it as `./ramps/providers//-status-mapping`); `packages/sdp-payments/src/ramps/boundaries.test.ts` enforces it. ## 3. Wire API admission and orchestration @@ -50,24 +50,15 @@ Update every applicable site: | Site | Decision | |---|---| -| `apps/sdp-api/src/services/provider-availability.service.ts` | label plus prod/sandbox credential completeness; `testMode === undefined` means either configured mode | +| `apps/sdp-api/src/services/ramps/runtime.ts` | add the env vars of SDP's managed sandbox keys to `MANAGED_RAMP_KEYS`, in the order a missing-key refusal names them; every runtime, availability check and discovery run resolves keys here | +| `apps/sdp-api/src/services/provider-availability.service.ts` | `managedRampDefinition("", [...])`, listing any key it needs beyond its account keys (Stripe: its webhook secret) | | `apps/sdp-api/src/services/provider-setup-registry.ts` | add `rampSetup("")`; ramps are deployment-managed today | | `apps/sdp-api/src/routes/counterparties/schemas.ts` | add the provider-specific submit-requirements schema arm; add the id only to the directions actually supported | -| `apps/sdp-api/src/routes/payments/ramps/{onramp,offramp}/handlers.ts` + `ramps/shared.ts` | add quote branches per direction and `advanceCounterpartyRequirements` branches in `shared.ts` for every provider; unsupported directions reject explicitly; keep DB work here or in `ramps/providers/.ts` | +| `apps/sdp-api/src/routes/payments/ramps/{onramp,offramp}/handlers.ts` + `ramps/shared.ts` | add the quote case per direction, calling `rampClientFor("", direction)` with `rampRuntime(c, "")`, and `advanceCounterpartyRequirements` branches in `shared.ts`; unsupported directions reject explicitly; keep DB work here or in `ramps/providers/.ts` | | `apps/sdp-api/src/services/ramps/webhooks/registry.ts` | register the provider's `WebhookProcessor`; the registry covers every provider id, so typecheck fails until you do | | `apps/sdp-api/src/services/jobs/sweep-ramp-transfer-statuses.ts` | add the provider to `RAMP_STATUS_PULLS` (its client paired with its processor) and `RAMP_ABANDONMENT_HORIZON_MS` (how long a quiet checkout stays open per direction: a product call, recorded with its reason) | -Availability is tri-state by environment: - -```ts -isConfigured: (env, testMode) => { - const prod = hasAllEnv(env, ["_KEY", "_SECRET"]); - const sandbox = hasAllEnv(env, ["_SANDBOX_KEY", "_SANDBOX_SECRET"]); - if (testMode === true) return sandbox; - if (testMode === false) return prod; - return prod || sandbox; -}, -``` +SDP holds sandbox keys only: production is never managed, so a production project answers `PROVIDER_NOT_CONFIGURED` until it brings its own account (HOO-1942, HOO-1943). Do not add obsolete `executeOnramp` / `executeOfframp` branches; those methods are not part of the current provider contract. @@ -99,7 +90,7 @@ Add every runtime key to all environment-contract projections that consume API k 3. `scripts/secret-keys.mjs` `API_LOCAL_ENV_KEYS` 4. `apps/sdp-api/.env.local.example` with commented placeholders and no real credentials -If the provider has distinct webhook keys, API base URL overrides, account ids, or sandbox-only settings, include those exact keys too. A missing required key must produce `PROVIDER_NOT_CONFIGURED` (503). +If the provider has distinct webhook keys, account ids, or sandbox-only settings, include those exact keys too. Hosts are not env keys: they follow `ctx.mode` inside the adapter. A missing required key must produce `PROVIDER_NOT_CONFIGURED` (503). ## 6. Surface the provider in the dashboard diff --git a/apps/sdp-api/scripts/ramp-support.ts b/apps/sdp-api/scripts/ramp-support.ts index 2af4aaa613..c930b092c3 100644 --- a/apps/sdp-api/scripts/ramp-support.ts +++ b/apps/sdp-api/scripts/ramp-support.ts @@ -22,6 +22,7 @@ import { } from "@sdp/types/payment-rails"; import { RAMP_PROVIDERS, type RampProviderId } from "@sdp/types/provider-access"; import { z } from "zod"; +import { resolveManagedRampCredentials } from "@/services/ramps/runtime"; import { describeRailSupportShrink, discoverWithShrinkGuard, @@ -746,7 +747,7 @@ async function discoverProviderRails( offline: boolean ): Promise { const distillation = await RAMP_PROVIDER_CLIENTS[provider].discoverCurrencyAndRails({ - env: process.env, + credentials: resolveManagedRampCredentials(process.env, provider, "sandbox"), fetchJson, fetchText, writeDump: writeCurrencySupportDump, diff --git a/apps/sdp-api/src/lib/places/google.ts b/apps/sdp-api/src/lib/places/google.ts index c6d45646ea..9ecb855517 100644 --- a/apps/sdp-api/src/lib/places/google.ts +++ b/apps/sdp-api/src/lib/places/google.ts @@ -1,8 +1,8 @@ -import { requireEnv } from "@sdp/payments/ramps/shared"; import type { PlaceSuggestion, ResolvedPlace } from "@sdp/types/places"; import { z } from "zod"; import { providerUnavailable } from "@/lib/errors"; import { fetchProviderJson, type ProviderJsonRequest } from "@/lib/provider-json"; +import { requireEnv } from "@/lib/runtime-env"; const PLACES_API_BASE_URL = "https://places.googleapis.com/v1"; const STATIC_MAPS_API_BASE_URL = "https://maps.googleapis.com/maps/api/staticmap"; diff --git a/apps/sdp-api/src/lib/ramps/providers/stripe/client.test.ts b/apps/sdp-api/src/lib/ramps/providers/stripe/client.test.ts deleted file mode 100644 index 7a4db95e16..0000000000 --- a/apps/sdp-api/src/lib/ramps/providers/stripe/client.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { StripeRampClient } from "@sdp/payments/ramps/providers/stripe/client"; -import type { RampOnrampQuoteInput } from "@sdp/payments/ramps/types"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -const STRIPE_CONTEXT = { - env: { - STRIPE_SANDBOX_SECRET_KEY: "sk_test_secret", - STRIPE_SANDBOX_PUBLISHABLE_KEY: "pk_test_pub", - }, - mode: "sandbox", -} as const; - -function jsonResponse(body: Record): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); -} - -/** A session that echoes the default `onrampInput` locked to `currency`, as Stripe returns it. */ -function sessionResponse(currency = "usdc"): Response { - return jsonResponse({ - id: "cos_123", - client_secret: "cos_123_secret_abc", - status: "initialized", - redirect_url: "https://crypto.link.com?session_hash=xyz", - transaction_details: { - destination_currencies: [currency], - destination_networks: ["solana"], - lock_wallet_address: true, - source_amount: "100.00", - source_currency: "usd", - wallet_addresses: { solana: "WALLET123" }, - }, - }); -} - -function quotesResponse(overrides?: Record): Response { - const body = { - id: "quote_set_1", - object: "crypto.onramp.quotes", - source_amount: "96.50", - source_currency: "usd", - rate_fetched_at: 1719947634, - destination_network_quotes: { - solana: [ - { - id: "q_usdc", - destination_network: "solana", - destination_currency: "usdc", - destination_amount: "96.500000", - fees: { network_fee_monetary: "0.01", transaction_fee_monetary: "3.49" }, - source_total_amount: "100.00", - }, - ], - }, - }; - return jsonResponse(overrides ? { ...body, ...overrides } : body); -} - -function onrampInput(overrides?: Partial): RampOnrampQuoteInput { - const input: RampOnrampQuoteInput = { - assetRail: "usdc.solana", - fiatCurrency: "USD", - fiatAmount: "100", - destinationWalletAddress: "WALLET123", - externalCustomerId: "cp_1", - customerIpAddress: "8.8.8.8", - stripeCustomerInfo: { - email: "john@doe.com", - firstName: "John", - lastName: "Doe", - dob: { year: 1990, month: 7, day: 4 }, - address: { - line1: "1 Market St", - city: "SF", - state: "CA", - postalCode: "94080", - country: "US", - }, - }, - }; - return overrides ? { ...input, ...overrides } : input; -} - -function requireFetchInit(fetchSpy: ReturnType): RequestInit { - const firstCall = fetchSpy.mock.calls[0]; - if (!firstCall) { - throw new Error("Expected fetch to be called"); - } - const init = firstCall[1]; - if (!init) { - throw new Error("Expected fetch init"); - } - return init; -} - -function readStripeBody(init: RequestInit): URLSearchParams { - if (init.body instanceof URLSearchParams) { - return init.body; - } - if (typeof init.body === "string") { - return new URLSearchParams(init.body); - } - throw new Error("Expected Stripe form body"); -} - -describe("StripeRampClient", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("creates an onramp session with a locked wallet and identity prefill", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(sessionResponse()); - - const quote = await new StripeRampClient().createOnrampQuote(STRIPE_CONTEXT, onrampInput()); - - expect(fetchSpy).toHaveBeenCalledTimes(1); - const [url] = fetchSpy.mock.calls[0]; - expect(url).toBe("https://api.stripe.com/v1/crypto/onramp_sessions"); - - const init = requireFetchInit(fetchSpy); - expect(new Headers(init.headers).get("Content-Type")).toBe("application/x-www-form-urlencoded"); - - const body = readStripeBody(init); - expect(body.get("wallet_addresses[solana]")).toBe("WALLET123"); - expect(body.get("lock_wallet_address")).toBe("true"); - expect(body.get("source_currency")).toBe("usd"); - expect(body.get("source_amount")).toBe("100"); - expect(body.get("destination_currency")).toBe("usdc"); - expect(body.get("destination_network")).toBe("solana"); - expect(body.getAll("destination_currencies[]")).toEqual(["usdc"]); - expect(body.getAll("destination_networks[]")).toEqual(["solana"]); - expect(body.get("customer_ip_address")).toBe("8.8.8.8"); - expect(body.get("customer_information[email]")).toBe("john@doe.com"); - expect(body.get("customer_information[dob][year]")).toBe("1990"); - expect(body.get("customer_information[address][state]")).toBe("CA"); - - expect(quote).toMatchObject({ - provider: "stripe", - deliveryMode: "session_widget", - id: "cos_123", - sessionId: "cos_123", - clientSecret: "cos_123_secret_abc", - publishableKey: "pk_test_pub", - redirectUrl: "https://crypto.link.com?session_hash=xyz", - }); - }); - - it("maps sol.solana to Stripe's sol destination and locks it", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(sessionResponse("sol")); - - await new StripeRampClient().createOnrampQuote( - STRIPE_CONTEXT, - onrampInput({ assetRail: "sol.solana", stripeCustomerInfo: undefined }) - ); - - const body = readStripeBody(requireFetchInit(fetchSpy)); - expect(body.get("destination_currency")).toBe("sol"); - expect(body.getAll("destination_currencies[]")).toEqual(["sol"]); - }); - - it("requires a fiat currency", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch"); - - await expect( - new StripeRampClient().createOnrampQuote( - STRIPE_CONTEXT, - onrampInput({ fiatCurrency: undefined }) - ) - ).rejects.toThrow(/requires a fiat currency/); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it("rejects unsupported destination assets", async () => { - await expect( - new StripeRampClient().createOnrampQuote( - STRIPE_CONTEXT, - onrampInput({ assetRail: "usdt.solana" }) - ) - ).rejects.toThrow(/usdc\.solana and sol\.solana/); - }); - - it("throws when the secret key is not configured", async () => { - await expect( - new StripeRampClient().createOnrampQuote({ env: {}, mode: "sandbox" }, onrampInput()) - ).rejects.toThrow(/Stripe sandbox is not configured/); - }); - - it("returns an onramp estimate from Stripe quotes", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(quotesResponse()); - - const estimate = await new StripeRampClient().estimateOnramp(STRIPE_CONTEXT, { - assetRail: "usdc.solana", - fiatCurrency: "USD", - fiatAmount: "100", - }); - - expect(fetchSpy).toHaveBeenCalledTimes(1); - const [url, init] = fetchSpy.mock.calls[0]; - expect(String(url)).toContain("/v1/crypto/onramp/quotes"); - expect(String(url)).toContain("source_amount=100"); - expect(String(url)).toContain("source_currency=usd"); - expect(init?.method).toBe("GET"); - expect(estimate).toMatchObject({ - provider: "stripe", - direction: "onramp", - fiatCurrency: "USD", - assetRail: "usdc.solana", - fiatAmount: "100", - cryptoAmount: "96.500000", - fees: { currency: "USD", total: "3.5", network: "0.01", provider: "3.49" }, - }); - }); - - it("throws ESTIMATE_NOT_AVAILABLE when Stripe returns no matching quote", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - quotesResponse({ destination_network_quotes: { solana: [] } }) - ); - - await expect( - new StripeRampClient().estimateOnramp(STRIPE_CONTEXT, { - assetRail: "usdc.solana", - fiatCurrency: "USD", - fiatAmount: "100", - }) - ).rejects.toThrow(/did not return an on-ramp quote/); - }); - - it("marks onramp counterparties ready and offramp unsupported", () => { - const client = new StripeRampClient(); - const counterparty = {} as Parameters[0]; - - expect( - client.validateCounterparty(counterparty, { direction: "onramp", providerData: {} }) - ).toEqual({ - provider: "stripe", - direction: "onramp", - status: "ready", - }); - expect( - client.validateCounterparty(counterparty, { direction: "offramp", providerData: {} }) - ).toMatchObject({ provider: "stripe", direction: "offramp", status: "unsupported" }); - }); - - it("sums network and transaction fees without float drift", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - quotesResponse({ - destination_network_quotes: { - solana: [ - { - id: "q_usdc", - destination_network: "solana", - destination_currency: "usdc", - destination_amount: "96.500000", - fees: { network_fee_monetary: "0.1", transaction_fee_monetary: "3.49" }, - source_total_amount: "100.00", - }, - ], - }, - }) - ); - - const estimate = await new StripeRampClient().estimateOnramp(STRIPE_CONTEXT, { - assetRail: "usdc.solana", - fiatCurrency: "USD", - fiatAmount: "100", - }); - - expect(estimate.fees).toMatchObject({ network: "0.1", provider: "3.49", total: "3.59" }); - }); -}); diff --git a/apps/sdp-api/src/lib/runtime-env.ts b/apps/sdp-api/src/lib/runtime-env.ts index e649aa72ab..074c51f8f1 100644 --- a/apps/sdp-api/src/lib/runtime-env.ts +++ b/apps/sdp-api/src/lib/runtime-env.ts @@ -34,6 +34,14 @@ export function isSelfHostedDeployment(env: Pick): b return resolveDeploymentMode(env.SDP_DEPLOYMENT_MODE) === "self_hosted"; } +export function requireEnv(env: Record, key: string): string { + const value = env[key]?.trim(); + if (!value) { + throw new Error(`Missing required env: ${key}.`); + } + return value; +} + /** * Return the complete Node process environment as SDP bindings. * diff --git a/apps/sdp-api/src/routes/payments/context.ts b/apps/sdp-api/src/routes/payments/context.ts index 58c5387a8e..fcdc655aed 100644 --- a/apps/sdp-api/src/routes/payments/context.ts +++ b/apps/sdp-api/src/routes/payments/context.ts @@ -1,4 +1,5 @@ import type { RampRuntimeContext } from "@sdp/payments/ramps/types"; +import type { RampProviderId } from "@sdp/types/provider-access"; import type { Context } from "hono"; import { createCounterpartiesRepository, @@ -10,15 +11,14 @@ import { } from "@/db/repositories/repository-factory"; import { resolveSdpEnvironment } from "@/lib/sdp-environment"; import { getRequestTenantScope } from "@/lib/tenant-scope"; +import { rampRuntimeFor } from "@/services/ramps/runtime"; import type { Env } from "@/types/env"; export type AppContext = Context<{ Bindings: Env }>; -export function rampRuntime(c: AppContext): RampRuntimeContext { - return { - env: c.env as unknown as Record, - mode: resolveSdpEnvironment(c), - }; +/** The runtime a call to `provider` runs under for this request's project mode. */ +export function rampRuntime(c: AppContext, provider: RampProviderId): RampRuntimeContext { + return rampRuntimeFor(c.env, provider, resolveSdpEnvironment(c)); } export function getPaymentsRepository(c: AppContext) { diff --git a/apps/sdp-api/src/routes/payments/ramps/estimate-fanout.test.ts b/apps/sdp-api/src/routes/payments/ramps/estimate-fanout.test.ts index 209c36fdfe..3bf7ecb2fc 100644 --- a/apps/sdp-api/src/routes/payments/ramps/estimate-fanout.test.ts +++ b/apps/sdp-api/src/routes/payments/ramps/estimate-fanout.test.ts @@ -26,6 +26,7 @@ vi.mock("@/routes/payments/wallets", async (importOriginal) => ({ })); import { estimateNotAvailable, SdpPaymentsError } from "@sdp/payments/errors"; +import { RAMP_PROVIDER_CLIENTS } from "@sdp/payments/ramps"; import { CUSTODY_PROVIDERS, STAGED_PROVIDER_REFUSAL_REASONS } from "@sdp/types/custody"; import type { PaymentRampEstimate } from "@sdp/types/payments"; import { @@ -94,9 +95,14 @@ describe("estimateAcrossProviders", () => { }); it("keeps the per-provider error contract but emits a structured log event", async () => { - const results = await estimateAcrossProviders(buildContext(), ["moonpay"], async () => { - throw new Error("provider exploded"); - }); + const results = await estimateAcrossProviders( + buildContext(), + "onramp", + ["moonpay"], + async () => { + throw new Error("provider exploded"); + } + ); expect(results).toEqual([{ provider: "moonpay", status: "error", error: "provider exploded" }]); expect(logEvent).toHaveBeenCalledTimes(1); @@ -113,14 +119,19 @@ describe("estimateAcrossProviders", () => { }); it("logs a provider's wording from diagnostics and keeps it out of the result", async () => { - const results = await estimateAcrossProviders(buildContext(), ["moonpay"], async () => { - throw new SdpPaymentsError( - "BAD_REQUEST", - "moonpay request failed with status 400", - { provider: "moonpay", providerStatus: 400 }, - { providerMessage: "amount too low for jane.doe@example.com" } - ); - }); + const results = await estimateAcrossProviders( + buildContext(), + "onramp", + ["moonpay"], + async () => { + throw new SdpPaymentsError( + "BAD_REQUEST", + "moonpay request failed with status 400", + { provider: "moonpay", providerStatus: 400 }, + { providerMessage: "amount too low for jane.doe@example.com" } + ); + } + ); expect(results).toEqual([ { provider: "moonpay", status: "error", error: "moonpay request failed with status 400" }, @@ -142,7 +153,12 @@ describe("estimateAcrossProviders", () => { }); const runProvider = vi.fn(); - const results = await estimateAcrossProviders(buildContext(), ["moonpay"], runProvider); + const results = await estimateAcrossProviders( + buildContext(), + "onramp", + ["moonpay"], + runProvider + ); expect(results).toEqual([ { @@ -177,7 +193,12 @@ describe("estimateAcrossProviders", () => { }); const runProvider = vi.fn(); - const results = await estimateAcrossProviders(buildContext(), ["moonpay"], runProvider); + const results = await estimateAcrossProviders( + buildContext(), + "onramp", + ["moonpay"], + runProvider + ); expect(results).toEqual([ { @@ -213,6 +234,7 @@ describe("estimateAcrossProviders", () => { const results = await estimateAcrossProviders( buildContext(), + "onramp", ["moonpay", "stripe"], runProvider ); @@ -227,7 +249,7 @@ describe("estimateAcrossProviders", () => { { provider: "stripe", status: "unsupported" }, ]); expect(loadProjectProviderVerdict).toHaveBeenCalledTimes(1); - expect(runProvider).toHaveBeenCalledExactlyOnceWith("stripe", {}); + expect(runProvider).toHaveBeenCalledExactlyOnceWith(RAMP_PROVIDER_CLIENTS.stripe, {}); expect(logEvent).toHaveBeenCalledExactlyOnceWith("info", { event: "sdp_api_ramp_provider_refused", provider: "moonpay", @@ -236,10 +258,25 @@ describe("estimateAcrossProviders", () => { }); }); + it("answers a provider that doesn't run the direction as unsupported, without calling it", async () => { + const runProvider = vi.fn(); + + const results = await estimateAcrossProviders( + buildContext(), + "offramp", + ["stripe"], + runProvider + ); + + expect(results).toEqual([{ provider: "stripe", status: "unsupported" }]); + expect(runProvider).not.toHaveBeenCalled(); + }); + it("does not log when a provider succeeds or is merely unsupported", async () => { const estimate = { provider: "moonpay" } as unknown as PaymentRampEstimate; const results = await estimateAcrossProviders( buildContext(), + "onramp", ["moonpay"], async () => estimate ); diff --git a/apps/sdp-api/src/routes/payments/ramps/handlers.ts b/apps/sdp-api/src/routes/payments/ramps/handlers.ts index 7f3f77e804..d15bfb52d7 100644 --- a/apps/sdp-api/src/routes/payments/ramps/handlers.ts +++ b/apps/sdp-api/src/routes/payments/ramps/handlers.ts @@ -44,7 +44,7 @@ async function cancelProviderSale(c: AppContext, transfer: PaymentTransferRow): }; try { const outcome = await client.cancelOfframpSale( - rampRuntime(c), + rampRuntime(c, transfer.provider), transfer.id, AbortSignal.timeout(PROVIDER_CANCEL_TIMEOUT_MS) ); diff --git a/apps/sdp-api/src/routes/payments/ramps/offramp/handlers.ts b/apps/sdp-api/src/routes/payments/ramps/offramp/handlers.ts index 70b521a1a7..a029ca631f 100644 --- a/apps/sdp-api/src/routes/payments/ramps/offramp/handlers.ts +++ b/apps/sdp-api/src/routes/payments/ramps/offramp/handlers.ts @@ -1,5 +1,5 @@ import { rampAmountLimitOf } from "@sdp/payments/errors"; -import { RAMP_PROVIDER_CLIENTS } from "@sdp/payments/ramps"; +import { rampClientFor } from "@sdp/payments/ramps"; import { OFFRAMP_SUPPORT, RAMP_SUPPORT_HASH } from "@sdp/types/generated/ramp"; import type { PaymentRampQuote } from "@sdp/types/payments"; import type { RampProviderId } from "@sdp/types/provider-access"; @@ -65,8 +65,8 @@ export async function estimateOfframp(c: ValidatedBodyContext - RAMP_PROVIDER_CLIENTS[provider].estimateOfframp(ctx, { + const estimates = await estimateAcrossProviders(c, "offramp", providers, (client, ctx) => + client.estimateOfframp(ctx, { assetRail: input.assetRail, fiatCurrency: input.fiatCurrency, cryptoAmount: input.cryptoAmount, @@ -95,13 +95,15 @@ export async function createOfframpQuote(c: AppContext): Promise { let precreatedTransferId: string | undefined; switch (input.provider) { case "moonpay": { + const moonpay = rampClientFor("moonpay", "offramp"); + const runtime = rampRuntime(c, "moonpay"); const fiatCurrency = input.fiatCurrency ?? "USD"; const token = rampTransferTokenMint(input.assetRail); // One provider estimate and one balance read, side by side, before any // row: the estimate is the payout SDP quotes and refuses an amount // MoonPay would not take; the read refuses a sale the wallet cannot fund. const [estimated, funded] = await Promise.allSettled([ - RAMP_PROVIDER_CLIENTS.moonpay.estimateOfframp(rampRuntime(c), { + moonpay.estimateOfframp(runtime, { assetRail: input.assetRail, fiatCurrency, cryptoAmount: input.cryptoAmount, @@ -146,7 +148,7 @@ export async function createOfframpQuote(c: AppContext): Promise { } precreatedTransferId = pendingMoonpayTransfer.id; try { - quote = await RAMP_PROVIDER_CLIENTS.moonpay.createOfframpQuote(rampRuntime(c), { + quote = await moonpay.createOfframpQuote(runtime, { assetRail: input.assetRail, fiatCurrency, cryptoAmount: input.cryptoAmount, diff --git a/apps/sdp-api/src/routes/payments/ramps/onramp/handlers.ts b/apps/sdp-api/src/routes/payments/ramps/onramp/handlers.ts index bd4e27a819..58af5b24a8 100644 --- a/apps/sdp-api/src/routes/payments/ramps/onramp/handlers.ts +++ b/apps/sdp-api/src/routes/payments/ramps/onramp/handlers.ts @@ -1,4 +1,4 @@ -import { RAMP_PROVIDER_CLIENTS } from "@sdp/payments/ramps"; +import { rampClientFor } from "@sdp/payments/ramps"; import { ONRAMP_SUPPORT, RAMP_SUPPORT_HASH } from "@sdp/types/generated/ramp"; import type { PaymentRampQuote } from "@sdp/types/payments"; import type { RampProviderId } from "@sdp/types/provider-access"; @@ -63,8 +63,8 @@ export async function estimateOnramp(c: ValidatedBodyContext - RAMP_PROVIDER_CLIENTS[provider].estimateOnramp(ctx, { + const estimates = await estimateAcrossProviders(c, "onramp", providers, (client, ctx) => + client.estimateOnramp(ctx, { assetRail: input.assetRail, fiatCurrency: input.fiatCurrency, fiatAmount: input.fiatAmount, @@ -126,14 +126,17 @@ export async function createOnrampQuote(c: AppContext): Promise { } precreatedTransferId = pendingTransfer.id; try { - quote = await RAMP_PROVIDER_CLIENTS.moonpay.createOnrampQuote(rampRuntime(c), { - assetRail: input.assetRail, - fiatCurrency: input.fiatCurrency, - fiatAmount: input.fiatAmount, - destinationWalletAddress, - externalCustomerId: counterparty.id, - paymentTransferId: pendingTransfer.id, - }); + quote = await rampClientFor("moonpay", "onramp").createOnrampQuote( + rampRuntime(c, "moonpay"), + { + assetRail: input.assetRail, + fiatCurrency: input.fiatCurrency, + fiatAmount: input.fiatAmount, + destinationWalletAddress, + externalCustomerId: counterparty.id, + paymentTransferId: pendingTransfer.id, + } + ); const updated = await auditedRampWrite(c, "create", (repository) => repository.transitionRampTransfer({ transferId: pendingTransfer.id, @@ -161,7 +164,7 @@ export async function createOnrampQuote(c: AppContext): Promise { break; } case "stripe": { - quote = await RAMP_PROVIDER_CLIENTS.stripe.createOnrampQuote(rampRuntime(c), { + quote = await rampClientFor("stripe", "onramp").createOnrampQuote(rampRuntime(c, "stripe"), { assetRail: input.assetRail, fiatCurrency: input.fiatCurrency, fiatAmount: input.fiatAmount, diff --git a/apps/sdp-api/src/routes/payments/ramps/shared.ts b/apps/sdp-api/src/routes/payments/ramps/shared.ts index a7c68088c9..8f52b18849 100644 --- a/apps/sdp-api/src/routes/payments/ramps/shared.ts +++ b/apps/sdp-api/src/routes/payments/ramps/shared.ts @@ -1,6 +1,12 @@ import type { CounterpartyRow } from "@sdp/payments/counterparty"; import { rampAmountLimitOf, SdpPaymentsError } from "@sdp/payments/errors"; -import type { RampRuntimeContext } from "@sdp/payments/ramps/types"; +import { RAMP_PROVIDER_CLIENTS } from "@sdp/payments/ramps"; +import { + type RampDirectionMembers, + type RampProvider, + type RampRuntimeContext, + supportsRampDirection, +} from "@sdp/payments/ramps/types"; import { redactCredentialString } from "@sdp/redaction/credentials"; import { isStagedProviderRefusalReason } from "@sdp/types/custody"; import { @@ -280,13 +286,16 @@ export async function persistRampQuoteTransfer( /** Ceiling on simultaneous live provider estimate calls per request. */ export const RAMP_ESTIMATE_PROVIDER_CONCURRENCY = 3; -export async function estimateAcrossProviders( +export async function estimateAcrossProviders( c: AppContext, + direction: Direction, providers: readonly RampProviderId[], - runProvider: (provider: RampProviderId, ctx: RampRuntimeContext) => Promise + runProvider: ( + client: RampProvider & RampDirectionMembers[Direction], + ctx: RampRuntimeContext + ) => Promise ): Promise { const scope = await resolveScope(c); - const ctx = rampRuntime(c); const verdict = await loadProjectProviderVerdict(c); const settled = await mapSettledWithConcurrency( @@ -312,7 +321,11 @@ export async function estimateAcrossProviders( reason: decision.reason, }; } - const estimate = await runProvider(provider, ctx); + const client = RAMP_PROVIDER_CLIENTS[provider]; + if (!supportsRampDirection(client, direction)) { + return { provider, status: "unsupported" }; + } + const estimate = await runProvider(client, rampRuntime(c, provider)); return { provider, status: "ok", estimate }; } catch (error) { if (error instanceof SdpPaymentsError && error.code === "ESTIMATE_NOT_AVAILABLE") { diff --git a/apps/sdp-api/src/services/jobs/sweep-ramp-transfer-statuses.ts b/apps/sdp-api/src/services/jobs/sweep-ramp-transfer-statuses.ts index 7a66bc9bf3..238d2b1cf8 100644 --- a/apps/sdp-api/src/services/jobs/sweep-ramp-transfer-statuses.ts +++ b/apps/sdp-api/src/services/jobs/sweep-ramp-transfer-statuses.ts @@ -20,6 +20,7 @@ import type { import { createSystemPaymentsRepository } from "@/db/repositories/repository-factory"; import { logEvent, paymentsErrorDiagnostics } from "@/runtime/money-path-events"; import { isProviderConfigured } from "@/services/provider-availability.service"; +import { rampRuntimeFor } from "@/services/ramps/runtime"; import type { WebhookProcessor } from "@/services/ramps/webhooks/processor"; import { RAMP_PROVIDER_WEBHOOK_PROCESSOR } from "@/services/ramps/webhooks/registry"; import type { Env } from "@/types/env"; @@ -127,13 +128,6 @@ export interface RampStatusSweepResult { unmapped: number; } -/** The string values of the process env, which is where provider credentials live. */ -function providerEnv(env: Env): Record { - return Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string") - ); -} - /** * Whether SDP should now call a checkout abandoned: still waiting on the * customer, nothing this pull said counts as activity, needs an operator, or is @@ -181,7 +175,6 @@ async function expireAbandonedCheckout( async function pullTransfer( env: Env, - credentials: Record, repo: PaymentsRepository, provider: RampProviderId, claimed: { transfer: RampPaymentTransferRow; environment: SdpEnvironment }, @@ -191,7 +184,7 @@ async function pullTransfer( let outcomes: RampStatusPullOutcome[]; try { outcomes = await RAMP_STATUS_PULLS[provider]( - { env: credentials, mode: environment }, + rampRuntimeFor(env, provider, environment), env, environment, { @@ -284,7 +277,6 @@ function sweepQueues(now: number): { async function sweepProvider( env: Env, - credentials: Record, repo: PaymentsRepository, provider: RampProviderId, deadline: number @@ -322,7 +314,7 @@ async function sweepProvider( break; } result.polled += 1; - await pullTransfer(env, credentials, repo, provider, claimed, result); + await pullTransfer(env, repo, provider, claimed, result); } } if (result.polled > 0) { @@ -347,11 +339,10 @@ export async function sweepRampTransferStatuses( env: Env, repo: PaymentsRepository = createSystemPaymentsRepository(env) ): Promise { - const credentials = providerEnv(env); const deadline = Date.now() + SWEEP_BUDGET_MS; // Settled, not raced: a failing provider fails the run only after the others finish. const settled = await Promise.allSettled( - RAMP_PROVIDERS.map((provider) => sweepProvider(env, credentials, repo, provider, deadline)) + RAMP_PROVIDERS.map((provider) => sweepProvider(env, repo, provider, deadline)) ); const results: RampStatusSweepResult[] = []; for (const outcome of settled) { diff --git a/apps/sdp-api/src/services/provider-availability.service.ts b/apps/sdp-api/src/services/provider-availability.service.ts index 3354a5f544..83325c3622 100644 --- a/apps/sdp-api/src/services/provider-availability.service.ts +++ b/apps/sdp-api/src/services/provider-availability.service.ts @@ -1,5 +1,4 @@ -import { hasMoonpayManagedKeys } from "@sdp/payments/ramps/providers/moonpay/client"; -import { hasStripeManagedKeys } from "@sdp/payments/ramps/providers/stripe/client"; +import { RAMP_PROVIDER_CLIENTS } from "@sdp/payments/ramps"; import type { SdpEnvironment } from "@sdp/types/api-keys"; import { CUSTODY_MODES, @@ -47,6 +46,7 @@ import { } from "@/lib/feature-flags"; import { isSelfHostedDeployment } from "@/lib/runtime-env"; import { logEvent } from "@/runtime/money-path-events"; +import { resolveManagedRampCredentials } from "@/services/ramps/runtime"; import type { Env } from "@/types/env"; type OrganizationProviderRow = { @@ -96,6 +96,27 @@ function hasAllEnv(env: Env, keys: readonly (keyof Env)[]): boolean { return keys.every((key) => hasEnv(env, key)); } +/** + * A ramp provider on SDP's managed keys: configured exactly when every call to + * it could run, so availability never disagrees with the call. Production is + * never managed. + * + * @param provider - The ramp provider. + * @param alsoRequired - Env keys it needs beyond its account keys. + * @returns Its availability definition. + */ +function managedRampDefinition( + provider: RampProviderId, + alsoRequired: readonly (keyof Env)[] +): ProviderAvailabilityDefinition { + return { + label: RAMP_PROVIDER_CLIENTS[provider].label, + isConfigured: (env, testMode) => + resolveManagedRampCredentials(env, provider, testMode === false ? "production" : "sandbox") + .ok && hasAllEnv(env, alsoRequired), + }; +} + /** * Earn providers SDP reaches with a credential, which is most but not all of * them — see `publicApiDefinition` below. Excluding the keyless ones here is @@ -239,21 +260,8 @@ const PROVIDER_AVAILABILITY_DEFINITIONS = { }, }, ramps: { - moonpay: { - label: "MoonPay", - // The client's own key check, so a live key under the sandbox names - // reads as unconfigured here too. Production is never managed. - isConfigured: (env, testMode) => - hasMoonpayManagedKeys(env, testMode === false ? "production" : "sandbox"), - }, - stripe: { - label: "Stripe", - // The client's own key check, so a live key under the sandbox names - // reads as unconfigured here too. Production is never managed. - isConfigured: (env, testMode) => - hasStripeManagedKeys(env, testMode === false ? "production" : "sandbox") && - hasEnv(env, "STRIPE_SANDBOX_WEBHOOK_SECRET"), - }, + moonpay: managedRampDefinition("moonpay", []), + stripe: managedRampDefinition("stripe", ["STRIPE_SANDBOX_WEBHOOK_SECRET"]), }, earn: { // Keyless like Kamino, though for a cluster reason rather than Kamino's 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 d6d32b108f..7e1cd956ea 100644 --- a/apps/sdp-api/src/services/provider-credential-submission.service.ts +++ b/apps/sdp-api/src/services/provider-credential-submission.service.ts @@ -1,11 +1,11 @@ import { hashString } from "@sdp/payments/hash"; -import { requireEnv } from "@sdp/payments/ramps/shared"; import type { Context } from "hono"; import { type DatabaseClient, getDb } from "@/db/client"; import { isPostgresUniqueViolation, parsePostgresJsonOr } from "@/db/postgres-utils"; import { getAuth, requireProjectId } from "@/lib/auth"; import { AppError, conflict, internalError, notFound, providerUnavailable } from "@/lib/errors"; import { normalizeForFingerprint, resolveIdempotencyReplay } from "@/lib/idempotency"; +import { requireEnv } from "@/lib/runtime-env"; import { getLogger } from "@/runtime/logger"; import { type AuditIntent, AuditService } from "@/services/audit.service"; import * as credentialSecretStore from "@/services/credential-secret-store"; diff --git a/apps/sdp-api/src/services/ramps/runtime.test.ts b/apps/sdp-api/src/services/ramps/runtime.test.ts new file mode 100644 index 0000000000..61c4b90d37 --- /dev/null +++ b/apps/sdp-api/src/services/ramps/runtime.test.ts @@ -0,0 +1,96 @@ +import { SdpPaymentsError } from "@sdp/payments/errors"; +import type { RampProviderCredentials } from "@sdp/payments/ramps/types"; +import { RAMP_PROVIDERS, type RampProviderId } from "@sdp/types/provider-access"; +import { CLUSTER_BY_SDP_ENVIRONMENT } from "@sdp/types/well-known-tokens"; +import { describe, expect, it } from "vitest"; +import { type ManagedRampKeysEnv, rampRuntimeFor, resolveManagedRampCredentials } from "./runtime"; + +interface ManagedRampCase { + /** The managed keys as mounted, by env var. */ + env: ManagedRampKeysEnv; + credentials: RampProviderCredentials; + /** A live key per mounted name, as one mounted by mistake. */ + liveKeys: ManagedRampKeysEnv; + refusals: { production: string; missing: string; liveKey: string }; +} + +const CASES = { + moonpay: { + env: { + MOONPAY_SANDBOX_API_KEY: "pk_test_managed", + MOONPAY_SANDBOX_SECRET_KEY: "sk_test_managed", + }, + credentials: { publicKey: "pk_test_managed", secretKey: "sk_test_managed" }, + liveKeys: { + MOONPAY_SANDBOX_API_KEY: "pk_live_mounted_by_mistake", + MOONPAY_SANDBOX_SECRET_KEY: "sk_live_mounted_by_mistake", + }, + refusals: { + production: "MoonPay is not configured for production projects.", + missing: + "MoonPay sandbox is not configured. Set MOONPAY_SANDBOX_API_KEY and MOONPAY_SANDBOX_SECRET_KEY.", + liveKey: "MoonPay sandbox keys must be test-mode keys.", + }, + }, + stripe: { + env: { + STRIPE_SANDBOX_SECRET_KEY: "sk_test_managed", + STRIPE_SANDBOX_PUBLISHABLE_KEY: "pk_test_managed", + }, + credentials: { publicKey: "pk_test_managed", secretKey: "sk_test_managed" }, + liveKeys: { + STRIPE_SANDBOX_SECRET_KEY: "sk_live_mounted_by_mistake", + STRIPE_SANDBOX_PUBLISHABLE_KEY: "pk_live_mounted_by_mistake", + }, + refusals: { + production: "Stripe is not configured for production projects.", + missing: + "Stripe sandbox is not configured. Set STRIPE_SANDBOX_SECRET_KEY and STRIPE_SANDBOX_PUBLISHABLE_KEY.", + liveKey: "Stripe sandbox keys must be test-mode keys.", + }, + }, +} as const satisfies Record; + +describe.each(RAMP_PROVIDERS)("SDP's managed %s keys", (provider) => { + const managed: ManagedRampCase = CASES[provider]; + + it("serve sandbox, trimmed, on the sandbox cluster", () => { + const padded = Object.fromEntries( + Object.entries(managed.env).map(([name, value]) => [name, ` ${value} `]) + ); + expect(resolveManagedRampCredentials(padded, provider, "sandbox")).toEqual({ + ok: true, + credentials: managed.credentials, + }); + expect(rampRuntimeFor(managed.env, provider, "sandbox")).toEqual({ + mode: "sandbox", + cluster: CLUSTER_BY_SDP_ENVIRONMENT.sandbox, + credentials: managed.credentials, + }); + }); + + it("never serve production, which brings its own account", () => { + expect(resolveManagedRampCredentials(managed.env, provider, "production")).toEqual({ + ok: false, + reason: managed.refusals.production, + }); + }); + + it.each(Object.keys(managed.env))("refuse sandbox without %s, naming every key", (name) => { + expect( + resolveManagedRampCredentials({ ...managed.env, [name]: " " }, provider, "sandbox") + ).toEqual({ ok: false, reason: managed.refusals.missing }); + }); + + it.each(Object.entries(managed.liveKeys))("refuse a live key mounted as %s", (name, liveKey) => { + expect( + resolveManagedRampCredentials({ ...managed.env, [name]: liveKey }, provider, "sandbox") + ).toEqual({ ok: false, reason: managed.refusals.liveKey }); + }); + + it("throw PROVIDER_NOT_CONFIGURED when building a runtime they can't serve", () => { + expect(() => rampRuntimeFor(managed.env, provider, "production")).toThrow( + new SdpPaymentsError("PROVIDER_NOT_CONFIGURED", managed.refusals.production) + ); + }); +}); diff --git a/apps/sdp-api/src/services/ramps/runtime.ts b/apps/sdp-api/src/services/ramps/runtime.ts new file mode 100644 index 0000000000..2e19c95cd3 --- /dev/null +++ b/apps/sdp-api/src/services/ramps/runtime.ts @@ -0,0 +1,83 @@ +import { providerNotConfigured } from "@sdp/payments/errors"; +import { RAMP_PROVIDER_CLIENTS } from "@sdp/payments/ramps"; +import type { + RampCredentialsResolution, + RampProviderCredentials, + RampRuntimeContext, +} from "@sdp/payments/ramps/types"; +import type { SdpEnvironment } from "@sdp/types/api-keys"; +import type { RampProviderId } from "@sdp/types/provider-access"; +import { CLUSTER_BY_SDP_ENVIRONMENT } from "@sdp/types/well-known-tokens"; +import type { Env } from "@/types/env"; + +/** + * The env vars SDP's managed sandbox account keeps each provider's keys + * under, listed in the order a missing-key refusal names them. + */ +const MANAGED_RAMP_KEYS = { + moonpay: { publicKey: "MOONPAY_SANDBOX_API_KEY", secretKey: "MOONPAY_SANDBOX_SECRET_KEY" }, + stripe: { secretKey: "STRIPE_SANDBOX_SECRET_KEY", publicKey: "STRIPE_SANDBOX_PUBLISHABLE_KEY" }, +} as const satisfies Record>; + +type ManagedRampKeyName = (typeof MANAGED_RAMP_KEYS)[RampProviderId][keyof RampProviderCredentials]; + +/** The part of the environment SDP's managed ramp keys are read from. */ +export type ManagedRampKeysEnv = Readonly>>; + +/** + * SDP's managed keys for `provider` in `mode`, or why they can't serve it. + * SDP holds sandbox keys only: a production project brings its own account + * (HOO-1942, HOO-1943), so production fails closed until it can. The provider + * then checks the keys fit the mode, so a live key mounted under the sandbox + * names never reaches it. Availability, every provider call and rail + * discovery read this, so they never disagree. + * + * @param env - Where the managed keys are mounted. + * @param provider - The ramp provider. + * @param mode - The mode the call runs in. + * @returns The keys, or the refusal. + */ +export function resolveManagedRampCredentials( + env: ManagedRampKeysEnv, + provider: RampProviderId, + mode: SdpEnvironment +): RampCredentialsResolution { + const client = RAMP_PROVIDER_CLIENTS[provider]; + if (mode === "production") { + return { ok: false, reason: `${client.label} is not configured for production projects.` }; + } + const names = MANAGED_RAMP_KEYS[provider]; + const publicKey = env[names.publicKey]?.trim(); + const secretKey = env[names.secretKey]?.trim(); + if (!publicKey || !secretKey) { + return { + ok: false, + reason: `${client.label} sandbox is not configured. Set ${Object.values(names).join(" and ")}.`, + }; + } + const credentials = { publicKey, secretKey }; + const refusal = client.checkCredentials(credentials, mode); + return refusal === null ? { ok: true, credentials } : { ok: false, reason: refusal }; +} + +/** + * The runtime one call to `provider` runs under in `mode`: the cluster the + * mode picks, as the cluster scope derives it (ADR 0007), and SDP's keys. + * + * @param env - Where the managed keys are mounted. + * @param provider - The ramp provider. + * @param mode - The mode the call runs in. + * @returns The runtime context. + * @throws SdpPaymentsError PROVIDER_NOT_CONFIGURED when the keys can't serve `mode`. + */ +export function rampRuntimeFor( + env: ManagedRampKeysEnv, + provider: RampProviderId, + mode: SdpEnvironment +): RampRuntimeContext { + const resolution = resolveManagedRampCredentials(env, provider, mode); + if (!resolution.ok) { + throw providerNotConfigured(resolution.reason); + } + return { mode, cluster: CLUSTER_BY_SDP_ENVIRONMENT[mode], credentials: resolution.credentials }; +} diff --git a/apps/sdp-api/src/services/ramps/webhooks/moonpay.ts b/apps/sdp-api/src/services/ramps/webhooks/moonpay.ts index 7aa700783f..99e7774f64 100644 --- a/apps/sdp-api/src/services/ramps/webhooks/moonpay.ts +++ b/apps/sdp-api/src/services/ramps/webhooks/moonpay.ts @@ -1,13 +1,12 @@ import { readRecord, readString } from "@sdp/payments/json"; -import { moonpayCryptoRail } from "@sdp/payments/ramps/providers/moonpay/client"; import { type MoonpaySettlementEvent, moonpayBuyTransactionSchema, moonpaySellTransactionSchema, moonpaySellTransactionSettlementEvent, moonpayTransactionSettlementEvent, + withMoonpaySettledAsset, } from "@sdp/payments/ramps/providers/moonpay/event-status-mapping"; -import type { RampWebhookValidationContext } from "@sdp/payments/ramps/types"; import type { SdpEnvironment } from "@sdp/types/api-keys"; import { isRampTransferType, @@ -30,7 +29,7 @@ import { withRampSettlementTransaction, } from "@/services/payments/ramp-settlements"; import type { Env } from "@/types/env"; -import type { WebhookProcessor } from "./processor"; +import type { RampWebhookValidationContext, WebhookProcessor } from "./processor"; /** * The key that signs `environment`'s MoonPay events. SDP holds the sandbox key @@ -91,10 +90,8 @@ function withSettledAsset( event: MoonpaySettlementEvent, cryptoCode: string | undefined ): MoonpaySettlementEvent { - if (event.kind !== "settled") { - return event; - } - if (cryptoCode === undefined) { + const withAsset = withMoonpaySettledAsset(event, cryptoCode); + if (withAsset === null) { throw badRequest( `MoonPay "${type}" webhook carries a completed transaction without its crypto`, { @@ -102,7 +99,7 @@ function withSettledAsset( } ); } - return { ...event, deliveredAssetRail: moonpayCryptoRail(cryptoCode) }; + return withAsset; } class RollBackMoonpayClaim extends Error { diff --git a/apps/sdp-api/src/services/ramps/webhooks/processor.ts b/apps/sdp-api/src/services/ramps/webhooks/processor.ts index 4d3d9db1c6..53a2bd3c28 100644 --- a/apps/sdp-api/src/services/ramps/webhooks/processor.ts +++ b/apps/sdp-api/src/services/ramps/webhooks/processor.ts @@ -1,4 +1,4 @@ -import type { RampProviderWebhook, RampWebhookValidationContext } from "@sdp/payments/ramps/types"; +import type { RampProviderWebhook } from "@sdp/payments/ramps/types"; import type { SdpEnvironment } from "@sdp/types/api-keys"; import type { RampWebhookProcessResult } from "@sdp/types/payments"; import type { RampProviderId } from "@sdp/types/provider-access"; @@ -9,6 +9,15 @@ export type AppContext = Context<{ Bindings: Env }>; export class TerminalRampWebhookError extends Error {} +/** What a processor verifies a delivery against. */ +export interface RampWebhookValidationContext { + env: Record; + environment: SdpEnvironment; + headers: Headers; + rawBody: string; + requestUrl?: string; +} + export interface WebhookProcessor extends RampProviderWebhook { readonly provider: RampProviderId; verify(context: RampWebhookValidationContext): Promise; diff --git a/apps/sdp-api/src/services/ramps/webhooks/stripe.ts b/apps/sdp-api/src/services/ramps/webhooks/stripe.ts index 530886716b..d4d496528a 100644 --- a/apps/sdp-api/src/services/ramps/webhooks/stripe.ts +++ b/apps/sdp-api/src/services/ramps/webhooks/stripe.ts @@ -4,7 +4,7 @@ import { stripeOnrampSessionStatusSchema, stripeSessionSettlementEvent, } from "@sdp/payments/ramps/providers/stripe/session-status-mapping"; -import type { RampSettlementEvent, RampWebhookValidationContext } from "@sdp/payments/ramps/types"; +import type { RampSettlementEvent } from "@sdp/payments/ramps/types"; import type { SdpEnvironment } from "@sdp/types/api-keys"; import type { RampWebhookProcessResult } from "@sdp/types/payments"; import { z } from "zod"; @@ -13,7 +13,7 @@ import { verifyWebhookSignature } from "@/lib/webhook-signature"; import { getLogger } from "@/runtime/logger"; import { applyRampSettlementEvent } from "@/services/payments/ramp-settlements"; import type { Env } from "@/types/env"; -import type { WebhookProcessor } from "./processor"; +import type { RampWebhookValidationContext, WebhookProcessor } from "./processor"; /** The one field every Stripe event carries that says which mode it ran in. */ const stripeEventModeSchema = z.object({ livemode: z.boolean() }); diff --git a/apps/sdp-api/src/test/helpers/payments-routes.ts b/apps/sdp-api/src/test/helpers/payments-routes.ts index 7f35e3bf96..391b7e10e3 100644 --- a/apps/sdp-api/src/test/helpers/payments-routes.ts +++ b/apps/sdp-api/src/test/helpers/payments-routes.ts @@ -157,10 +157,6 @@ let originalMoonPaySandboxApiKey: string | undefined; let originalMoonPaySandboxSecretKey: string | undefined; -let originalMoonPayOnrampUrl: string | undefined; - -let originalMoonPayOfframpUrl: string | undefined; - async function seedAuthAndWallet(): Promise { const keyHash = await hashString(TEST_API_KEY.raw, env.API_KEY_PEPPER); @@ -574,13 +570,9 @@ export function installPaymentsRouteTestHooks(): void { originalMoonPaySandboxApiKey = env.MOONPAY_SANDBOX_API_KEY; originalMoonPaySandboxSecretKey = env.MOONPAY_SANDBOX_SECRET_KEY; - originalMoonPayOnrampUrl = env.MOONPAY_ONRAMP_URL; - originalMoonPayOfframpUrl = env.MOONPAY_OFFRAMP_URL; env.MOONPAY_SANDBOX_API_KEY = TEST_MOONPAY_API_KEY; env.MOONPAY_SANDBOX_SECRET_KEY = TEST_MOONPAY_SECRET_KEY; - env.MOONPAY_ONRAMP_URL = TEST_MOONPAY_ONRAMP_URL; - env.MOONPAY_OFFRAMP_URL = TEST_MOONPAY_OFFRAMP_URL; await seedTestDatabase(env); await seedAuthAndWallet(); @@ -589,8 +581,6 @@ export function installPaymentsRouteTestHooks(): void { afterEach(async () => { env.MOONPAY_SANDBOX_API_KEY = originalMoonPaySandboxApiKey; env.MOONPAY_SANDBOX_SECRET_KEY = originalMoonPaySandboxSecretKey; - env.MOONPAY_ONRAMP_URL = originalMoonPayOnrampUrl; - env.MOONPAY_OFFRAMP_URL = originalMoonPayOfframpUrl; await clearKVStores(env); }); diff --git a/apps/sdp-api/src/types/env.d.ts b/apps/sdp-api/src/types/env.d.ts index d1c5a70041..9a054c10b5 100644 --- a/apps/sdp-api/src/types/env.d.ts +++ b/apps/sdp-api/src/types/env.d.ts @@ -275,8 +275,6 @@ export interface Env extends ManagedRpcEnv { // MoonPay ramps configuration // MoonPay sandbox only: production projects bring their own MoonPay account. - MOONPAY_ONRAMP_URL?: string; - MOONPAY_OFFRAMP_URL?: string; MOONPAY_SANDBOX_API_KEY?: string; MOONPAY_SANDBOX_SECRET_KEY?: string; MOONPAY_SANDBOX_WEBHOOK_KEY?: string; diff --git a/apps/sdp-docs/content/docs/payments/ramps-moonpay.mdx b/apps/sdp-docs/content/docs/payments/ramps-moonpay.mdx index f7979dff86..3060b33649 100644 --- a/apps/sdp-docs/content/docs/payments/ramps-moonpay.mdx +++ b/apps/sdp-docs/content/docs/payments/ramps-moonpay.mdx @@ -15,7 +15,5 @@ SDP holds MoonPay test-mode keys only, so MoonPay runs for sandbox projects. Pro | `MOONPAY_SANDBOX_API_KEY` | conditional | Test-mode publishable key (`pk_test_…`). A live key is refused before any request. | | `MOONPAY_SANDBOX_SECRET_KEY` | conditional | Test-mode secret (`sk_test_…`) used to sign onramp and offramp URLs. Paired with `MOONPAY_SANDBOX_API_KEY`. | | `MOONPAY_SANDBOX_WEBHOOK_KEY` | conditional | Signing key of the endpoint MoonPay calls at `/webhooks/payments/ramps/sandbox/moonpay`. | -| `MOONPAY_ONRAMP_URL` | no | Override the onramp host. Defaults to MoonPay's sandbox widget. | -| `MOONPAY_OFFRAMP_URL` | no | Same idea for offramp. | MoonPay normalizes provider currency codes internally — SDP accepts the canonical asset rails such as `usdc.solana` and maps them to MoonPay's `usdc_sol` code. diff --git a/apps/sdp-docs/public/llms-full.txt b/apps/sdp-docs/public/llms-full.txt index b148c03312..0582b699c9 100644 --- a/apps/sdp-docs/public/llms-full.txt +++ b/apps/sdp-docs/public/llms-full.txt @@ -3460,8 +3460,6 @@ SDP holds MoonPay test-mode keys only, so MoonPay runs for sandbox projects. Pro | `MOONPAY_SANDBOX_API_KEY` | conditional | Test-mode publishable key (`pk_test_…`). A live key is refused before any request. | | `MOONPAY_SANDBOX_SECRET_KEY` | conditional | Test-mode secret (`sk_test_…`) used to sign onramp and offramp URLs. Paired with `MOONPAY_SANDBOX_API_KEY`. | | `MOONPAY_SANDBOX_WEBHOOK_KEY` | conditional | Signing key of the endpoint MoonPay calls at `/webhooks/payments/ramps/sandbox/moonpay`. | -| `MOONPAY_ONRAMP_URL` | no | Override the onramp host. Defaults to MoonPay's sandbox widget. | -| `MOONPAY_OFFRAMP_URL` | no | Same idea for offramp. | MoonPay normalizes provider currency codes internally — SDP accepts the canonical asset rails such as `usdc.solana` and maps them to MoonPay's `usdc_sol` code. diff --git a/docs/contributing/code-standards.md b/docs/contributing/code-standards.md index ac1b1d01eb..41a9bca8e0 100644 --- a/docs/contributing/code-standards.md +++ b/docs/contributing/code-standards.md @@ -118,7 +118,8 @@ A change that triggers one of these must handle it. lookup) and has a status-pull entry and an abandonment horizon in `apps/sdp-api/src/services/jobs/sweep-ramp-transfer-statuses.ts`; typecheck fails until it does. -- Provider instances come only from `RAMP_PROVIDER_CLIENTS`. A second instance breaks the webhook tests' `vi.spyOn` mocks. +- Provider instances come only from `RAMP_PROVIDER_CLIENTS`, typed as the contract; a caller narrows to a direction with `rampClientFor`. A second instance breaks the webhook tests' `vi.spyOn` mocks. +- Adapters get `RampRuntimeContext` (`{ mode, cluster, credentials }`) and never the process env. The API resolves the keys once (`apps/sdp-api/src/services/ramps/runtime.ts`) for every call, availability check and discovery run. Outside the package, only a provider's webhook processor imports its modules, and only its status mapping (`packages/sdp-payments/src/ramps/boundaries.test.ts`). - Provider clients never access the database. Route handlers and background jobs access it through repositories. ## Dashboard (`apps/sdp-web`) diff --git a/docs/decisions/0008-ramp-provider-contract.md b/docs/decisions/0008-ramp-provider-contract.md index 8b79ccdbcd..4d8f3c4daf 100644 --- a/docs/decisions/0008-ramp-provider-contract.md +++ b/docs/decisions/0008-ramp-provider-contract.md @@ -1,9 +1,9 @@ # ADR 0008: Ramp provider contract v2 -- **Status:** Proposed +- **Status:** Accepted - **Date:** 2026-10-13 - **Deciders:** SDP engineering -- **Related:** HOO-2031, HOO-2026; `packages/sdp-payments/src/ramps`; ADR 0002 (Earn provider pluggability), ADR 0005 (release channels), ADR 0007 (cluster scope); PRO-2065, PRO-2057, PRO-2058; HOO-2032, HOO-2038, HOO-2040 +- **Related:** HOO-2031, HOO-2086, HOO-2026; `packages/sdp-payments/src/ramps`; ADR 0002 (Earn provider pluggability), ADR 0005 (release channels), ADR 0007 (cluster scope); PRO-2065, PRO-2057, PRO-2058; HOO-2032, HOO-2038, HOO-2040 ## Context @@ -40,17 +40,21 @@ stateful providers fit the model at all. presence, so an onramp-only provider stops carrying an offramp method that throws. - `RampRuntimeContext` is `{ mode, cluster, credentials }`. Adapters never see - the process environment. Credentials resolve per project for the resolved - mode (HOO-2032); the cluster comes from the cluster scope (ADR 0007). + the process environment. Credentials are the account's public and secret + keys, resolved by the API for the project's mode + (`apps/sdp-api/src/services/ramps/runtime.ts`: SDP's managed sandbox keys + until projects bring their own, HOO-2032). Each adapter's + `checkCredentials` refuses keys whose format names the other mode. The + cluster is the one the mode picks, as the cluster scope derives it (ADR 0007). - `RampQuoteRequest` and `RampQuoteResult` are provider-neutral and build on PRO-2065's `quoteDetails` and `paymentInstructions`. Core types import no provider module and name no provider field. - `RampQuoteResult` carries the terms the provider reported. One shared assertion checks them against the request (Lightspark's quote-matches-request check, generalized). - - Optional members: `webhook` (signature descriptor, `eventId`, a pure `parse`; - HOO-2038), `fetchTransferStatus` (HOO-2040) and `checkConnection` - (HOO-1942, HOO-1943). + - `fetchTransferStatus` is required (HOO-2040). Optional members: `webhook` + (signature descriptor, `eventId`, a pure `parse`; HOO-2038) and + `checkConnection` (HOO-1942, HOO-1943). - **The registry is typed as the contract:** `Readonly>`. `resolveRampProviderClient` answers 503 for any id that is not live, retired @@ -80,8 +84,11 @@ stateful providers fit the model at all. quote shape per delivery mode, mode selection and a response that fails closed. Webhook verify and parse join with HOO-2038, idempotent replay with PRO-2058. -- **Boundary tests:** nothing outside the package imports a provider module, - only `services/ramps` writes ramp rows, and `@sdp/payments` never sees `Env`. +- **Boundary tests:** outside the package, only a provider's webhook processor + imports its modules, and only its status mapping, and `@sdp/payments` never + sees the process environment (`packages/sdp-payments/src/ramps/boundaries.test.ts`). + Every ramp status writer is listed in the writer inventory + (`apps/sdp-api/src/services/payments/ramp-transfer-transitions.test.ts`). - **Headless providers re-enter through this contract**, not a second extension point. A stateful provider needs four things, each an optional member or port: @@ -119,8 +126,9 @@ stateful providers fit the model at all. 1. This ADR, the error split and the diagnostics log. 2. Response schemas and the contract suite. 3. The contract reshaping and registry typing, after the non-launch providers are - removed and the cluster-scope stack lands. Credentials land with HOO-2032, - the webhook member with HOO-2038, status pull with HOO-2040. + removed and the cluster-scope stack lands (HOO-2086). Still open: quote + results that carry the provider's terms and the one shared assertion over + them (Stripe checks its session terms itself), and `checkConnection`. - Provider failures lose the provider's wording in API error bodies. Callers get the code, the provider status in `details` and an SDP message; operators read the provider's wording from `error_diagnostics` in the logs. diff --git a/packages/sdp-payments/package.json b/packages/sdp-payments/package.json index 8b1397f6e3..6e5194d8c1 100644 --- a/packages/sdp-payments/package.json +++ b/packages/sdp-payments/package.json @@ -39,21 +39,11 @@ "import": "./src/ramps/fetch.ts", "default": "./src/ramps/fetch.ts" }, - "./ramps/providers/moonpay/client": { - "types": "./src/ramps/providers/moonpay/client.ts", - "import": "./src/ramps/providers/moonpay/client.ts", - "default": "./src/ramps/providers/moonpay/client.ts" - }, "./ramps/providers/moonpay/event-status-mapping": { "types": "./src/ramps/providers/moonpay/event-status-mapping.ts", "import": "./src/ramps/providers/moonpay/event-status-mapping.ts", "default": "./src/ramps/providers/moonpay/event-status-mapping.ts" }, - "./ramps/providers/stripe/client": { - "types": "./src/ramps/providers/stripe/client.ts", - "import": "./src/ramps/providers/stripe/client.ts", - "default": "./src/ramps/providers/stripe/client.ts" - }, "./ramps/providers/stripe/session-status-mapping": { "types": "./src/ramps/providers/stripe/session-status-mapping.ts", "import": "./src/ramps/providers/stripe/session-status-mapping.ts", diff --git a/packages/sdp-payments/src/ramps/boundaries.test.ts b/packages/sdp-payments/src/ramps/boundaries.test.ts new file mode 100644 index 0000000000..6d36abdfbe --- /dev/null +++ b/packages/sdp-payments/src/ramps/boundaries.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { z } from "zod"; + +const PACKAGE_ROOT = path.resolve(import.meta.dirname, "../.."); +const REPOSITORY_ROOT = path.resolve(PACKAGE_ROOT, "../.."); +const SKIPPED_DIRECTORIES = new Set(["node_modules", "dist", "coverage", ".next", ".turbo"]); +const PROVIDER_MODULE_IMPORT = /["']@sdp\/payments\/ramps\/providers\/([^/"']+)\/([^"']+)["']/g; + +/** Every TypeScript file under `directory`, build output and dependencies aside. */ +function typescriptFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return SKIPPED_DIRECTORIES.has(entry.name) ? [] : typescriptFiles(entryPath); + } + return /\.(ts|tsx|mts)$/.test(entry.name) ? [entryPath] : []; + }); +} + +function repositoryPath(file: string): string { + return path.relative(REPOSITORY_ROOT, file); +} + +describe("ramp provider boundaries", () => { + it("lets only a provider's webhook processor import its modules, and only its status mapping", () => { + const outsideFiles = ["apps", "packages"] + .flatMap((workspaces) => typescriptFiles(path.join(REPOSITORY_ROOT, workspaces))) + .filter((file) => !file.startsWith(`${PACKAGE_ROOT}${path.sep}`)); + const violations = outsideFiles.flatMap((file) => + [...readFileSync(file, "utf8").matchAll(PROVIDER_MODULE_IMPORT)].flatMap( + ([, provider, module]) => + repositoryPath(file) === `apps/sdp-api/src/services/ramps/webhooks/${provider}.ts` && + module.endsWith("-status-mapping") + ? [] + : [`${repositoryPath(file)} imports providers/${provider}/${module}`] + ) + ); + assert.deepEqual(violations, []); + }); + + it("exports no provider module but a status mapping", () => { + const manifest = z + .object({ exports: z.record(z.string(), z.unknown()) }) + .parse(JSON.parse(readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"))); + const providerModules = Object.keys(manifest.exports).filter( + (subpath) => subpath.startsWith("./ramps/providers/") && !subpath.endsWith("-status-mapping") + ); + assert.deepEqual(providerModules, []); + }); + + it("never sees the process environment", () => { + const readers = typescriptFiles(path.join(PACKAGE_ROOT, "src")) + .filter((file) => !file.endsWith(".test.ts")) + .filter((file) => /\bprocess\.env\b|\benv\b/.test(readFileSync(file, "utf8"))) + .map(repositoryPath); + assert.deepEqual(readers, []); + }); +}); diff --git a/packages/sdp-payments/src/ramps/contract.test.ts b/packages/sdp-payments/src/ramps/contract.test.ts index c08189e553..a2f41babe3 100644 --- a/packages/sdp-payments/src/ramps/contract.test.ts +++ b/packages/sdp-payments/src/ramps/contract.test.ts @@ -16,9 +16,16 @@ import { type RampProviderTestKit, type RecordedExchange, type Replay, + rampTestRuntime, replayExchanges, } from "./test-kit"; -import type { RampProvider, RampSettlementEvent, RampTransferLookup } from "./types"; +import { + RAMP_DIRECTION_MEMBERS, + type RampProvider, + type RampProviderCredentials, + type RampRuntimeContext, + supportsRampDirection, +} from "./types"; /** * Every registered provider has a kit. A provider on its way out names the @@ -59,23 +66,38 @@ async function replayed( return { result: settled.result, replay }; } +function isProviderNotConfigured(cause: unknown): boolean { + assert.ok(cause instanceof SdpPaymentsError); + assert.equal(cause.code, "PROVIDER_NOT_CONFIGURED"); + return true; +} + /** The kit's cases for `direction`, or undefined when the provider does not run it. */ function casesFor(kit: RampProviderTestKit, direction: RampDirection) { return direction === "onramp" ? kit.onramp : kit.offramp; } +/** The kit's keys for the other mode, which every call in `mode` refuses. */ +function otherModeCredentials( + kit: RampProviderTestKit, + mode: SdpEnvironment +): RampProviderCredentials { + return mode === "sandbox" ? kit.liveCredentials : kit.credentials; +} + async function runEstimate( client: RampProvider, kit: RampProviderTestKit, direction: RampDirection, - mode: SdpEnvironment + ctx: RampRuntimeContext ): Promise { - const ctx = { env: kit.env, mode }; if (direction === "onramp") { assert.ok(kit.onramp, "the kit records an onramp"); + assert.ok(supportsRampDirection(client, "onramp"), "a provider with onramp cases runs onramp"); return client.estimateOnramp(ctx, kit.onramp.estimate.input); } assert.ok(kit.offramp, "the kit records an offramp"); + assert.ok(supportsRampDirection(client, "offramp"), "a provider with offramp cases runs offramp"); return client.estimateOfframp(ctx, kit.offramp.estimate.input); } @@ -83,28 +105,18 @@ async function runQuote( client: RampProvider, kit: RampProviderTestKit, direction: RampDirection, - mode: SdpEnvironment + ctx: RampRuntimeContext ): Promise { - const ctx = { env: kit.env, mode }; if (direction === "onramp") { assert.ok(kit.onramp, "the kit records an onramp"); - assert.ok(client.createOnrampQuote, "a provider that runs onramp can quote it"); + assert.ok(supportsRampDirection(client, "onramp"), "a provider with onramp cases runs onramp"); return client.createOnrampQuote(ctx, kit.onramp.quote.input); } assert.ok(kit.offramp, "the kit records an offramp"); + assert.ok(supportsRampDirection(client, "offramp"), "a provider with offramp cases runs offramp"); return client.createOfframpQuote(ctx, kit.offramp.quote.input); } -async function runTransferStatus( - client: RampProvider, - kit: RampProviderTestKit, - lookup: RampTransferLookup, - mode: SdpEnvironment -): Promise { - assert.ok(client.fetchTransferStatus, "a provider with status cases can look a transfer up"); - return client.fetchTransferStatus({ env: kit.env, mode }, lookup); -} - function assertEstimateContract( estimate: PaymentRampEstimate, provider: RampProviderId, @@ -142,32 +154,24 @@ function assertQuoteContract(quote: PaymentRampQuote, provider: RampProviderId): } } -function assertRequestCredentials( - kit: RampProviderTestKit, - replay: Replay, - mode: SdpEnvironment -): void { +function assertRequestCredentials(kit: RampProviderTestKit, replay: Replay): void { for (const request of replay.requests) { - assert.ok( - kit.usesModeCredential(request, mode), - `${request.method} ${request.url} carries the ${mode} credential` - ); + assert.ok(kit.usesCredentials(request), `${request.method} ${request.url} carries the keys`); } } function assertQuoteCredentials( kit: RampProviderTestKit, replay: Replay, - quote: PaymentRampQuote, - mode: SdpEnvironment + quote: PaymentRampQuote ): void { - assertRequestCredentials(kit, replay, mode); - const { quoteUsesModeCredential } = kit; - if (quoteUsesModeCredential === undefined) { + assertRequestCredentials(kit, replay); + const { quoteUsesCredentials } = kit; + if (quoteUsesCredentials === undefined) { assert.ok(replay.requests.length > 0, "a quote built without a request needs a quote check"); return; } - assert.ok(quoteUsesModeCredential(quote, mode), `the quote carries the ${mode} credential`); + assert.ok(quoteUsesCredentials(quote), "the quote carries the keys"); } describe("ramp provider contract", () => { @@ -178,84 +182,119 @@ describe("ramp provider contract", () => { for (const provider of RAMP_PROVIDERS) { const kit = KITS[provider]; const client: RampProvider = RAMP_PROVIDER_CLIENTS[provider]; + const sandbox = rampTestRuntime("sandbox", kit.credentials); describe(provider, () => { + it("has every member of a direction or none", () => { + for (const direction of RAMP_DIRECTIONS) { + const present = RAMP_DIRECTION_MEMBERS[direction].filter( + (member) => client[member] !== undefined + ); + assert.ok( + present.length === 0 || present.length === RAMP_DIRECTION_MEMBERS[direction].length, + `${direction} members: ${present.join(", ")}` + ); + } + }); + + it("records a direction exactly when it runs it", () => { + for (const direction of RAMP_DIRECTIONS) { + assert.equal( + casesFor(kit, direction) !== undefined, + supportsRampDirection(client, direction), + direction + ); + } + }); + + it("accepts keys only in the mode they name", () => { + const { credentials, liveCredentials } = kit; + assert.equal(client.checkCredentials(credentials, "sandbox"), null); + assert.equal(client.checkCredentials(liveCredentials, "production"), null); + assert.equal( + client.checkCredentials(liveCredentials, "sandbox"), + `${client.label} sandbox keys must be test-mode keys.` + ); + assert.equal( + client.checkCredentials(credentials, "production"), + `${client.label} production keys must be live-mode keys.` + ); + for (const mixed of [ + { ...credentials, publicKey: liveCredentials.publicKey }, + { ...credentials, secretKey: liveCredentials.secretKey }, + ]) { + for (const mode of SDP_ENVIRONMENTS) { + assert.notEqual(client.checkCredentials(mixed, mode), null, `mixed keys in ${mode}`); + } + } + }); + for (const direction of RAMP_DIRECTIONS) { const cases = casesFor(kit, direction); if (cases === undefined) { continue; } - for (const mode of SDP_ENVIRONMENTS) { - if (!kit.managedModes.includes(mode)) { - it(`refuses an ${direction} in ${mode} before any request`, async () => { - const runs: ReadonlyArray<() => Promise> = [ - () => runEstimate(client, kit, direction, mode), - () => runQuote(client, kit, direction, mode), - ]; - for (const run of runs) { - await assert.rejects(replayed([], run), (cause: unknown) => { - assert.ok(cause instanceof SdpPaymentsError); - assert.equal(cause.code, "PROVIDER_NOT_CONFIGURED"); - return true; - }); - } - }); - continue; - } - it(`estimates an ${direction} in ${mode} to the contract`, async () => { - const { result, replay } = await replayed(cases.estimate.exchanges, () => - runEstimate(client, kit, direction, mode) + for (const mode of SDP_ENVIRONMENTS) { + it(`refuses an ${direction} in ${mode} on the other mode's keys before any request`, async () => { + const ctx = rampTestRuntime(mode, otherModeCredentials(kit, mode)); + await assert.rejects( + replayed([], () => runEstimate(client, kit, direction, ctx)), + isProviderNotConfigured ); - assertEstimateContract(result, provider, direction, cases.estimate.input); - assertRequestCredentials(kit, replay, mode); - }); - - it(`quotes an ${direction} in ${mode} for its delivery mode`, async () => { - const { result, replay } = await replayed(cases.quote.exchanges, () => - runQuote(client, kit, direction, mode) + await assert.rejects( + replayed([], () => runQuote(client, kit, direction, ctx)), + isProviderNotConfigured ); - assertQuoteContract(result, provider); - assertQuoteCredentials(kit, replay, result, mode); }); } + + it(`estimates an ${direction} to the contract`, async () => { + const { result, replay } = await replayed(cases.estimate.exchanges, () => + runEstimate(client, kit, direction, sandbox) + ); + assertEstimateContract(result, provider, direction, cases.estimate.input); + assertRequestCredentials(kit, replay); + }); + + it(`quotes an ${direction} for its delivery mode`, async () => { + const { result, replay } = await replayed(cases.quote.exchanges, () => + runQuote(client, kit, direction, sandbox) + ); + assertQuoteContract(result, provider); + assertQuoteCredentials(kit, replay, result); + }); } - it("records status lookups exactly when it can look a transfer up", () => { - assert.equal(kit.transferStatus !== undefined, client.fetchTransferStatus !== undefined); + it("records at least one status lookup", () => { + assert.ok(Object.keys(kit.transferStatus).length > 0); }); - for (const [name, statusCase] of Object.entries(kit.transferStatus ?? {})) { + for (const [name, statusCase] of Object.entries(kit.transferStatus)) { for (const mode of SDP_ENVIRONMENTS) { - if (!kit.managedModes.includes(mode)) { - it(`refuses to look up ${name} in ${mode} before any request`, async () => { - await assert.rejects( - replayed([], () => runTransferStatus(client, kit, statusCase.input, mode)), - (cause: unknown) => { - assert.ok(cause instanceof SdpPaymentsError); - assert.equal(cause.code, "PROVIDER_NOT_CONFIGURED"); - return true; - } - ); - }); - continue; - } - - it(`looks up ${name} in ${mode} with one request`, async () => { - const { result, replay } = await replayed(statusCase.exchanges, () => - runTransferStatus(client, kit, statusCase.input, mode) + it(`refuses to look up ${name} in ${mode} on the other mode's keys before any request`, async () => { + const ctx = rampTestRuntime(mode, otherModeCredentials(kit, mode)); + await assert.rejects( + replayed([], () => client.fetchTransferStatus(ctx, statusCase.input)), + isProviderNotConfigured ); - assert.equal(replay.requests.length, 1, "a status lookup makes one request"); - assertRequestCredentials(kit, replay, mode); - assert.deepEqual(result, statusCase.events); }); } + + it(`looks up ${name} with one request`, async () => { + const { result, replay } = await replayed(statusCase.exchanges, () => + client.fetchTransferStatus(sandbox, statusCase.input) + ); + assert.equal(replay.requests.length, 1, "a status lookup makes one request"); + assertRequestCredentials(kit, replay); + assert.deepEqual(result, statusCase.events); + }); } it("fails closed on a response it cannot read", async () => { const { direction, exchange } = kit.malformedEstimate; replayExchanges([exchange]); - await assert.rejects(runEstimate(client, kit, direction, "sandbox"), (cause: unknown) => { + await assert.rejects(runEstimate(client, kit, direction, sandbox), (cause: unknown) => { assert.ok(cause instanceof SdpPaymentsError); assert.equal(cause.code, "PROVIDER_RESPONSE_INVALID"); assert.equal(cause.message, `${provider} returned an unexpected response`); diff --git a/packages/sdp-payments/src/ramps/index.ts b/packages/sdp-payments/src/ramps/index.ts index ae912d766f..c752b10ae2 100644 --- a/packages/sdp-payments/src/ramps/index.ts +++ b/packages/sdp-payments/src/ramps/index.ts @@ -1,13 +1,32 @@ +import type { RampDirection } from "@sdp/types/payments"; import type { RampProviderId } from "@sdp/types/provider-access"; -import { providerNotConfigured } from "#/errors"; +import { badRequest, providerNotConfigured } from "#/errors"; import { MoonpayRampClient } from "./providers/moonpay/client"; +import type { MoonpaySettlementEvent } from "./providers/moonpay/event-status-mapping"; import { StripeRampClient } from "./providers/stripe/client"; -import type { RampProvider } from "./types"; +import { + type RampDirectionMembers, + type RampProvider, + type RampSettlementEvent, + supportsRampDirection, +} from "./types"; -export const RAMP_PROVIDER_CLIENTS = { +/** The events each provider's status pull answers, as its webhook processor reads them. */ +interface RampProviderSettlementEvents { + moonpay: MoonpaySettlementEvent; + stripe: RampSettlementEvent; +} + +/** + * The one instance of each provider, typed as the contract: callers reach a + * provider only through `RampProvider` members. + */ +export const RAMP_PROVIDER_CLIENTS: { + readonly [Provider in RampProviderId]: RampProvider; +} = { moonpay: new MoonpayRampClient(), stripe: new StripeRampClient(), -} as const satisfies Record; +}; export function isRampProviderId(value: string): value is RampProviderId { // Object.hasOwn, not `in`: provider ids come from open TEXT columns, and a @@ -28,3 +47,18 @@ export function resolveRampProviderClient(provider: string): RampProvider { } return RAMP_PROVIDER_CLIENTS[provider]; } + +/** + * `provider`'s client, narrowed to the members `direction` needs. BAD_REQUEST + * when it does not run that direction. + */ +export function rampClientFor( + provider: RampProviderId, + direction: Direction +): RampProvider & RampDirectionMembers[Direction] { + const client: RampProvider = RAMP_PROVIDER_CLIENTS[provider]; + if (!supportsRampDirection(client, direction)) { + throw badRequest(`${provider} does not support ${direction}.`, { provider }); + } + return client; +} diff --git a/packages/sdp-payments/src/ramps/providers/moonpay/client.test.ts b/packages/sdp-payments/src/ramps/providers/moonpay/client.test.ts index 2914e21402..e424c74684 100644 --- a/packages/sdp-payments/src/ramps/providers/moonpay/client.test.ts +++ b/packages/sdp-payments/src/ramps/providers/moonpay/client.test.ts @@ -6,23 +6,21 @@ import { type CryptoRailId, SOLANA_CRYPTO_RAILS } from "@sdp/types/payment-rails import type { PaymentRampQuote } from "@sdp/types/payments"; import { addDecimalAmounts, subtractDecimalAmounts } from "#/decimal"; import { rampAmountLimitOf, SdpPaymentsError } from "#/errors"; -import { RAMP_PROVIDER_CLIENTS } from "#/ramps/index"; -import { replayExchanges } from "#/ramps/test-kit"; -import type { - RampOfframpQuoteInput, - RampOnrampQuoteInput, - RampRuntimeContext, -} from "#/ramps/types"; -import { distillMoonpayRailSupport, MoonpayRampClient } from "./client"; +import { RAMP_PROVIDER_CLIENTS, rampClientFor } from "#/ramps/index"; +import { rampTestRuntime } from "#/ramps/test-kit"; +import type { RampOfframpQuoteInput, RampOnrampQuoteInput } from "#/ramps/types"; +import { distillMoonpayRailSupport } from "./client"; import { MOONPAY_TEST_KIT } from "./test-kit"; const WALLET = "8mSiNWTeu59yy1pxsoNCyy7KNMnKvfgGu8Ej975LsufM"; const SECRET_KEY = "sk_test_moonpay"; -const runtimeContext = { - env: { MOONPAY_SANDBOX_API_KEY: "pk_test_moonpay", MOONPAY_SANDBOX_SECRET_KEY: SECRET_KEY }, - mode: "sandbox", -} satisfies RampRuntimeContext; +const runtimeContext = rampTestRuntime("sandbox", { + publicKey: "pk_test_moonpay", + secretKey: SECRET_KEY, +}); +const moonpayOnramp = rampClientFor("moonpay", "onramp"); +const moonpayOfframp = rampClientFor("moonpay", "offramp"); const EXPECTED_MOONPAY_CODES = { "sol.solana": "sol", @@ -101,14 +99,12 @@ describe("MoonpayRampClient currency codes", () => { paths.push(new URL(String(input)).pathname); return Response.json(QUOTE_RESPONSE); }; - - const client = new MoonpayRampClient(); - await client.estimateOnramp(runtimeContext, { + await moonpayOnramp.estimateOnramp(runtimeContext, { assetRail, fiatCurrency: "USD", fiatAmount: "50", }); - await client.estimateOfframp(runtimeContext, { + await moonpayOfframp.estimateOfframp(runtimeContext, { assetRail, fiatCurrency: "USD", cryptoAmount: "1", @@ -122,10 +118,11 @@ describe("MoonpayRampClient currency codes", () => { it(`signs ${code} into both widget URLs for ${assetRail}`, async () => { refuseProviderCalls(); - - const client = new MoonpayRampClient(); - const onramp = await client.createOnrampQuote(runtimeContext, { ...onrampInput, assetRail }); - const offramp = await client.createOfframpQuote(runtimeContext, { + const onramp = await moonpayOnramp.createOnrampQuote(runtimeContext, { + ...onrampInput, + assetRail, + }); + const offramp = await moonpayOfframp.createOfframpQuote(runtimeContext, { ...offrampInput, assetRail, }); @@ -155,23 +152,30 @@ describe("MoonpayRampClient currency codes", () => { it("refuses a rail with no MoonPay code before calling MoonPay", async () => { refuseProviderCalls(); const assetRail = "eurc.solana" as string as CryptoRailId; - const client = new MoonpayRampClient(); const unsupported = isBadRequest(/^MoonPay does not support eurc\.solana$/); await assert.rejects( - client.estimateOnramp(runtimeContext, { assetRail, fiatCurrency: "USD", fiatAmount: "50" }), + moonpayOnramp.estimateOnramp(runtimeContext, { + assetRail, + fiatCurrency: "USD", + fiatAmount: "50", + }), unsupported ); await assert.rejects( - client.estimateOfframp(runtimeContext, { assetRail, fiatCurrency: "USD", cryptoAmount: "1" }), + moonpayOfframp.estimateOfframp(runtimeContext, { + assetRail, + fiatCurrency: "USD", + cryptoAmount: "1", + }), unsupported ); await assert.rejects( - client.createOnrampQuote(runtimeContext, { ...onrampInput, assetRail }), + moonpayOnramp.createOnrampQuote(runtimeContext, { ...onrampInput, assetRail }), unsupported ); await assert.rejects( - client.createOfframpQuote(runtimeContext, { ...offrampInput, assetRail }), + moonpayOfframp.createOfframpQuote(runtimeContext, { ...offrampInput, assetRail }), unsupported ); }); @@ -189,7 +193,7 @@ describe("MoonpayRampClient on-ramp total", () => { return Response.json({ ...QUOTE_RESPONSE, baseCurrencyAmount: 48.4 }); }; - const estimate = await RAMP_PROVIDER_CLIENTS.moonpay.estimateOnramp(runtimeContext, { + const estimate = await moonpayOnramp.estimateOnramp(runtimeContext, { assetRail: "sol.solana", fiatCurrency: "USD", fiatAmount: "50", @@ -204,7 +208,7 @@ describe("MoonpayRampClient on-ramp total", () => { it("takes MoonPay's own total when it sends one", async () => { answerBuyQuote({ ...QUOTE_RESPONSE, totalAmount: 51.61 }); - const estimate = await RAMP_PROVIDER_CLIENTS.moonpay.estimateOnramp(runtimeContext, { + const estimate = await moonpayOnramp.estimateOnramp(runtimeContext, { assetRail: "sol.solana", fiatCurrency: "USD", fiatAmount: "50", @@ -216,10 +220,7 @@ describe("MoonpayRampClient on-ramp total", () => { it("opens the buy widget on card, the method its estimate priced", async () => { refuseProviderCalls(); - const quote = await RAMP_PROVIDER_CLIENTS.moonpay.createOnrampQuote( - runtimeContext, - onrampInput - ); + const quote = await moonpayOnramp.createOnrampQuote(runtimeContext, onrampInput); assert.equal(signedHostedUrl(quote).searchParams.get("paymentMethod"), "credit_debit_card"); }); @@ -256,7 +257,7 @@ describe("MoonpayRampClient off-ramp limits", () => { } function estimateUsdc(cryptoAmount: string) { - return RAMP_PROVIDER_CLIENTS.moonpay.estimateOfframp(runtimeContext, { + return moonpayOfframp.estimateOfframp(runtimeContext, { assetRail: "usdc.solana", fiatCurrency: "USD", cryptoAmount, @@ -313,6 +314,12 @@ describe("MoonpayRampClient off-ramp limits", () => { describe("MoonpayRampClient sale cancel", () => { const TRANSFER_ID = "xfr_0f1e2d3c-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + function cancelSale(transferId: string) { + const moonpay = RAMP_PROVIDER_CLIENTS.moonpay; + assert.ok(moonpay.cancelOfframpSale, "MoonPay cancels the sales it holds"); + return moonpay.cancelOfframpSale(runtimeContext, transferId); + } + function answerCancel(status: number): Array<{ url: string; init: RequestInit | undefined }> { const requests: Array<{ url: string; init: RequestInit | undefined }> = []; globalThis.fetch = async (input, init) => { @@ -328,10 +335,7 @@ describe("MoonpayRampClient sale cancel", () => { it("deletes the sale by SDP's id with the secret key", async () => { const requests = answerCancel(204); - const outcome = await RAMP_PROVIDER_CLIENTS.moonpay.cancelOfframpSale( - runtimeContext, - TRANSFER_ID - ); + const outcome = await cancelSale(TRANSFER_ID); assert.equal(outcome, "canceled"); assert.equal(requests.length, 1); @@ -344,28 +348,19 @@ describe("MoonpayRampClient sale cancel", () => { it("reads no sale and a finished sale as answers, not failures", async () => { answerCancel(404); - assert.equal( - await RAMP_PROVIDER_CLIENTS.moonpay.cancelOfframpSale(runtimeContext, TRANSFER_ID), - "not_found" - ); + assert.equal(await cancelSale(TRANSFER_ID), "not_found"); answerCancel(409); - assert.equal( - await RAMP_PROVIDER_CLIENTS.moonpay.cancelOfframpSale(runtimeContext, TRANSFER_ID), - "not_cancelable" - ); + assert.equal(await cancelSale(TRANSFER_ID), "not_cancelable"); }); it("throws when MoonPay refuses the key", async () => { answerCancel(401); - await assert.rejects( - RAMP_PROVIDER_CLIENTS.moonpay.cancelOfframpSale(runtimeContext, TRANSFER_ID), - (cause: unknown) => { - assert.ok(cause instanceof SdpPaymentsError); - assert.equal(cause.code, "PROVIDER_NOT_CONFIGURED"); - return true; - } - ); + await assert.rejects(cancelSale(TRANSFER_ID), (cause: unknown) => { + assert.ok(cause instanceof SdpPaymentsError); + assert.equal(cause.code, "PROVIDER_NOT_CONFIGURED"); + return true; + }); }); }); @@ -374,7 +369,7 @@ describe("MoonpayRampClient on-ramp limits", () => { const usd = MOONPAY_ONRAMP_CURRENCIES.USD; async function quoteAud(fiatAmount: string) { - return new MoonpayRampClient().createOnrampQuote(runtimeContext, { + return moonpayOnramp.createOnrampQuote(runtimeContext, { ...onrampInput, fiatCurrency: "AUD", fiatAmount, @@ -417,7 +412,7 @@ describe("MoonpayRampClient on-ramp limits", () => { assert.equal(Object.hasOwn(MOONPAY_ONRAMP_CURRENCIES, unlisted), false); await assert.rejects( - new MoonpayRampClient().createOnrampQuote(runtimeContext, { + moonpayOnramp.createOnrampQuote(runtimeContext, { ...onrampInput, // SAFETY: every RampFiatCurrency has a MoonPay row today, so the guard // under test is fed a code from outside the type on purpose. @@ -431,9 +426,8 @@ describe("MoonpayRampClient on-ramp limits", () => { refuseProviderCalls(); const { paymentTransferId, externalCustomerId, destinationWalletAddress, assetRail } = onrampInput; - const client = new MoonpayRampClient(); const quote = (fiatAmount: string) => - client.createOnrampQuote(runtimeContext, { + moonpayOnramp.createOnrampQuote(runtimeContext, { paymentTransferId, externalCustomerId, destinationWalletAddress, @@ -460,62 +454,24 @@ describe("MoonpayRampClient on-ramp limits", () => { }); }); -describe("MoonpayRampClient credentials", () => { - /** Estimates the kit's onramp with `env` in `mode`, counting every request. */ - async function estimateWith(env: Record, mode: "sandbox" | "production") { - assert.ok(MOONPAY_TEST_KIT.onramp, "the MoonPay kit records an onramp"); - const replay = replayExchanges(MOONPAY_TEST_KIT.onramp.estimate.exchanges); - const settled = await RAMP_PROVIDER_CLIENTS.moonpay - .estimateOnramp({ env, mode }, MOONPAY_TEST_KIT.onramp.estimate.input) - .then( - () => ({ ok: true as const }), - (cause: unknown) => ({ ok: false as const, cause }) - ); - return { settled, requests: replay.requests.length + replay.unrecorded.length }; - } - - function refusedBeforeAnyRequest( - result: Awaited>, - message: string - ): void { - assert.equal(result.requests, 0, "no request reaches MoonPay"); - assert.ok(!result.settled.ok, "the estimate is refused"); - assert.ok(result.settled.cause instanceof SdpPaymentsError); - assert.equal(result.settled.cause.code, "PROVIDER_NOT_CONFIGURED"); - assert.equal(result.settled.cause.message, message); - } - - it("refuses production projects, which bring their own keys", async () => { - refusedBeforeAnyRequest( - await estimateWith(MOONPAY_TEST_KIT.env, "production"), - "MoonPay is not configured for production projects." - ); - }); - - it("refuses a live secret key mounted under the sandbox names", async () => { - refusedBeforeAnyRequest( - await estimateWith( - { ...MOONPAY_TEST_KIT.env, MOONPAY_SANDBOX_SECRET_KEY: "sk_live_mounted_by_mistake" }, - "sandbox" - ), - "MoonPay sandbox keys must be test-mode keys." - ); - }); - - it("refuses a live publishable key mounted under the sandbox names", async () => { - refusedBeforeAnyRequest( - await estimateWith( - { ...MOONPAY_TEST_KIT.env, MOONPAY_SANDBOX_API_KEY: "pk_live_mounted_by_mistake" }, - "sandbox" - ), - "MoonPay sandbox keys must be test-mode keys." - ); - }); - - it("refuses when the sandbox keys are missing", async () => { - refusedBeforeAnyRequest( - await estimateWith({ MOONPAY_SANDBOX_API_KEY: "pk_test_contract" }, "sandbox"), - "MoonPay sandbox is not configured. Set MOONPAY_SANDBOX_API_KEY and MOONPAY_SANDBOX_SECRET_KEY." - ); +describe("MoonpayRampClient widget hosts", () => { + it("opens each mode's widget host", async () => { + refuseProviderCalls(); + for (const [mode, credentials, onrampHost, offrampHost] of [ + [ + "sandbox", + MOONPAY_TEST_KIT.credentials, + "buy-sandbox.moonpay.com", + "sell-sandbox.moonpay.com", + ], + ["production", MOONPAY_TEST_KIT.liveCredentials, "buy.moonpay.com", "sell.moonpay.com"], + ] as const) { + const runtime = rampTestRuntime(mode, credentials); + const onramp = await moonpayOnramp.createOnrampQuote(runtime, onrampInput); + const offramp = await moonpayOfframp.createOfframpQuote(runtime, offrampInput); + assert.ok(onramp.deliveryMode === "hosted" && offramp.deliveryMode === "hosted"); + assert.equal(new URL(onramp.hostedUrl).host, onrampHost); + assert.equal(new URL(offramp.hostedUrl).host, offrampHost); + } }); }); diff --git a/packages/sdp-payments/src/ramps/providers/moonpay/client.ts b/packages/sdp-payments/src/ramps/providers/moonpay/client.ts index 6cea445447..a3790f1022 100644 --- a/packages/sdp-payments/src/ramps/providers/moonpay/client.ts +++ b/packages/sdp-payments/src/ramps/providers/moonpay/client.ts @@ -20,10 +20,12 @@ import { } from "#/ramps/fetch"; import { readyCounterparty } from "#/ramps/requirements"; import { + checkRampKeyFormats, isActiveIso4217CurrencyCode, isIso3166Alpha2CountryCode, RAMP_RAIL_DUMPS, - requireEnv, + type RampKeyFormats, + rampCredentials, unreportedCurrencyLimit, } from "#/ramps/shared"; import type { @@ -36,21 +38,39 @@ import type { RampOnrampQuoteInput, RampProvider, RampProviderCancelOutcome, + RampProviderCredentials, RampRuntimeContext, RampTransferLookup, ValidateCounterpartyOptions, } from "#/ramps/types"; import { + isMoonpayCryptoCode, + MOONPAY_CRYPTO_CODE_TO_RAIL, + MOONPAY_CRYPTO_CODES, + type MoonpayCryptoCode, type MoonpaySettlementEvent, moonpayBuyTransactionSchema, moonpaySellTransactionSchema, moonpaySellTransactionSettlementEvent, moonpayTransactionSettlementEvent, + withMoonpaySettledAsset, } from "./event-status-mapping"; const MOONPAY_API_BASE_URL = "https://api.moonpay.com"; -const MOONPAY_SANDBOX_ONRAMP_URL = "https://buy-sandbox.moonpay.com"; -const MOONPAY_SANDBOX_OFFRAMP_URL = "https://sell-sandbox.moonpay.com"; + +/** The widget hosts each mode's keys open. */ +const MOONPAY_WIDGET_URLS = { + sandbox: { + onramp: "https://buy-sandbox.moonpay.com", + offramp: "https://sell-sandbox.moonpay.com", + }, + production: { onramp: "https://buy.moonpay.com", offramp: "https://sell.moonpay.com" }, +} as const satisfies Record>; + +const MOONPAY_KEY_FORMATS = { + sandbox: { publicKey: /^pk_test_/, secretKey: /^sk_test_/ }, + production: { publicKey: /^pk_live_/, secretKey: /^sk_live_/ }, +} as const satisfies RampKeyFormats; /** * The buy widget opens on card and spends the amount it is given, fees @@ -63,13 +83,6 @@ export const MOONPAY_DECLARED_RAIL_SUPPORT = { offramp: { entityTypes: ["individual"] }, } as const satisfies ProviderDeclaredRailSupport; -interface MoonpayConfig { - apiKey: string; - secretKey: string; - onrampUrl: string; - offrampUrl: string; -} - /** The fields `estimateOnramp` reads from a MoonPay buy quote. */ const moonpayBuyQuoteSchema = z.object({ baseCurrencyAmount: z.number(), @@ -100,72 +113,6 @@ const moonpaySellMinimumSchema = z ) .pipe(z.string().regex(/^\d+(\.\d+)?$/)); -/** The env keys SDP's managed MoonPay credentials and widget overrides live under. */ -interface MoonpayManagedEnv { - readonly MOONPAY_SANDBOX_API_KEY?: string; - readonly MOONPAY_SANDBOX_SECRET_KEY?: string; - readonly MOONPAY_ONRAMP_URL?: string; - readonly MOONPAY_OFFRAMP_URL?: string; -} - -const MOONPAY_TEST_API_KEY = /^pk_test_/; -const MOONPAY_TEST_SECRET_KEY = /^sk_test_/; - -/** - * The managed MoonPay keys for `mode`, or why SDP can't use them. SDP holds - * sandbox keys only; a production project brings its own (HOO-1942), so - * production fails closed until it can. Both keys must be test-mode keys, so - * a live key mounted under the sandbox names can never reach MoonPay. - */ -function readMoonpayManagedKeys( - env: MoonpayManagedEnv, - mode: SdpEnvironment -): { ok: true; apiKey: string; secretKey: string } | { ok: false; reason: string } { - if (mode === "production") { - return { ok: false, reason: "MoonPay is not configured for production projects." }; - } - const apiKey = env.MOONPAY_SANDBOX_API_KEY?.trim(); - const secretKey = env.MOONPAY_SANDBOX_SECRET_KEY?.trim(); - if (!apiKey || !secretKey) { - return { - ok: false, - reason: - "MoonPay sandbox is not configured. Set MOONPAY_SANDBOX_API_KEY and MOONPAY_SANDBOX_SECRET_KEY.", - }; - } - if (!MOONPAY_TEST_API_KEY.test(apiKey) || !MOONPAY_TEST_SECRET_KEY.test(secretKey)) { - return { ok: false, reason: "MoonPay sandbox keys must be test-mode keys." }; - } - return { ok: true, apiKey, secretKey }; -} - -/** - * Whether SDP's managed MoonPay keys can serve `mode`: the same check every - * MoonPay call makes before its first request, so availability never disagrees. - */ -export function hasMoonpayManagedKeys(env: MoonpayManagedEnv, mode: SdpEnvironment): boolean { - return readMoonpayManagedKeys(env, mode).ok; -} - -function readMoonpayConfig(env: MoonpayManagedEnv, mode: SdpEnvironment): MoonpayConfig { - const keys = readMoonpayManagedKeys(env, mode); - if (!keys.ok) { - throw providerNotConfigured(keys.reason); - } - - const onrampUrl = env.MOONPAY_ONRAMP_URL ?? MOONPAY_SANDBOX_ONRAMP_URL; - const offrampUrl = env.MOONPAY_OFFRAMP_URL ?? MOONPAY_SANDBOX_OFFRAMP_URL; - - try { - new URL(onrampUrl); - new URL(offrampUrl); - } catch { - throw new SdpPaymentsError("INTERNAL_ERROR", "MoonPay URL configuration is invalid."); - } - - return { apiKey: keys.apiKey, secretKey: keys.secretKey, onrampUrl, offrampUrl }; -} - function requireMoonpayTransferId(paymentTransferId: string | undefined): string { if (!paymentTransferId) { throw new SdpPaymentsError( @@ -205,26 +152,6 @@ async function buildSignedMoonpayWidgetUrl( return url.toString(); } -const MOONPAY_CRYPTO_CODES = ["sol", "usdc_sol", "usdt_sol", "usdg_sol", "pyusd_sol"] as const; -type MoonpayCryptoCode = (typeof MOONPAY_CRYPTO_CODES)[number]; - -const MOONPAY_CRYPTO_CODE_TO_RAIL = { - sol: "sol.solana", - usdc_sol: "usdc.solana", - usdt_sol: "usdt.solana", - usdg_sol: "usdg.solana", - pyusd_sol: "pyusd.solana", -} as const satisfies Record; - -function isMoonpayCryptoCode(value: string): value is MoonpayCryptoCode { - return (MOONPAY_CRYPTO_CODES as readonly string[]).includes(value); -} - -/** The rail a MoonPay crypto code settles on, or null for an asset SDP has no rail for. */ -export function moonpayCryptoRail(code: string): CryptoRailId | null { - return isMoonpayCryptoCode(code) ? MOONPAY_CRYPTO_CODE_TO_RAIL[code] : null; -} - const MOONPAY_RAIL_TO_CODE: ReadonlyMap = new Map( MOONPAY_CRYPTO_CODES.map((code): [CryptoRailId, MoonpayCryptoCode] => [ MOONPAY_CRYPTO_CODE_TO_RAIL[code], @@ -489,9 +416,8 @@ async function lookUpMoonpayTransactions { readonly id = "moonpay"; + readonly label = "MoonPay"; readonly declaredRailSupport = MOONPAY_DECLARED_RAIL_SUPPORT; + checkCredentials(credentials: RampProviderCredentials, mode: SdpEnvironment): string | null { + return checkRampKeyFormats(this.label, MOONPAY_KEY_FORMATS, credentials, mode); + } + validateCounterparty( _counterparty: Counterparty, options: ValidateCounterpartyOptions @@ -530,21 +459,21 @@ export class MoonpayRampClient implements RampProvider { context: RampDiscoveryContext ): Promise { if (!context.offline) { - const { env, fetchJson, writeDump } = context; - const apiKey = requireEnv(env, "MOONPAY_SANDBOX_API_KEY"); - const base = "https://api.moonpay.com"; - + const { credentials, fetchJson, writeDump } = context; + if (!credentials.ok) { + throw providerNotConfigured(credentials.reason); + } await writeDump( RAMP_RAIL_DUMPS.moonpay.currencies.name, await fetchJson( this.id, "GET /v3/currencies?show=all", - `${base}/v3/currencies?show=all&apiKey=${apiKey}` + `${MOONPAY_API_BASE_URL}/v3/currencies?show=all&apiKey=${credentials.credentials.publicKey}` ) ); await writeDump( RAMP_RAIL_DUMPS.moonpay.countries.name, - await fetchJson(this.id, "GET /v3/countries", `${base}/v3/countries`) + await fetchJson(this.id, "GET /v3/countries", `${MOONPAY_API_BASE_URL}/v3/countries`) ); } const [currencies, countries] = await Promise.all([ @@ -555,13 +484,13 @@ export class MoonpayRampClient implements RampProvider { } async estimateOnramp( - { env, mode }: RampRuntimeContext, + ctx: RampRuntimeContext, input: RampEstimateOnrampInput ): Promise { - const config = readMoonpayConfig(env, mode); + const { publicKey } = rampCredentials(this, ctx); const currencyCode = moonpayCryptoCode(input.assetRail); const url = new URL(`${MOONPAY_API_BASE_URL}/v3/currencies/${currencyCode}/buy_quote`); - url.searchParams.set("apiKey", config.apiKey); + url.searchParams.set("apiKey", publicKey); url.searchParams.set("baseCurrencyCode", input.fiatCurrency.toLowerCase()); url.searchParams.set("baseCurrencyAmount", input.fiatAmount); url.searchParams.set("areFeesIncluded", "true"); @@ -604,13 +533,13 @@ export class MoonpayRampClient implements RampProvider { } async estimateOfframp( - { env, mode }: RampRuntimeContext, + ctx: RampRuntimeContext, input: RampEstimateOfframpInput ): Promise { - const config = readMoonpayConfig(env, mode); + const { publicKey } = rampCredentials(this, ctx); const currencyCode = moonpayCryptoCode(input.assetRail); const url = new URL(`${MOONPAY_API_BASE_URL}/v3/currencies/${currencyCode}/sell_quote`); - url.searchParams.set("apiKey", config.apiKey); + url.searchParams.set("apiKey", publicKey); url.searchParams.set("quoteCurrencyCode", input.fiatCurrency.toLowerCase()); url.searchParams.set("baseCurrencyAmount", input.cryptoAmount); const answer = await providerFetch(this.id, url.toString(), { method: "GET" }); @@ -653,16 +582,17 @@ export class MoonpayRampClient implements RampProvider { } async createOnrampQuote( - { env, mode }: RampRuntimeContext, + ctx: RampRuntimeContext, input: RampOnrampQuoteInput ): Promise { const fiatCurrency = input.fiatCurrency ?? "USD"; assertMoonpayOnrampAmount(fiatCurrency, input.fiatAmount); - const config = readMoonpayConfig(env, mode); + const { publicKey, secretKey } = rampCredentials(this, ctx); const quoteId = requireMoonpayTransferId(input.paymentTransferId); - const hostedUrl = await buildSignedMoonpayWidgetUrl(config.onrampUrl, config.secretKey, { - apiKey: config.apiKey, + const widgetUrl = MOONPAY_WIDGET_URLS[ctx.mode].onramp; + const hostedUrl = await buildSignedMoonpayWidgetUrl(widgetUrl, secretKey, { + apiKey: publicKey, baseCurrencyCode: fiatCurrency.toLowerCase(), baseCurrencyAmount: input.fiatAmount, currencyCode: moonpayCryptoCode(input.assetRail), @@ -683,13 +613,14 @@ export class MoonpayRampClient implements RampProvider { } async createOfframpQuote( - { env, mode }: RampRuntimeContext, + ctx: RampRuntimeContext, input: RampOfframpQuoteInput ): Promise { - const config = readMoonpayConfig(env, mode); + const { publicKey, secretKey } = rampCredentials(this, ctx); const quoteId = requireMoonpayTransferId(input.paymentTransferId); - const hostedUrl = await buildSignedMoonpayWidgetUrl(config.offrampUrl, config.secretKey, { - apiKey: config.apiKey, + const widgetUrl = MOONPAY_WIDGET_URLS[ctx.mode].offramp; + const hostedUrl = await buildSignedMoonpayWidgetUrl(widgetUrl, secretKey, { + apiKey: publicKey, baseCurrencyCode: moonpayCryptoCode(input.assetRail), baseCurrencyAmount: input.cryptoAmount, quoteCurrencyCode: (input.fiatCurrency ?? "USD").toLowerCase(), @@ -710,15 +641,15 @@ export class MoonpayRampClient implements RampProvider { /** One request, authorized by the secret key: MoonPay's cancel of the sale held under SDP's id. */ async cancelOfframpSale( - { env, mode }: RampRuntimeContext, + ctx: RampRuntimeContext, transferId: string, signal?: AbortSignal ): Promise { - const config = readMoonpayConfig(env, mode); + const { secretKey } = rampCredentials(this, ctx); const url = `${MOONPAY_API_BASE_URL}${MOONPAY_TRANSFER_LOOKUP_PATHS.offramp}/${encodeURIComponent(transferId)}`; const { response } = await providerFetch(this.id, url, { method: "DELETE", - headers: { Authorization: `Api-Key ${config.secretKey}` }, + headers: { Authorization: `Api-Key ${secretKey}` }, signal, }); if (response.ok) return "canceled"; @@ -738,13 +669,13 @@ export class MoonpayRampClient implements RampProvider { * per attempt, and the settlement guard orders each only against itself. */ async fetchTransferStatus( - { env, mode }: RampRuntimeContext, + ctx: RampRuntimeContext, lookup: RampTransferLookup ): Promise { - const config = readMoonpayConfig(env, mode); + const { publicKey } = rampCredentials(this, ctx); const path = MOONPAY_TRANSFER_LOOKUP_PATHS[lookup.rampType]; const url = new URL(`${MOONPAY_API_BASE_URL}${path}/${encodeURIComponent(lookup.transferId)}`); - url.searchParams.set("apiKey", config.apiKey); + url.searchParams.set("apiKey", publicKey); if (lookup.rampType === "onramp") { const buys = await lookUpMoonpayTransactions(url, lookup.transferId, moonpayBuyLookupSchema); return buys.map((buy) => diff --git a/packages/sdp-payments/src/ramps/providers/moonpay/event-status-mapping.ts b/packages/sdp-payments/src/ramps/providers/moonpay/event-status-mapping.ts index 4ffa49c2ad..8d78aad401 100644 --- a/packages/sdp-payments/src/ramps/providers/moonpay/event-status-mapping.ts +++ b/packages/sdp-payments/src/ramps/providers/moonpay/event-status-mapping.ts @@ -1,3 +1,4 @@ +import type { CryptoRailId } from "@sdp/types/payment-rails"; import type { MoonpayRampSettlement, RampTransferType } from "@sdp/types/payments"; import { z } from "zod"; import { decimalStringFromNumber } from "#/decimal"; @@ -12,6 +13,55 @@ export type MoonpaySettlementEvent = rampType: RampTransferType; } & Exclude); +export const MOONPAY_CRYPTO_CODES = [ + "sol", + "usdc_sol", + "usdt_sol", + "usdg_sol", + "pyusd_sol", +] as const; +export type MoonpayCryptoCode = (typeof MOONPAY_CRYPTO_CODES)[number]; + +export const MOONPAY_CRYPTO_CODE_TO_RAIL = { + sol: "sol.solana", + usdc_sol: "usdc.solana", + usdt_sol: "usdt.solana", + usdg_sol: "usdg.solana", + pyusd_sol: "pyusd.solana", +} as const satisfies Record; + +export function isMoonpayCryptoCode(value: string): value is MoonpayCryptoCode { + return (MOONPAY_CRYPTO_CODES as readonly string[]).includes(value); +} + +/** The rail a MoonPay crypto code settles on, or null for an asset SDP has no rail for. */ +function moonpayCryptoRail(code: string): CryptoRailId | null { + return isMoonpayCryptoCode(code) ? MOONPAY_CRYPTO_CODE_TO_RAIL[code] : null; +} + +/** + * `event` with the rail of the crypto a settled transaction moved, so + * settlement can refuse another asset. The webhook and the status pull both + * read it here. Null when a settled transaction names no crypto: each caller + * says how that fails. + * + * @param event - The transaction's settlement event. + * @param cryptoCode - The MoonPay code of the crypto the transaction moved. + * @returns The event, with `deliveredAssetRail` when it is settled. + */ +export function withMoonpaySettledAsset( + event: MoonpaySettlementEvent, + cryptoCode: string | undefined +): MoonpaySettlementEvent | null { + if (event.kind !== "settled") { + return event; + } + if (cryptoCode === undefined) { + return null; + } + return { ...event, deliveredAssetRail: moonpayCryptoRail(cryptoCode) }; +} + const MOONPAY_TRANSACTION_STATUS = { waitingPayment: "awaiting_payment", pending: "settling", diff --git a/packages/sdp-payments/src/ramps/providers/moonpay/test-kit.ts b/packages/sdp-payments/src/ramps/providers/moonpay/test-kit.ts index 8298000857..1cb457ef3f 100644 --- a/packages/sdp-payments/src/ramps/providers/moonpay/test-kit.ts +++ b/packages/sdp-payments/src/ramps/providers/moonpay/test-kit.ts @@ -180,9 +180,9 @@ export function moonpayLookupExchange( } /** - * Whether a widget URL is on `mode`'s host with `mode`'s key and signature. + * Whether a widget URL is on a sandbox host with the kit's key and signature. * The signature is recomputed here as MoonPay checks it: an HMAC-SHA256 of the - * query without it, keyed by the mode's secret. + * query without it, keyed by the kit's secret. */ function signedWithSandboxKeys(hostedUrl: string): boolean { const url = new URL(hostedUrl); @@ -201,8 +201,8 @@ function signedWithSandboxKeys(hostedUrl: string): boolean { * checked by URL. */ export const MOONPAY_TEST_KIT: RampProviderTestKit = { - env: { MOONPAY_SANDBOX_API_KEY: API_KEY, MOONPAY_SANDBOX_SECRET_KEY: SECRET_KEY }, - managedModes: ["sandbox"], + credentials: { publicKey: API_KEY, secretKey: SECRET_KEY }, + liveCredentials: { publicKey: "pk_live_contract", secretKey: "sk_live_contract" }, onramp: { estimate: { input: { assetRail: "sol.solana", fiatCurrency: "USD", fiatAmount: "50" }, @@ -254,8 +254,8 @@ export const MOONPAY_TEST_KIT: RampProviderTestKit = { events: [], }, }, - usesModeCredential: (request) => new URL(request.url).searchParams.get("apiKey") === API_KEY, - quoteUsesModeCredential: (quote) => + usesCredentials: (request) => new URL(request.url).searchParams.get("apiKey") === API_KEY, + quoteUsesCredentials: (quote) => quote.deliveryMode === "hosted" && signedWithSandboxKeys(quote.hostedUrl), malformedEstimate: { direction: "onramp", diff --git a/packages/sdp-payments/src/ramps/providers/moonpay/transfer-status.test.ts b/packages/sdp-payments/src/ramps/providers/moonpay/transfer-status.test.ts index ae7a04cfce..7205af8a24 100644 --- a/packages/sdp-payments/src/ramps/providers/moonpay/transfer-status.test.ts +++ b/packages/sdp-payments/src/ramps/providers/moonpay/transfer-status.test.ts @@ -6,6 +6,7 @@ import { RAMP_PROVIDER_CLIENTS } from "#/ramps/index"; import { CONTRACT_DELIVERY_SIGNATURE, type RecordedExchange, + rampTestRuntime, replayExchanges, } from "#/ramps/test-kit"; import { @@ -31,10 +32,11 @@ afterEach(() => { async function lookUp(rampType: RampTransferType, status: number, body: RecordedExchange["body"]) { const replay = replayExchanges([moonpayLookupExchange(rampType, status, body)]); const settled = await RAMP_PROVIDER_CLIENTS.moonpay - .fetchTransferStatus( - { env: MOONPAY_TEST_KIT.env, mode: "sandbox" }, - { transferId: TRANSFER_ID, rampType, providerReference: null } - ) + .fetchTransferStatus(rampTestRuntime("sandbox", MOONPAY_TEST_KIT.credentials), { + transferId: TRANSFER_ID, + rampType, + providerReference: null, + }) .then( (events) => ({ ok: true as const, events }), (cause: unknown) => ({ ok: false as const, cause }) @@ -79,7 +81,7 @@ describe("MoonpayRampClient.fetchTransferStatus", () => { }; await RAMP_PROVIDER_CLIENTS.moonpay.fetchTransferStatus( - { env: MOONPAY_TEST_KIT.env, mode: "sandbox" }, + rampTestRuntime("sandbox", MOONPAY_TEST_KIT.credentials), { transferId: TRANSFER_ID, rampType: "offramp", providerReference: null } ); @@ -87,7 +89,7 @@ describe("MoonpayRampClient.fetchTransferStatus", () => { const url = new URL(requested.url); assert.equal(url.pathname, `/v3/sell_transactions/ext/${TRANSFER_ID}`); assert.deepEqual([...url.searchParams.keys()], ["apiKey"]); - assert.equal(url.searchParams.get("apiKey"), MOONPAY_TEST_KIT.env.MOONPAY_SANDBOX_API_KEY); + assert.equal(url.searchParams.get("apiKey"), MOONPAY_TEST_KIT.credentials.publicKey); assert.equal(requested.headers.get("Authorization"), null); }); diff --git a/packages/sdp-payments/src/ramps/providers/stripe/client.test.ts b/packages/sdp-payments/src/ramps/providers/stripe/client.test.ts index 73a3ededd8..b4ed30b47d 100644 --- a/packages/sdp-payments/src/ramps/providers/stripe/client.test.ts +++ b/packages/sdp-payments/src/ramps/providers/stripe/client.test.ts @@ -1,11 +1,19 @@ import assert from "node:assert/strict"; import { afterEach, describe, it } from "node:test"; import { SdpPaymentsError } from "#/errors"; -import { RAMP_PROVIDER_CLIENTS } from "#/ramps/index"; -import { replayExchanges } from "#/ramps/test-kit"; -import { StripeRampClient } from "./client"; +import { rampClientFor } from "#/ramps/index"; +import { rampTestRuntime, replayExchanges } from "#/ramps/test-kit"; +import type { RampOnrampQuoteInput } from "#/ramps/types"; import { STRIPE_SESSION_TERMS, STRIPE_TEST_KIT, stripeSessionExchange } from "./test-kit"; +const stripe = rampClientFor("stripe", "onramp"); +const sandbox = rampTestRuntime("sandbox", STRIPE_TEST_KIT.credentials); + +function kitQuoteInput(): RampOnrampQuoteInput { + assert.ok(STRIPE_TEST_KIT.onramp, "the Stripe kit records an onramp"); + return STRIPE_TEST_KIT.onramp.quote.input; +} + const originalFetch = globalThis.fetch; afterEach(() => { @@ -14,12 +22,8 @@ afterEach(() => { /** Quotes the kit's onramp against a session that echoes `terms`. */ async function quoteAgainst(terms: typeof STRIPE_SESSION_TERMS) { - assert.ok(STRIPE_TEST_KIT.onramp, "the Stripe kit records an onramp"); replayExchanges([stripeSessionExchange(terms)]); - return new StripeRampClient().createOnrampQuote( - { env: STRIPE_TEST_KIT.env, mode: "sandbox" }, - STRIPE_TEST_KIT.onramp.quote.input - ); + return stripe.createOnrampQuote(sandbox, kitQuoteInput()); } /** Asserts the quote failed closed, naming exactly `mismatched` in diagnostics. */ @@ -73,72 +77,79 @@ describe("StripeRampClient session terms", () => { }); }); -describe("StripeRampClient credentials", () => { - /** Estimates the kit's onramp with `env` in `mode`, recording every request. */ - async function estimateWith(env: Record, mode: "sandbox" | "production") { - assert.ok(STRIPE_TEST_KIT.onramp, "the Stripe kit records an onramp"); - const replay = replayExchanges(STRIPE_TEST_KIT.onramp.estimate.exchanges); - const settled = await RAMP_PROVIDER_CLIENTS.stripe - .estimateOnramp({ env, mode }, STRIPE_TEST_KIT.onramp.estimate.input) - .then( - () => ({ ok: true as const }), - (cause: unknown) => ({ ok: false as const, cause }) - ); - return { settled, requests: replay.requests.length + replay.unrecorded.length }; - } - - function refusedBeforeAnyRequest( - result: Awaited>, - message: string - ): void { - assert.equal(result.requests, 0, "no request reaches Stripe"); - assert.ok(!result.settled.ok, "the estimate is refused"); - assert.ok(result.settled.cause instanceof SdpPaymentsError); - assert.equal(result.settled.cause.code, "PROVIDER_NOT_CONFIGURED"); - assert.equal(result.settled.cause.message, message); +describe("StripeRampClient session request", () => { + /** Quotes `input` against a session locked to `currency`, answering with the form it sent. */ + async function sentForm(input: RampOnrampQuoteInput, currency: string) { + let form: URLSearchParams | undefined; + const { body } = stripeSessionExchange({ + ...STRIPE_SESSION_TERMS, + destination_currency: currency, + destination_currencies: [currency], + }); + globalThis.fetch = async (_url, init) => { + form = new URLSearchParams(String(init?.body)); + return Response.json(body); + }; + await stripe.createOnrampQuote(sandbox, input); + assert.ok(form, "the quote sent a session request"); + return form; } - it("refuses production projects, which bring their own keys", async () => { - refusedBeforeAnyRequest( - await estimateWith(STRIPE_TEST_KIT.env, "production"), - "Stripe is not configured for production projects." + it("pre-fills the buyer's identity it was given", async () => { + const form = await sentForm( + { + ...kitQuoteInput(), + customerDetails: { + email: "john@doe.com", + dob: { year: 1990, month: 7, day: 4 }, + address: { state: "CA", country: "US" }, + }, + }, + "usdc" ); + assert.equal(form.get("customer_information[email]"), "john@doe.com"); + assert.equal(form.get("customer_information[dob][year]"), "1990"); + assert.equal(form.get("customer_information[address][state]"), "CA"); }); - it("refuses a live secret key mounted under the sandbox names", async () => { - refusedBeforeAnyRequest( - await estimateWith( - { ...STRIPE_TEST_KIT.env, STRIPE_SANDBOX_SECRET_KEY: "sk_live_mounted_by_mistake" }, - "sandbox" - ), - "Stripe sandbox keys must be test-mode keys." - ); + it("locks sol.solana to Stripe's sol", async () => { + const form = await sentForm({ ...kitQuoteInput(), assetRail: "sol.solana" }, "sol"); + assert.equal(form.get("destination_currency"), "sol"); + assert.deepEqual(form.getAll("destination_currencies[]"), ["sol"]); }); - it("refuses a live publishable key mounted under the sandbox names", async () => { - refusedBeforeAnyRequest( - await estimateWith( - { ...STRIPE_TEST_KIT.env, STRIPE_SANDBOX_PUBLISHABLE_KEY: "pk_live_mounted_by_mistake" }, - "sandbox" - ), - "Stripe sandbox keys must be test-mode keys." - ); + it("refuses a quote it can't send before any request", async () => { + globalThis.fetch = async () => assert.fail("Stripe must not be called"); + for (const [input, message] of [ + [{ ...kitQuoteInput(), fiatCurrency: undefined }, /requires a fiat currency/], + [{ ...kitQuoteInput(), assetRail: "usdt.solana" }, /usdc\.solana and sol\.solana/], + ] as const) { + await assert.rejects(stripe.createOnrampQuote(sandbox, input), (cause: unknown) => { + assert.ok(cause instanceof SdpPaymentsError); + assert.equal(cause.code, "BAD_REQUEST"); + assert.match(cause.message, message); + return true; + }); + } }); +}); - it("refuses when the sandbox keys are missing", async () => { - refusedBeforeAnyRequest( - await estimateWith({ STRIPE_SANDBOX_PUBLISHABLE_KEY: "pk_test_contract" }, "sandbox"), - "Stripe sandbox is not configured. Set STRIPE_SANDBOX_SECRET_KEY and STRIPE_SANDBOX_PUBLISHABLE_KEY." +describe("StripeRampClient credentials", () => { + it("accepts a restricted secret key in either mode", () => { + assert.equal( + stripe.checkCredentials( + { ...STRIPE_TEST_KIT.credentials, secretKey: "rk_test_restricted" }, + "sandbox" + ), + null ); - }); - - it("accepts a restricted test-mode secret key", async () => { - const result = await estimateWith( - { ...STRIPE_TEST_KIT.env, STRIPE_SANDBOX_SECRET_KEY: "rk_test_restricted" }, - "sandbox" + assert.equal( + stripe.checkCredentials( + { ...STRIPE_TEST_KIT.liveCredentials, secretKey: "rk_live_restricted" }, + "production" + ), + null ); - assert.ok(result.settled.ok, "the estimate succeeds"); - assert.equal(result.requests, 1); }); }); @@ -151,9 +162,8 @@ describe("StripeRampClient estimate total", () => { fees: { network_fee_monetary: "0.01", transaction_fee_monetary: "0.80" }, }; - /** Estimates 20 USD of USDC against a Stripe quote set holding `answer`. */ - async function estimateAgainst(answer: Record) { - assert.ok(STRIPE_TEST_KIT.env, "the Stripe kit carries sandbox credentials"); + /** Estimates 20 USD of USDC against a Stripe quote set holding `answers`. */ + async function estimateAgainst(...answers: Record[]) { replayExchanges([ { method: "GET", @@ -166,14 +176,15 @@ describe("StripeRampClient estimate total", () => { source_amount: "20.00", source_currency: "usd", rate_fetched_at: 1719947634, - destination_network_quotes: { solana: [answer] }, + destination_network_quotes: { solana: answers }, }, }, ]); - return RAMP_PROVIDER_CLIENTS.stripe.estimateOnramp( - { env: STRIPE_TEST_KIT.env, mode: "sandbox" }, - { assetRail: "usdc.solana", fiatCurrency: "USD", fiatAmount: "20" } - ); + return stripe.estimateOnramp(sandbox, { + assetRail: "usdc.solana", + fiatCurrency: "USD", + fiatAmount: "20", + }); } it("reports what the buyer pays in all, Stripe's fees on top of the amount", async () => { @@ -184,6 +195,15 @@ describe("StripeRampClient estimate total", () => { it("adds the fees itself when Stripe leaves the total out", async () => { const estimate = await estimateAgainst(quote); + assert.equal(estimate.fees.total, "0.81"); assert.equal(estimate.totalFiatAmount, "20.81"); }); + + it("answers ESTIMATE_NOT_AVAILABLE when Stripe quotes nothing for the asset", async () => { + await assert.rejects(estimateAgainst(), (cause: unknown) => { + assert.ok(cause instanceof SdpPaymentsError); + assert.equal(cause.code, "ESTIMATE_NOT_AVAILABLE"); + return true; + }); + }); }); diff --git a/packages/sdp-payments/src/ramps/providers/stripe/client.ts b/packages/sdp-payments/src/ramps/providers/stripe/client.ts index 96cf8f04dc..9ea1b7d2d6 100644 --- a/packages/sdp-payments/src/ramps/providers/stripe/client.ts +++ b/packages/sdp-payments/src/ramps/providers/stripe/client.ts @@ -10,7 +10,6 @@ import { badRequest, estimateNotAvailable, internalError, - providerNotConfigured, providerUnavailable, SdpPaymentsError, } from "#/errors"; @@ -18,18 +17,21 @@ import { providerFetchJson } from "#/ramps/fetch"; import { readyCounterparty } from "#/ramps/requirements"; import { basicAuthHeader, + checkRampKeyFormats, + type RampKeyFormats, + rampCredentials, UNREPORTED_COUNTRY_SUPPORT, unreportedCurrencyLimit, } from "#/ramps/shared"; import type { ProviderDeclaredRailSupport, ProviderRailSupportDistillation, + RampCustomerDetails, RampDiscoveryContext, - RampEstimateOfframpInput, RampEstimateOnrampInput, - RampOfframpQuoteInput, RampOnrampQuoteInput, RampProvider, + RampProviderCredentials, RampRuntimeContext, RampSettlementEvent, RampTransferLookup, @@ -55,25 +57,11 @@ export const STRIPE_DECLARED_RAIL_SUPPORT = { }, } as const satisfies ProviderDeclaredRailSupport; -interface StripeConfig { - secretKey: string; - publishableKey: string; -} - -export interface StripeCustomerInfo { - email?: string; - firstName?: string; - lastName?: string; - dob?: { year: number; month: number; day: number }; - address?: { - line1?: string; - line2?: string; - city?: string; - state?: string; - postalCode?: string; - country?: string; - }; -} +/** A secret key may be standard (`sk_`) or restricted (`rk_`). */ +const STRIPE_KEY_FORMATS = { + sandbox: { publicKey: /^pk_test_/, secretKey: /^(sk|rk)_test_/ }, + production: { publicKey: /^pk_live_/, secretKey: /^(sk|rk)_live_/ }, +} as const satisfies RampKeyFormats; type StripeFormValue = | string @@ -158,63 +146,6 @@ const stripeOnrampQuotesSchema = z.object({ ), }); -/** The env keys SDP's managed Stripe credentials live under. */ -interface StripeManagedEnv { - readonly STRIPE_SANDBOX_SECRET_KEY?: string; - readonly STRIPE_SANDBOX_PUBLISHABLE_KEY?: string; -} - -/** A test-mode Stripe secret key: standard (`sk_`) or restricted (`rk_`). */ -const STRIPE_TEST_SECRET_KEY = /^(sk|rk)_test_/; -const STRIPE_TEST_PUBLISHABLE_KEY = /^pk_test_/; - -/** - * The managed Stripe keys for `mode`, or why SDP can't use them. SDP holds - * sandbox keys only; a production project brings its own (HOO-1943), so - * production fails closed until it can. Both keys must be test-mode keys, so - * a live key mounted under the sandbox names can never reach Stripe. - */ -function readStripeManagedKeys( - env: StripeManagedEnv, - mode: SdpEnvironment -): { ok: true; config: StripeConfig } | { ok: false; reason: string } { - if (mode === "production") { - return { ok: false, reason: "Stripe is not configured for production projects." }; - } - const secretKey = env.STRIPE_SANDBOX_SECRET_KEY?.trim(); - const publishableKey = env.STRIPE_SANDBOX_PUBLISHABLE_KEY?.trim(); - if (!secretKey || !publishableKey) { - return { - ok: false, - reason: - "Stripe sandbox is not configured. Set STRIPE_SANDBOX_SECRET_KEY and STRIPE_SANDBOX_PUBLISHABLE_KEY.", - }; - } - if ( - !STRIPE_TEST_SECRET_KEY.test(secretKey) || - !STRIPE_TEST_PUBLISHABLE_KEY.test(publishableKey) - ) { - return { ok: false, reason: "Stripe sandbox keys must be test-mode keys." }; - } - return { ok: true, config: { secretKey, publishableKey } }; -} - -/** - * Whether SDP's managed Stripe keys can serve `mode`: the same check every - * Stripe call makes before its first request, so availability never disagrees. - */ -export function hasStripeManagedKeys(env: StripeManagedEnv, mode: SdpEnvironment): boolean { - return readStripeManagedKeys(env, mode).ok; -} - -function readStripeConfig(env: StripeManagedEnv, mode: SdpEnvironment): StripeConfig { - const keys = readStripeManagedKeys(env, mode); - if (!keys.ok) { - throw providerNotConfigured(keys.reason); - } - return keys.config; -} - function normalizeStripeOnrampAsset(cryptoToken: string): { currency: string; network: string } { const normalized = cryptoToken.trim().toLowerCase().replace(/_/g, "."); const separator = normalized.indexOf("."); @@ -257,7 +188,7 @@ function encodeStripeForm(form: StripeForm): URLSearchParams { return params; } -function buildCustomerInformation(info: StripeCustomerInfo | undefined): StripeForm | undefined { +function buildCustomerInformation(info: RampCustomerDetails | undefined): StripeForm | undefined { if (!info) { return undefined; } @@ -356,8 +287,13 @@ async function stripeGet( export class StripeRampClient implements RampProvider { readonly id = "stripe"; + readonly label = "Stripe"; readonly declaredRailSupport = STRIPE_DECLARED_RAIL_SUPPORT; + checkCredentials(credentials: RampProviderCredentials, mode: SdpEnvironment): string | null { + return checkRampKeyFormats(this.label, STRIPE_KEY_FORMATS, credentials, mode); + } + validateCounterparty( _counterparty: Counterparty, options: ValidateCounterpartyOptions @@ -393,14 +329,14 @@ export class StripeRampClient implements RampProvider { } async estimateOnramp( - { env, mode }: RampRuntimeContext, + ctx: RampRuntimeContext, input: RampEstimateOnrampInput ): Promise { - const config = readStripeConfig(env, mode); + const { secretKey } = rampCredentials(this, ctx); const { currency, network } = normalizeStripeOnrampAsset(input.assetRail); const quotes = await stripeGet( - config.secretKey, + secretKey, `${STRIPE_API_BASE_URL}/v1/crypto/onramp/quotes`, { source_amount: input.fiatAmount, @@ -460,25 +396,18 @@ export class StripeRampClient implements RampProvider { }; } - async estimateOfframp( - _ctx: RampRuntimeContext, - _input: RampEstimateOfframpInput - ): Promise { - throw estimateNotAvailable("Stripe supports on-ramp only.", { provider: this.id }); - } - async createOnrampQuote( - { env, mode }: RampRuntimeContext, + ctx: RampRuntimeContext, input: RampOnrampQuoteInput ): Promise { - const config = readStripeConfig(env, mode); + const { secretKey, publicKey } = rampCredentials(this, ctx); const { currency, network } = normalizeStripeOnrampAsset(input.assetRail); if (!input.fiatCurrency) { throw badRequest("Stripe on-ramp requires a fiat currency.", { provider: this.id }); } const session = await stripeRequest( - config.secretKey, + secretKey, `${STRIPE_API_BASE_URL}/v1/crypto/onramp_sessions`, { customer_ip_address: input.customerIpAddress, @@ -492,7 +421,7 @@ export class StripeRampClient implements RampProvider { destination_currencies: [currency], destination_networks: [network], metadata: { externalCustomerId: input.externalCustomerId }, - customer_information: buildCustomerInformation(input.stripeCustomerInfo), + customer_information: buildCustomerInformation(input.customerDetails), }, stripeOnrampSessionSchema ); @@ -530,27 +459,20 @@ export class StripeRampClient implements RampProvider { deliveryMode: "session_widget", clientSecret: session.client_secret, sessionId: id, - publishableKey: config.publishableKey, + publishableKey: publicKey, ...(session.redirect_url ? { redirectUrl: session.redirect_url } : {}), }; } - async createOfframpQuote( - _ctx: RampRuntimeContext, - _input: RampOfframpQuoteInput - ): Promise { - throw badRequest("Stripe off-ramp is not supported.", { provider: this.id }); - } - /** * One request: the onramp session the transfer was quoted with, mapped as * its webhook is. The key picks the mode, so there is no `livemode` to check. */ async fetchTransferStatus( - { env, mode }: RampRuntimeContext, + ctx: RampRuntimeContext, lookup: RampTransferLookup ): Promise { - const config = readStripeConfig(env, mode); + const { secretKey } = rampCredentials(this, ctx); if (lookup.rampType !== "onramp") { throw badRequest("Stripe off-ramp is not supported.", { provider: this.id }); } @@ -561,7 +483,7 @@ export class StripeRampClient implements RampProvider { }); } const session = await stripeGet( - config.secretKey, + secretKey, `${STRIPE_API_BASE_URL}/v1/crypto/onramp_sessions/${encodeURIComponent(sessionId)}`, {}, stripeOnrampSessionStatusSchema diff --git a/packages/sdp-payments/src/ramps/providers/stripe/test-kit.ts b/packages/sdp-payments/src/ramps/providers/stripe/test-kit.ts index ab7f947c88..16e1da39d0 100644 --- a/packages/sdp-payments/src/ramps/providers/stripe/test-kit.ts +++ b/packages/sdp-payments/src/ramps/providers/stripe/test-kit.ts @@ -121,16 +121,10 @@ export function stripeSessionLookupExchange( }; } -/** - * Stripe's contract kit. Stripe runs onramp only, and SDP holds sandbox keys - * only: production projects bring their own, so production fails closed. - */ +/** Stripe's contract kit. Stripe runs onramp only. */ export const STRIPE_TEST_KIT: RampProviderTestKit = { - env: { - STRIPE_SANDBOX_SECRET_KEY: SECRET_KEY, - STRIPE_SANDBOX_PUBLISHABLE_KEY: "pk_test_contract", - }, - managedModes: ["sandbox"], + credentials: { publicKey: "pk_test_contract", secretKey: SECRET_KEY }, + liveCredentials: { publicKey: "pk_live_contract", secretKey: "sk_live_contract" }, onramp: { estimate: { input: { assetRail: "usdc.solana", fiatCurrency: "USD", fiatAmount: FIAT_AMOUNT }, @@ -183,7 +177,7 @@ export const STRIPE_TEST_KIT: RampProviderTestKit = { events: [{ provider: "stripe", kind: "awaiting_payment", reference: "cos_contract" }], }, }, - usesModeCredential: (request) => + usesCredentials: (request) => request.headers.get("Authorization") === basicAuthHeader(SECRET_KEY, ""), malformedEstimate: { direction: "onramp", diff --git a/packages/sdp-payments/src/ramps/providers/stripe/transfer-status.test.ts b/packages/sdp-payments/src/ramps/providers/stripe/transfer-status.test.ts index fdb8b47536..46cbc16d84 100644 --- a/packages/sdp-payments/src/ramps/providers/stripe/transfer-status.test.ts +++ b/packages/sdp-payments/src/ramps/providers/stripe/transfer-status.test.ts @@ -2,7 +2,12 @@ import assert from "node:assert/strict"; import { afterEach, describe, it } from "node:test"; import { SdpPaymentsError, type SdpPaymentsErrorCode } from "#/errors"; import { RAMP_PROVIDER_CLIENTS } from "#/ramps/index"; -import { CONTRACT_DELIVERY_SIGNATURE, CONTRACT_WALLET, replayExchanges } from "#/ramps/test-kit"; +import { + CONTRACT_DELIVERY_SIGNATURE, + CONTRACT_WALLET, + rampTestRuntime, + replayExchanges, +} from "#/ramps/test-kit"; import type { RampTransferLookup } from "#/ramps/types"; import { STRIPE_COMPLETED_SESSION, @@ -30,7 +35,7 @@ async function lookUp( ) { const replay = replayExchanges(exchanges); const settled = await RAMP_PROVIDER_CLIENTS.stripe - .fetchTransferStatus({ env: STRIPE_TEST_KIT.env, mode: "sandbox" }, lookup) + .fetchTransferStatus(rampTestRuntime("sandbox", STRIPE_TEST_KIT.credentials), lookup) .then( (events) => ({ ok: true as const, events }), (cause: unknown) => ({ ok: false as const, cause }) diff --git a/packages/sdp-payments/src/ramps/shared.ts b/packages/sdp-payments/src/ramps/shared.ts index 9bdb806755..0a795e5ed6 100644 --- a/packages/sdp-payments/src/ramps/shared.ts +++ b/packages/sdp-payments/src/ramps/shared.ts @@ -1,3 +1,4 @@ +import type { SdpEnvironment } from "@sdp/types/api-keys"; import { CRYPTO_RAIL_ASSET_LABELS, type CryptoAssetSymbol, @@ -5,7 +6,8 @@ import { type RampCountrySupport, type RampCurrencyLimit, } from "@sdp/types/payment-rails"; -import { internalError } from "#/errors"; +import { internalError, providerNotConfigured } from "#/errors"; +import type { RampProvider, RampProviderCredentials, RampRuntimeContext } from "#/ramps/types"; let icuModernCurrencies: Set | undefined; let countryDisplayNames: Intl.DisplayNames | undefined; @@ -84,12 +86,60 @@ export function isIso3166Alpha2CountryCode(value: string): boolean { return displayName !== undefined && displayName !== normalized; } -export function requireEnv(env: Record, key: string): string { - const value = env[key]?.trim(); - if (!value) { - throw new Error(`Missing required env: ${key}.`); +/** The format of each key in each mode: a provider's keys name the mode they run in. */ +export type RampKeyFormats = Readonly< + Record>> +>; + +const KEY_MODE_NAMES = { + sandbox: "test-mode", + production: "live-mode", +} as const satisfies Record; + +/** + * Every provider's `checkCredentials`: why `credentials` can't serve `mode` + * under `formats`, or null when both keys fit it. + * + * @param label - The provider's name in the refusal. + * @param formats - The provider's key formats by mode. + * @param credentials - The keys to check. + * @param mode - The mode the call runs in. + * @returns The refusal, or null. + */ +export function checkRampKeyFormats( + label: string, + formats: RampKeyFormats, + credentials: RampProviderCredentials, + mode: SdpEnvironment +): string | null { + const format = formats[mode]; + if ( + format.publicKey.test(credentials.publicKey) && + format.secretKey.test(credentials.secretKey) + ) { + return null; + } + return `${label} ${mode} keys must be ${KEY_MODE_NAMES[mode]} keys.`; +} + +/** + * `ctx`'s keys, once `provider` confirms they fit `ctx.mode`. Every provider + * call reads its keys here, so a key from the other mode never makes a request. + * + * @param provider - The provider making the call. + * @param ctx - The call's runtime. + * @returns The keys. + * @throws SdpPaymentsError PROVIDER_NOT_CONFIGURED when they don't fit the mode. + */ +export function rampCredentials( + provider: Pick, + ctx: RampRuntimeContext +): RampProviderCredentials { + const refusal = provider.checkCredentials(ctx.credentials, ctx.mode); + if (refusal !== null) { + throw providerNotConfigured(refusal); } - return value; + return ctx.credentials; } export function basicAuthHeader(username: string, password: string): string { diff --git a/packages/sdp-payments/src/ramps/test-kit.ts b/packages/sdp-payments/src/ramps/test-kit.ts index caaad14b08..396a83ae17 100644 --- a/packages/sdp-payments/src/ramps/test-kit.ts +++ b/packages/sdp-payments/src/ramps/test-kit.ts @@ -1,10 +1,13 @@ import type { SdpEnvironment } from "@sdp/types/api-keys"; import type { PaymentRampQuote, RampDirection } from "@sdp/types/payments"; +import { CLUSTER_BY_SDP_ENVIRONMENT } from "@sdp/types/well-known-tokens"; import type { RampEstimateOfframpInput, RampEstimateOnrampInput, RampOfframpQuoteInput, RampOnrampQuoteInput, + RampProviderCredentials, + RampRuntimeContext, RampSettlementEvent, RampTransferLookup, } from "./types"; @@ -56,10 +59,10 @@ export interface RampTransferStatusCase extends RampKitCase * provider runs has an estimate case and a quote case. */ export interface RampProviderTestKit { - /** The managed credentials, for every mode in `managedModes`. */ - env: Record; - /** The modes SDP holds credentials for. Every other mode must fail closed before any request. */ - managedModes: readonly SdpEnvironment[]; + /** Test-mode keys: the kit's cases run with them in sandbox. */ + credentials: RampProviderCredentials; + /** Live-mode keys: production accepts them, and sandbox refuses them before any request. */ + liveCredentials: RampProviderCredentials; onramp?: { estimate: RampKitCase; quote: RampKitCase; @@ -68,19 +71,30 @@ export interface RampProviderTestKit { estimate: RampKitCase; quote: RampKitCase; }; - /** Status lookups by name, present exactly when the provider has `fetchTransferStatus`. */ - transferStatus?: Readonly>; - /** Whether a request carries the credential the adapter must use for `mode`. */ - usesModeCredential: (request: ReplayedRequest, mode: SdpEnvironment) => boolean; + /** Status lookups by name: every provider has `fetchTransferStatus`. */ + transferStatus: Readonly>; + /** Whether a request carries the kit's test-mode keys. */ + usesCredentials: (request: ReplayedRequest) => boolean; /** - * Whether a quote carries the credential the adapter must use for `mode`. - * Required when the provider builds quotes without a request. + * Whether a quote carries the kit's test-mode keys. Required when the + * provider builds quotes without a request. */ - quoteUsesModeCredential?: (quote: PaymentRampQuote, mode: SdpEnvironment) => boolean; + quoteUsesCredentials?: (quote: PaymentRampQuote) => boolean; /** An estimate reply with one field the adapter reads given the wrong type. */ malformedEstimate: { direction: RampDirection; exchange: RecordedExchange }; } +/** + * The runtime a test call runs under: `mode`, the cluster it picks, and + * `credentials`. + */ +export function rampTestRuntime( + mode: SdpEnvironment, + credentials: RampProviderCredentials +): RampRuntimeContext { + return { mode, cluster: CLUSTER_BY_SDP_ENVIRONMENT[mode], credentials }; +} + /** The live state of one replay. */ export interface Replay { /** Requests the kit answered, in order. */ diff --git a/packages/sdp-payments/src/ramps/types.ts b/packages/sdp-payments/src/ramps/types.ts index 06a380325d..0b1d844019 100644 --- a/packages/sdp-payments/src/ramps/types.ts +++ b/packages/sdp-payments/src/ramps/types.ts @@ -15,13 +15,14 @@ import type { PaymentRampEstimate, PaymentRampQuote, RampCryptoDeposit, + RampDirection, RampTransferSettlement, RampTransferType, } from "@sdp/types/payments"; import type { RampProviderId } from "@sdp/types/provider-access"; import type { CollectedFieldData, CounterpartyRequirements } from "@sdp/types/ramp-requirements"; +import type { SolanaCluster } from "@sdp/types/well-known-tokens"; import { z } from "zod"; -import type { StripeCustomerInfo } from "./providers/stripe/client"; export interface ProviderDirectionSupportSnapshot { currencies: Readonly>; @@ -99,8 +100,26 @@ export type RampFetchText = ( export type RampDumpWriter = (name: string, payload: RampDiscoveryResponseDump) => Promise; export type RampRawDumpReader = (relativePath: string) => Promise; +/** + * The keys of the provider account a call runs on: a public identifier and a + * secret, the pair every ramp account SDP integrates uses. A provider whose + * account has another shape adds an optional field here. + */ +export interface RampProviderCredentials { + /** Identifies the account: a publishable key, an API key or a client id. */ + publicKey: string; + /** Authenticates the account's calls and signs its widget URLs. */ + secretKey: string; +} + +/** An account's keys for a mode, or why none can serve it. */ +export type RampCredentialsResolution = + | { ok: true; credentials: RampProviderCredentials } + | { ok: false; reason: string }; + export interface RampDiscoveryContext { - env: Record; + /** SDP's sandbox keys for the provider, or why it has none. */ + credentials: RampCredentialsResolution; fetchJson: RampFetchJson; fetchText: RampFetchText; writeDump: RampDumpWriter; @@ -109,14 +128,6 @@ export interface RampDiscoveryContext { offline: boolean; } -export interface RampWebhookValidationContext { - env: Record; - environment: SdpEnvironment; - headers: Headers; - rawBody: string; - requestUrl?: string; -} - export interface RampOnchainTransfer { signature: string; sourceAddress?: string; @@ -209,13 +220,14 @@ export function isAuditedRampSettlementKind( } /** - * Runtime context for quote/execute calls. Providers read their own credentials - * from `env` keyed by `mode`; the route handler resolves `mode` (it depends on - * AppContext) and passes plain values so the provider stays AppContext-free. + * What one provider call runs under: the project's mode, the cluster that mode + * picks, and the provider account's keys for it. The API resolves all three, + * so an adapter never sees the process environment (ADR 0008). */ export interface RampRuntimeContext { - env: Record; mode: SdpEnvironment; + cluster: SolanaCluster; + credentials: RampProviderCredentials; } export interface RampEstimateOnrampInput { @@ -230,6 +242,22 @@ export interface RampEstimateOfframpInput { cryptoAmount: string; } +/** What the caller knows about the buyer, for a provider widget to pre-fill. */ +export interface RampCustomerDetails { + email?: string; + firstName?: string; + lastName?: string; + dob?: { year: number; month: number; day: number }; + address?: { + line1?: string; + line2?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; +} + export interface RampOnrampQuoteInput { assetRail: CryptoRailId; fiatCurrency?: RampFiatCurrency; @@ -241,8 +269,8 @@ export interface RampOnrampQuoteInput { paymentTransferId?: string; /** End-user IP forwarded for the provider's geo/fraud checks (Stripe). */ customerIpAddress?: string; - /** Identity pre-fill for the embedded on-ramp widget (Stripe). */ - stripeCustomerInfo?: StripeCustomerInfo; + /** Identity pre-fill for an embedded on-ramp widget (Stripe). */ + customerDetails?: RampCustomerDetails; } export interface RampOfframpQuoteInput { @@ -287,28 +315,81 @@ export interface RampTransferLookup { providerReference: string | null; } +/** The members a provider needs to run an on-ramp. */ +export interface RampOnrampMembers { + estimateOnramp: ( + ctx: RampRuntimeContext, + input: RampEstimateOnrampInput + ) => Promise; + createOnrampQuote: ( + ctx: RampRuntimeContext, + input: RampOnrampQuoteInput + ) => Promise; +} + +/** The members a provider needs to run an off-ramp. */ +export interface RampOfframpMembers { + estimateOfframp: ( + ctx: RampRuntimeContext, + input: RampEstimateOfframpInput + ) => Promise; + createOfframpQuote: ( + ctx: RampRuntimeContext, + input: RampOfframpQuoteInput + ) => Promise; +} + +/** Each direction's members, by direction. */ +export interface RampDirectionMembers { + onramp: RampOnrampMembers; + offramp: RampOfframpMembers; +} + +/** The member names each direction needs, so `supportsRampDirection` and the contract suite agree. */ +export const RAMP_DIRECTION_MEMBERS = { + onramp: ["estimateOnramp", "createOnrampQuote"], + offramp: ["estimateOfframp", "createOfframpQuote"], +} as const satisfies { + readonly [Direction in RampDirection]: readonly (keyof RampDirectionMembers[Direction])[]; +}; + /** * Full provider contract: rail discovery plus the runtime quote/execute flow. * All HTTP lives behind this; the route handler owns DB interaction and passes - * pre-resolved inputs. + * pre-resolved inputs. Members are function-typed, so an implementation is + * checked strictly. A provider that does not run a direction omits that + * direction's members. + * + * @typeParam Event - The settlement events its status pull answers, as its + * webhook processor reads them. */ -export interface RampProvider { - id: RampProviderId; - declaredRailSupport: ProviderDeclaredRailSupport; +export interface RampProvider + extends Partial, + Partial { + readonly id: RampProviderId; + /** The provider's name as people read it, in SDP's messages. */ + readonly label: string; + readonly declaredRailSupport: ProviderDeclaredRailSupport; + /** + * Why `credentials` can't serve `mode`, or null when they can. A key's + * format names the mode it runs in, so a live key never reaches a sandbox + * call. Pure: makes no request. + */ + checkCredentials: (credentials: RampProviderCredentials, mode: SdpEnvironment) => string | null; /** Optional webhook contract. Ingress verifies signed bytes before persisting; * parsing is pure and runs from the durable inbox after acknowledgement. */ webhook?: RampProviderWebhook; /** - * Optional status pull, for a sweep that recovers missed webhooks. Answers - * the events the provider's webhook would carry for each transaction it - * holds for the transfer, oldest update first, and none when it holds no + * Status pull, for the sweep that recovers missed webhooks. Answers the + * events the provider's webhook would carry for each transaction it holds + * for the transfer, oldest update first, and none when it holds no * transaction. Throws when SDP could not ask, so a failure never reads as * absence. */ - fetchTransferStatus?: ( + fetchTransferStatus: ( ctx: RampRuntimeContext, lookup: RampTransferLookup - ) => Promise; + ) => Promise; /** * Optional: cancels the sale the provider holds under SDP's transfer id, * once SDP has canceled its own record. `not_found` when the customer never @@ -325,27 +406,24 @@ export interface RampProvider { * persists them with `context.writeDump` (skipped when `context.offline`), * then re-reads the dumps and distills them into the currency/rail snapshot. */ - discoverCurrencyAndRails(context: RampDiscoveryContext): Promise; - estimateOnramp( - ctx: RampRuntimeContext, - input: RampEstimateOnrampInput - ): Promise; - estimateOfframp( - ctx: RampRuntimeContext, - input: RampEstimateOfframpInput - ): Promise; - createOnrampQuote?( - ctx: RampRuntimeContext, - input: RampOnrampQuoteInput - ): Promise; - createOfframpQuote( - ctx: RampRuntimeContext, - input: RampOfframpQuoteInput - ): Promise; - validateCounterparty( + discoverCurrencyAndRails: ( + context: RampDiscoveryContext + ) => Promise; + validateCounterparty: ( counterparty: Counterparty, options: ValidateCounterpartyOptions - ): CounterpartyRequirements; + ) => CounterpartyRequirements; +} + +/** + * Whether `provider` runs `direction`: it has every member the direction + * needs. Narrows the provider to those members. + */ +export function supportsRampDirection( + provider: RampProvider, + direction: Direction +): provider is RampProvider & RampDirectionMembers[Direction] { + return RAMP_DIRECTION_MEMBERS[direction].every((member) => provider[member] !== undefined); } export interface RampProviderWebhook { diff --git a/scripts/secret-keys.mjs b/scripts/secret-keys.mjs index 3455aa339a..e6b90e3df2 100644 --- a/scripts/secret-keys.mjs +++ b/scripts/secret-keys.mjs @@ -129,8 +129,6 @@ export const API_LOCAL_ENV_KEYS = [ "PRIVATE_CHANNELS_ENABLED", "PRIVATE_CHANNEL_EGRESS_ALLOWLIST", "HELIUS_RINGS_ENABLED", - "MOONPAY_ONRAMP_URL", - "MOONPAY_OFFRAMP_URL", "MOONPAY_SANDBOX_API_KEY", "MOONPAY_SANDBOX_SECRET_KEY", "MOONPAY_SANDBOX_WEBHOOK_KEY", diff --git a/turbo.json b/turbo.json index ead342a112..ddf1de2369 100644 --- a/turbo.json +++ b/turbo.json @@ -149,8 +149,6 @@ "UTILA_POLL_INTERVAL_MS", "UTILA_MAX_POLL_ATTEMPTS", "UTILA_DESIGNATED_SIGNERS", - "MOONPAY_ONRAMP_URL", - "MOONPAY_OFFRAMP_URL", "MOONPAY_SANDBOX_API_KEY", "MOONPAY_SANDBOX_SECRET_KEY", "MOONPAY_SANDBOX_WEBHOOK_KEY",