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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .agents/skills/integrate-estimate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaymentRampEstimate>
estimateOfframp(ctx: RampRuntimeContext, input: RampEstimateOfframpInput): Promise<PaymentRampEstimate>
estimateOnramp: (ctx: RampRuntimeContext, input: RampEstimateOnrampInput) => Promise<PaymentRampEstimate>;
estimateOfframp: (ctx: RampRuntimeContext, input: RampEstimateOfframpInput) => Promise<PaymentRampEstimate>;
```

Inputs (`packages/sdp-payments/src/ramps/types.ts`):
Expand All @@ -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`.

Expand Down
8 changes: 4 additions & 4 deletions .agents/skills/integrate-ramp-provider/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/<id>/*-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<…>`.
Expand Down
5 changes: 3 additions & 2 deletions .agents/skills/integrate-webhook/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ The same typed processor handles the background apply and replay.

## verify

`verify(context: RampWebhookValidationContext): Promise<string>`. `context` =
`{ env, environment, headers, rawBody, requestUrl? }`. Read the mode-keyed
`verify(context: RampWebhookValidationContext): Promise<string>` (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.
Expand Down
11 changes: 7 additions & 4 deletions .agents/skills/rail-discovery/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderRailSupportDistillation> {
if (!context.offline) {
const apiKey = requireEnv(context.env, "<PROVIDER>_SANDBOX_API_KEY");
if (!context.credentials.ok) {
throw providerNotConfigured(context.credentials.reason);
}
const { publicKey } = context.credentials.credentials;
await context.writeDump(
RAMP_RAIL_DUMPS.<id>.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 distill<Id>RailSupport(await context.readDump(RAMP_RAIL_DUMPS.<id>.currencies.file));
Expand Down Expand Up @@ -138,7 +141,7 @@ Commit `.ramp-support/<id>.currency.json` and the regenerated `ramp.generated.ts

- HTTP only in `discoverCurrencyAndRails` — no DB, no business logic.
- `distill<Id>RailSupport` 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.
25 changes: 8 additions & 17 deletions .agents/skills/register-provider/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,36 +38,27 @@ export class <Id>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/<id>/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, <ID>_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/<id>/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/<id>/<x>-status-mapping`); `packages/sdp-payments/src/ramps/boundaries.test.ts` enforces it.

## 3. Wire API admission and orchestration

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("<id>", [...])`, listing any key it needs beyond its account keys (Stripe: its webhook secret) |
| `apps/sdp-api/src/services/provider-setup-registry.ts` | add `rampSetup("<id>")`; 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/<id>.ts` |
| `apps/sdp-api/src/routes/payments/ramps/{onramp,offramp}/handlers.ts` + `ramps/shared.ts` | add the quote case per direction, calling `rampClientFor("<id>", direction)` with `rampRuntime(c, "<id>")`, and `advanceCounterpartyRequirements` branches in `shared.ts`; unsupported directions reject explicitly; keep DB work here or in `ramps/providers/<id>.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, ["<PROVIDER>_KEY", "<PROVIDER>_SECRET"]);
const sandbox = hasAllEnv(env, ["<PROVIDER>_SANDBOX_KEY", "<PROVIDER>_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.

Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion apps/sdp-api/scripts/ramp-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -746,7 +747,7 @@ async function discoverProviderRails(
offline: boolean
): Promise<RailDiscoveryAttempt> {
const distillation = await RAMP_PROVIDER_CLIENTS[provider].discoverCurrencyAndRails({
env: process.env,
credentials: resolveManagedRampCredentials(process.env, provider, "sandbox"),
fetchJson,
fetchText,
writeDump: writeCurrencySupportDump,
Expand Down
2 changes: 1 addition & 1 deletion apps/sdp-api/src/lib/places/google.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading
Loading