diff --git a/docs/API_KEYS.md b/docs/API_KEYS.md index 6f6d90c5..9ed3a4e0 100644 --- a/docs/API_KEYS.md +++ b/docs/API_KEYS.md @@ -28,9 +28,9 @@ fails CI if the catalogue, the call sites and this document disagree. The **Enforcement** column says whether the scope actually gates anything: -| Enforcement | Meaning | -| --- | --- | -| `enforced` | At least one route requires it. Granting it is meaningful; omitting it is restrictive. | +| Enforcement | Meaning | +| --------------- | ----------------------------------------------------------------------------------------------------- | +| `enforced` | At least one route requires it. Granting it is meaningful; omitting it is restrictive. | | `declared-only` | Declared and reservable, but no route requires it today — granting it currently grants nothing extra. | The **Implies** column lists the scopes a scope grants on its own. `super` @@ -44,7 +44,7 @@ implies every admin scope, and the legacy `agent` implies `agent:read` and | --- | --- | --- | --- | --- | --- | --- | | `loans:read` | read | enforced | — | Read loan state and borrowing capacity. | — | `GET /api/v1/loans`, `GET /api/v1/loans/:loanId`, `GET /api/v1/loans/capacity` | | `portfolio:read` | read | enforced | — | Read portfolio positions, balances, history, earnings and tax reports. | — | `GET /api/v1/portfolio/:userId`, `GET /api/v1/portfolio/:userId/earnings`, `GET /api/v1/portfolio/:userId/history`, `GET /api/v1/portfolio/:userId/suggestions`, `GET /api/v1/portfolio/:userId/tax-report` | -| `transactions:read` | read | enforced | — | Read transaction history. | — | `GET /api/v1/transactions/:id/events`, `GET /api/v1/transactions/:userId`, `GET /api/v1/transactions/detail/:txHash` | +| `transactions:read` | read | enforced | — | Read transaction history. | — | `GET /api/v1/accounting/export`, `GET /api/v1/transactions/:id/events`, `GET /api/v1/transactions/:userId`, `GET /api/v1/transactions/detail/:txHash` | | `vault:read` | read | enforced | — | Read vault state and balance. | — | `GET /api/v1/vault/balance` | | `alerts:manage` | write | enforced | — | Manage alert rules. | — | `DELETE /api/v1/alerts/:id`, `PATCH /api/v1/alerts/:id`, `POST /api/v1/alerts`, `POST /api/v1/alerts/:id/restore` | | `goals:write` | write | enforced | — | Manage savings goals. | — | `DELETE /api/v1/portfolio/goals/:id`, `PATCH /api/v1/portfolio/goals/:id`, `POST /api/v1/portfolio/goals` | @@ -69,13 +69,13 @@ New keys are **read-only by default**. Write scopes must be explicitly requested | `agent:read` | read | enforced | — | Read agent decisions and circuit-breaker state. | — | `GET /api/v1/admin/agent/breakers`, `GET /api/v1/admin/agent/decisions` | | `dlq:read` | read | enforced | — | Inspect the dead-letter queue. | — | `GET /api/v1/admin/dlq/inspect`, `GET /api/v1/admin/notifications/dlq` | | `erasure:read` | read | declared-only | — | Preview what an erasure would delete. | No route requires this scope today — the erasure preview route is gated by the generic read scope. Declared so a key can be provisioned ahead of a route that needs it. | — | -| `fiat:read` | read | enforced | — | Read fiat providers and their health. | — | `GET /api/v1/admin/fiat/providers` | +| `fiat:read` | read | enforced | — | Read fiat providers and their health. | — | `GET /api/v1/admin/fiat/limit-policies`, `GET /api/v1/admin/fiat/providers` | | `flags:read` | read | enforced | — | Read feature flags and rollout state. | — | `GET /api/v1/admin/feature-flags`, `GET /api/v1/admin/feature-flags/:key`, `GET /api/v1/admin/feature-flags/history` | | `keys:read` | read | enforced | — | Read admin API keys and wallet rotation status. | — | `GET /api/v1/admin/keys`, `GET /api/v1/admin/wallets/rotation-status` | | `messages:read` | read | enforced | — | Read message delivery state. | — | `GET /api/v1/admin/messages`, `GET /api/v1/admin/messages/:id`, `GET /api/v1/admin/messages/stats` | | `metrics:read` | read | enforced | — | Read operational metrics and dashboards. | — | `GET /api/v1/admin/jobs/dashboard`, `GET /api/v1/admin/stellar/metrics` | | `outbox:read` | read | enforced | — | Read the durable outbox. | — | `GET /api/v1/admin/outbox`, `GET /api/v1/admin/outbox/:id`, `GET /api/v1/admin/outbox/stats` | -| `read` | read | enforced | — | General read access to admin resources. | — | `GET /api/v1/admin/erasure/preview/:userId`, `GET /api/v1/admin/erasure/requests`, `GET /api/v1/admin/referrals/review` | +| `read` | read | enforced | — | General read access to admin resources. | — | `GET /api/v1/admin/audit/export`, `GET /api/v1/admin/erasure/preview/:userId`, `GET /api/v1/admin/erasure/requests`, `GET /api/v1/admin/referrals/review` | | `referrals:read` | read | enforced | — | Read flagged referral conversions. | — | `GET /api/v1/admin/referrals/flagged`, `GET /api/v1/admin/referrals/fraud/calibration`, `GET /api/v1/admin/referrals/fraud/queue` | | `reserves:read` | read | enforced | — | Read reserve and sponsorship state. | — | `GET /api/v1/admin/reserves` | | `risk-metadata:read` | read | enforced | — | Read protocol risk metadata. | `GET /api/v1/admin/risk-metadata` lists entries with a stale flag. | `GET /api/v1/admin/risk-metadata` | @@ -97,8 +97,8 @@ New keys are **read-only by default**. Write scopes must be explicitly requested | `agent` | operational | declared-only | `agent:read`, `agent:write` | Agent loop operations. | — | — | | `agent:write` | operational | enforced | — | Change agent circuit breakers. | — | `POST /api/v1/admin/agent/breakers`, `POST /api/v1/admin/agent/breakers/:id/reset` | | `backfill:write` | operational | enforced | — | Run Stellar event backfills. | — | `POST /api/v1/admin/stellar/backfill` | -| `dlq:write` | operational | enforced | — | Replay, retry and resolve dead-lettered work. | — | `POST /api/v1/admin/dlq/replay`, `POST /api/v1/admin/dlq/resolve`, `POST /api/v1/admin/dlq/retry`, `POST /api/v1/admin/notifications/dlq/:id/retry` | -| `fiat:write` | operational | enforced | — | Change fiat provider failover state. | — | `POST /api/v1/admin/fiat/providers/:name/failover` | +| `dlq:write` | operational | enforced | — | Replay, retry and resolve dead-lettered work. | — | `POST /api/v1/admin/dlq/discard`, `POST /api/v1/admin/dlq/replay`, `POST /api/v1/admin/dlq/requeue`, `POST /api/v1/admin/dlq/resolve`, `POST /api/v1/admin/dlq/retry`, `POST /api/v1/admin/notifications/dlq/:id/retry` | +| `fiat:write` | operational | enforced | — | Change fiat provider failover state. | — | `POST /api/v1/admin/fiat/providers/:name/failover`, `POST /api/v1/admin/users/:id/kyc-tier`, `PUT /api/v1/admin/fiat/limit-policies` | | `flags:write` | operational | enforced | — | Manage feature flags, staged rollouts and emergency rollbacks. | — | `POST /api/v1/admin/feature-flags/:key/reset`, `POST /api/v1/admin/feature-flags/:key/rollback`, `POST /api/v1/admin/feature-flags/:key/staged-rollout`, `POST /api/v1/admin/feature-flags/emergency-disable-all`, `PUT /api/v1/admin/feature-flags/:key` | | `messages:write` | operational | enforced | — | Retry and recover failed message deliveries. | — | `DELETE /api/v1/admin/messages/:id`, `POST /api/v1/admin/messages/:id/retry`, `POST /api/v1/admin/messages/retry-dead-letters` | | `risk-metadata:write` | operational | enforced | — | Review and publish protocol risk metadata. | `PUT /api/v1/admin/risk-metadata/:protocolName` upserts an entry; each change writes a history row and an admin audit log row. | `PUT /api/v1/admin/risk-metadata/:protocolName` | @@ -112,21 +112,21 @@ New keys are **read-only by default**. Write scopes must be explicitly requested ## Management endpoints (session auth only) -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/v1/keys` | Create key (returns secret once) | -| `GET` | `/api/v1/keys` | List keys (metadata only) | -| `DELETE` | `/api/v1/keys/:id` | Revoke key | -| `POST` | `/api/v1/keys/:id/rotate` | Rotate secret | -| `GET` | `/api/v1/keys/:id/usage` | Usage metadata | +| Method | Path | Description | +| -------- | ------------------------- | -------------------------------- | +| `POST` | `/api/v1/keys` | Create key (returns secret once) | +| `GET` | `/api/v1/keys` | List keys (metadata only) | +| `DELETE` | `/api/v1/keys/:id` | Revoke key | +| `POST` | `/api/v1/keys/:id/rotate` | Rotate secret | +| `GET` | `/api/v1/keys/:id/usage` | Usage metadata | An API key **cannot** create, rotate, or revoke other keys. ## Scope catalogue endpoint (admin auth) -| Method | Path | Description | Scope | -|--------|------|-------------|-------| -| `GET` | `/api/v1/admin/keys/scopes` | The machine-readable scope catalogue, with the endpoints each scope covers | `keys:read` | +| Method | Path | Description | Scope | +| ------ | --------------------------- | -------------------------------------------------------------------------- | ----------- | +| `GET` | `/api/v1/admin/keys/scopes` | The machine-readable scope catalogue, with the endpoints each scope covers | `keys:read` | The response carries both scope families: @@ -135,7 +135,11 @@ The response carries both scope families: "success": true, "data": { "user": { - "defaultReadScopes": ["portfolio:read", "transactions:read", "vault:read"], + "defaultReadScopes": [ + "portfolio:read", + "transactions:read", + "vault:read", + ], "scopes": [ { "scope": "withdraw:write", @@ -144,14 +148,14 @@ The response carries both scope families: "grants": "Submits withdrawals to the durable outbox for on-chain settlement.", "enforcement": "enforced", "requires": "Requires allowWithdrawals on the key AND the platform kill-switch USER_API_KEY_WITHDRAWALS_ENABLED to be on.", - "endpoints": ["POST /api/v1/withdraw"] - } - ] + "endpoints": ["POST /api/v1/withdraw"], + }, + ], }, "admin": { - "scopes": [/* the same shape, for admin key scopes */] - } - } + "scopes": [/* the same shape, for admin key scopes */], + }, + }, } ``` @@ -160,13 +164,13 @@ claim to unlock an endpoint that stopped requiring it. ## Errors -| Status | Error | Meaning | -|--------|-------|---------| -| 401 | `key_expired` | Key past `expiresAt` | -| 401 | `Invalid or revoked API key` | Bad token or revoked | -| 403 | `insufficient_scope` | Missing required scope | -| 403 | Session authentication required | API key used on session-only endpoint | -| 409 | Maximum active API keys reached | Per-user cap exceeded | +| Status | Error | Meaning | +| ------ | ------------------------------- | ------------------------------------- | +| 401 | `key_expired` | Key past `expiresAt` | +| 401 | `Invalid or revoked API key` | Bad token or revoked | +| 403 | `insufficient_scope` | Missing required scope | +| 403 | Session authentication required | API key used on session-only endpoint | +| 409 | Maximum active API keys reached | Per-user cap exceeded | ## Notifications @@ -174,7 +178,7 @@ Every create/revoke/rotate emits `security.api_key_changed` on the real-time ale ## Configuration -| Variable | Default | Description | -|----------|---------|-------------| -| `USER_API_KEY_MAX_ACTIVE` | 10 | Max active keys per user | -| `USER_API_KEY_WITHDRAWALS_ENABLED` | true | Platform withdrawal kill-switch | +| Variable | Default | Description | +| ---------------------------------- | ------- | ------------------------------- | +| `USER_API_KEY_MAX_ACTIVE` | 10 | Max active keys per user | +| `USER_API_KEY_WITHDRAWALS_ENABLED` | true | Platform withdrawal kill-switch | diff --git a/docs/FIAT_LIMITS.md b/docs/FIAT_LIMITS.md new file mode 100644 index 00000000..575fd935 --- /dev/null +++ b/docs/FIAT_LIMITS.md @@ -0,0 +1,72 @@ +# Fiat Transaction Limits, Velocity Controls & KYC-Tier Enforcement (#362) + +This document describes the platform-side fiat transaction limits, rolling velocity controls, and KYC-tier enforcement engine (`src/fiat/limits.ts`). + +--- + +## 1. KYC Tiers & Default Policy Table + +Every user has a `KycProfile` (`userId`, `tier`, `providerRef`, `verifiedAt`, `reviewNote`) defaulting to `TIER_0` until verified by a signed provider callback or an audited admin override. + +| Tier | Per-Txn Max (On-Ramp / Off-Ramp) | Rolling 24h Daily Max (On-Ramp / Off-Ramp) | Rolling 30d Monthly Max (On-Ramp / Off-Ramp) | Max Txns / 1h (On-Ramp / Off-Ramp) | Max Txns / 24h (On-Ramp / Off-Ramp) | Base Currency | +| :------- | :------------------------------- | :----------------------------------------- | :------------------------------------------- | :--------------------------------- | :---------------------------------- | :------------ | +| `TIER_0` | $0 / $0 | $0 / $0 | $0 / $0 | 0 / 0 | 0 / 0 | `USD` | +| `TIER_1` | $1,000 / $500 | $2,500 / $1,000 | $10,000 / $5,000 | 3 / 2 | 10 / 5 | `USD` | +| `TIER_2` | $10,000 / $5,000 | $25,000 / $15,000 | $100,000 / $50,000 | 5 / 4 | 20 / 10 | `USD` | +| `TIER_3` | $50,000 / $25,000 | $100,000 / $50,000 | $500,000 / $250,000 | 10 / 6 | 50 / 25 | `USD` | + +Operators can inspect and tune these limits at runtime via `GET /api/v1/admin/fiat/limit-policies` and `PUT /api/v1/admin/fiat/limit-policies` (persisted in `fiat_limit_policies` and recorded in `admin_audit_logs`). + +--- + +## 2. Evaluation Order & Invariants (`evaluateFiatOrder`) + +Every path that creates a `FiatOrder` — manual `POST /api/v1/fiat/orders`, scheduled fiat purchase jobs, and scheduled fiat payout jobs — calls `evaluateFiatOrder` first. Checks run in deterministic priority order: + +1. **Compliance Freeze (`compliance_freeze`)**: + - Checked **before** any tier or limit calculation. If the user is inactive (`User.isActive === false`) or has an active `#321` `ComplianceCase` (`OPEN`, `TRIAGE`, `INVESTIGATING`, `ESCALATED`, `PENDING_SAR`), the order is denied immediately with `reason: 'compliance_freeze'`. +2. **Tier Zero (`tier_zero`)**: + - Unverified (`TIER_0`) accounts have $0 caps and are denied with `reason: 'tier_zero'`. +3. **FX Conversion to Base Currency (`fx_unavailable`)**: + - Policy caps are denominated in `policy.currency` (default `USD`). Cross-currency orders are converted via the FX feed (`rateSource: 'FX_FEED'`, never assumed `1.0`). + - If the FX feed is down or a currency pair cannot be priced, cross-currency checks **fail closed** (`reason: 'fx_unavailable'`). +4. **Per-Transaction Cap (`per_txn`)**: + - Denies orders where `baseAmount > perTxnMax`. +5. **Rolling 1-Hour Velocity (`velocity_hourly`)**: + - Counts counting orders in `(now - 1h, now]`. Denies when `count + 1 > maxTxnsPerHour` and returns `retryAfter` (seconds until the oldest order in the 1h window ages out). +6. **Rolling 24-Hour Velocity (`velocity_daily`)**: + - Counts counting orders in `(now - 24h, now]`. Denies when `count + 1 > maxTxnsPerDay`. +7. **Rolling 24-Hour Volume (`daily`)**: + - Sums base-currency volume in `(now - 24h, now]`. Denies when `used + baseAmount > dailyMax`. +8. **Rolling Monthly Volume (`monthly`)**: + - Sums base-currency volume in the rolling 30-day window (or UTC calendar month when configured). Denies when `used + baseAmount > monthlyMax`. + +--- + +## 3. Pending-Order Headroom & Refund / Failure Release Semantics + +- **Pending Headroom Reservation**: + - Orders in `PENDING`, `PROCESSING`, and `SETTLED` states all reserve headroom immediately upon creation (`createdAt`). A burst of concurrent order creations cannot bypass velocity or volume caps before provider webhooks arrive. +- **Headroom Release on `FAILED` / `REFUNDED`**: + - When a `PENDING` order transitions to `FAILED` (provider webhook or stale-order age-out), or a `SETTLED` order transitions to `REFUNDED`, its headroom is released **as of the failure/refund timestamp** (`refundedAt ?? updatedAt`), and `fiat_limit_headroom_released_total` is incremented. + - Historical windows prior to `refundedAt` are **never** retroactively rewritten: point-in-time evaluations at `t < refundedAt` still reflect the headroom that was reserved at time `t`, while evaluations at `t >= refundedAt` see the released headroom. + +--- + +## 4. Soft Warnings & Scheduled Plan Auto-Pause + +- **Soft Warning (`fiat.limit.approaching`)**: + - When an allowed order pushes any window's utilization above `LIMIT_SOFT_WARN_PCT` (default `0.80` / 80%), the order succeeds, attaches `limitWarning` (`{ window, utilizationPct, used, limit, remaining, currency }`) to the response, and emits `fiat.limit.approaching` on the `alerts` stream topic and webhook channel. +- **Scheduled Plan Auto-Pause (`evaluateScheduledFiatRun`)**: + - When a scheduled fiat purchase or payout run is denied by `evaluateFiatOrder`, it records a `skipped_limit` outcome and increments `consecutiveSkippedLimit`. + - After `FIAT_SCHEDULED_SKIP_AUTO_PAUSE_THRESHOLD` (default `3`) consecutive `skipped_limit` runs, the plan automatically transitions to `PAUSED` (`shouldAutoPause: true`) and logs an alert rather than silently no-op'ing indefinitely. + +--- + +## 5. API Endpoints + +- `GET /api/v1/fiat/limits` — Returns the authenticated caller's `tier`, `currency`, and `{ limit, used, remaining, nextResetAt }` for `perTxn`, `daily`, `monthly`, `velocityHourly`, and `velocityDaily`. +- `POST /api/v1/kyc/verify` — Initiates the provider-hosted KYC verification session. Rejects any client-supplied `tier`. +- `POST /api/v1/kyc/webhook/:provider` — Signature-verified provider callback that updates `KycProfile.tier` and `KycProfile.providerRef` (opaque token only; no PII stored). +- `GET /api/v1/admin/fiat/limit-policies` & `PUT /api/v1/admin/fiat/limit-policies` — Admin inspection and tuning of per-tier policies (`fiat:read` / `fiat:write`, audited). +- `POST /api/v1/admin/users/:id/kyc-tier` — Manual KYC tier override requiring a non-empty `reviewNote` (`fiat:write`, audited). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f6c0d22d..5bcc5fa7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -5078,3 +5078,137 @@ paths: description: Forbidden '429': description: Erasure rate limit exceeded + /fiat/limits: + get: + tags: + - Fiat + operationId: getFiatLimits + summary: Get caller's KYC tier, per-window limit/used/remaining, and next reset time (#362) + security: + - bearerAuth: [] + parameters: + - name: direction + in: query + required: false + schema: + type: string + enum: [ON_RAMP, OFF_RAMP] + responses: + '200': + description: Caller's KYC tier and per-window usage summary + '401': + description: Unauthorized + /kyc/verify: + post: + tags: + - KYC + operationId: verifyKyc + summary: Initiate provider-hosted KYC verification flow (#362) + security: + - bearerAuth: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + provider: + type: string + redirectUrl: + type: string + responses: + '200': + description: KYC verification session initiated + '400': + description: Validation error + '401': + description: Unauthorized + /kyc/webhook/{provider}: + post: + tags: + - KYC + operationId: kycProviderWebhook + summary: Signature-verified provider KYC callback setting KycProfile.tier (#362) + parameters: + - name: provider + in: path + required: true + schema: + type: string + responses: + '200': + description: KYC tier updated + '400': + description: Invalid payload + '401': + description: Invalid webhook signature + /admin/fiat/limit-policies: + get: + tags: + - Admin + operationId: adminGetFiatLimitPolicies + summary: Get per-tier fiat transaction and velocity limit policies (#362) + security: + - adminAuth: [] + responses: + '200': + description: Active fiat limit policies by KYC tier + '401': + description: Admin authentication required + put: + tags: + - Admin + operationId: adminUpdateFiatLimitPolicies + summary: Update per-tier fiat transaction and velocity limit policies (#362) + security: + - adminAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Updated fiat limit policies + '400': + description: Validation error + '401': + description: Admin authentication required + /admin/users/{id}/kyc-tier: + post: + tags: + - Admin + operationId: adminOverrideUserKycTier + summary: Manually override a user's KYC tier with mandatory reviewNote (#362) + security: + - adminAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tier, reviewNote] + properties: + tier: + type: string + enum: [TIER_0, TIER_1, TIER_2, TIER_3] + reviewNote: + type: string + providerRef: + type: string + responses: + '200': + description: User KYC tier overridden and audit-logged + '400': + description: Missing reviewNote or invalid tier + '401': + description: Admin authentication required diff --git a/prisma/migrations/20261027020000_fiat_limits_kyc_tiers/migration.sql b/prisma/migrations/20261027020000_fiat_limits_kyc_tiers/migration.sql new file mode 100644 index 00000000..19189a41 --- /dev/null +++ b/prisma/migrations/20261027020000_fiat_limits_kyc_tiers/migration.sql @@ -0,0 +1,41 @@ +-- CreateEnum +CREATE TYPE "KycTier" AS ENUM ('TIER_0', 'TIER_1', 'TIER_2', 'TIER_3'); + +-- CreateTable +CREATE TABLE "kyc_profiles" ( + "userId" TEXT NOT NULL, + "tier" "KycTier" NOT NULL DEFAULT 'TIER_0', + "providerRef" TEXT, + "verifiedAt" TIMESTAMP(3), + "reviewNote" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "kyc_profiles_pkey" PRIMARY KEY ("userId") +); + +-- CreateTable +CREATE TABLE "fiat_limit_policies" ( + "tier" "KycTier" NOT NULL, + "perTxnMax" DECIMAL(36,18) NOT NULL, + "dailyMax" DECIMAL(36,18) NOT NULL, + "monthlyMax" DECIMAL(36,18) NOT NULL, + "maxTxnsPerHour" INTEGER NOT NULL, + "maxTxnsPerDay" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + "offRampOverrides" JSONB, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "fiat_limit_policies_pkey" PRIMARY KEY ("tier") +); + +-- AddForeignKey +ALTER TABLE "kyc_profiles" ADD CONSTRAINT "kyc_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Seed default tier policies (#362) +INSERT INTO "fiat_limit_policies" ("tier", "perTxnMax", "dailyMax", "monthlyMax", "maxTxnsPerHour", "maxTxnsPerDay", "currency", "offRampOverrides", "updatedAt") +VALUES + ('TIER_0', 0, 0, 0, 0, 0, 'USD', NULL, CURRENT_TIMESTAMP), + ('TIER_1', 1000, 2500, 10000, 3, 10, 'USD', '{"perTxnMax":500,"dailyMax":1000,"monthlyMax":5000}', CURRENT_TIMESTAMP), + ('TIER_2', 10000, 25000, 100000, 5, 20, 'USD', '{"perTxnMax":5000,"dailyMax":15000,"monthlyMax":50000}', CURRENT_TIMESTAMP), + ('TIER_3', 50000, 100000, 500000, 10, 50, 'USD', '{"perTxnMax":25000,"dailyMax":50000,"monthlyMax":250000}', CURRENT_TIMESTAMP) +ON CONFLICT ("tier") DO NOTHING; diff --git a/prisma/migrations/20261027020000_fiat_limits_kyc_tiers/rollback.sql b/prisma/migrations/20261027020000_fiat_limits_kyc_tiers/rollback.sql new file mode 100644 index 00000000..d29cb7f9 --- /dev/null +++ b/prisma/migrations/20261027020000_fiat_limits_kyc_tiers/rollback.sql @@ -0,0 +1,5 @@ +-- Rollback for 20261027020000_fiat_limits_kyc_tiers +ALTER TABLE "kyc_profiles" DROP CONSTRAINT IF EXISTS "kyc_profiles_userId_fkey"; +DROP TABLE IF EXISTS "fiat_limit_policies"; +DROP TABLE IF EXISTS "kyc_profiles"; +DROP TYPE IF EXISTS "KycTier"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c69c2e81..15576dd4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -163,6 +163,13 @@ enum FiatOrderStatus { REFUNDED } +enum KycTier { + TIER_0 + TIER_1 + TIER_2 + TIER_3 +} + enum ReferralStatus { PENDING ACTIVATED @@ -395,6 +402,7 @@ model User { supportTickets SupportTicket[] webAuthnCredentials WebAuthnCredential[] digestSubscriptions DigestSubscription[] + kycProfile KycProfile? @@map("users") } @@ -1426,6 +1434,40 @@ model FiatQuoteLock { @@map("fiat_quote_locks") } +/// Per-user KYC verification profile and tier (#362). +/// Tier is set only by verified provider callbacks or an audited admin override — +/// never from client input. `providerRef` is an opaque provider-side token; no +/// identity documents or PII are stored on the platform. +model KycProfile { + userId String @id + tier KycTier @default(TIER_0) + providerRef String? + verifiedAt DateTime? + reviewNote String? + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("kyc_profiles") +} + +/// Per-tier fiat transaction and velocity limits (#362). +/// Defined in a canonical base `currency` (default USD); cross-currency orders +/// are converted via the FX feed (rateSource: FX_FEED) before evaluation. +model FiatLimitPolicy { + tier KycTier @id + perTxnMax Decimal @db.Decimal(36, 18) + dailyMax Decimal @db.Decimal(36, 18) + monthlyMax Decimal @db.Decimal(36, 18) + maxTxnsPerHour Int + maxTxnsPerDay Int + currency String + offRampOverrides Json? + updatedAt DateTime @updatedAt + + @@map("fiat_limit_policies") +} + /// A user's own single-level referral code (#growth). One code per user; the /// same code is returned on repeated requests. Sharing this code lets a new /// user attribute their signup to the owner. diff --git a/src/events/types.ts b/src/events/types.ts index 9a13ef92..e91f60e3 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -87,6 +87,7 @@ export const EVENT_TYPE_TOPIC: Record = { 'fiat.order.settled': 'transactions', 'fiat.order.failed': 'transactions', 'fiat.order.rate_mismatch': 'transactions', + 'fiat.limit.approaching': 'alerts', 'recurring_deposit.executed': 'transactions', 'recurring_deposit.failed': 'transactions', 'outbox.op_failed': 'transactions', diff --git a/src/fiat/limits.ts b/src/fiat/limits.ts new file mode 100644 index 00000000..0fc823c1 --- /dev/null +++ b/src/fiat/limits.ts @@ -0,0 +1,886 @@ +/** + * Fiat Transaction Limits, Velocity Controls & KYC-Tier Enforcement (#362). + * + * Pure, side-effect-free evaluation engine that every fiat order creation path + * (manual on-ramp/off-ramp routes, scheduled fiat purchase jobs, scheduled fiat + * payout jobs) must pass before an order is created. + * + * Key invariants: + * 1. Compliance freeze (#321) is checked FIRST — a frozen user is denied with + * `compliance_freeze` regardless of KYC tier or remaining headroom. + * 2. Pending orders (`PENDING`, `PROCESSING`) reserve headroom alongside + * `SETTLED` orders so rapid-fire concurrent requests cannot bypass limits. + * 3. Terminal failures (`FAILED`, `REFUNDED`) release headroom as of their + * failure/refund timestamp (`updatedAt` / `refundedAt`) without + * retroactively rewriting historical windows prior to that timestamp. + * 4. Rolling windows (1h, 24h, rolling 30d or calendar month) prevent + * midnight-reset structuring. + * 5. Cross-currency orders are converted to the policy's base currency via the + * FX feed (`rateSource: 'FX_FEED'`, never assumed 1.0). When the FX feed is + * unavailable, cross-currency checks FAIL CLOSED (`fx_unavailable`). + */ + +import type { FiatDirection } from './types' + +export const KYC_TIERS = ['TIER_0', 'TIER_1', 'TIER_2', 'TIER_3'] as const +export type KycTierValue = (typeof KYC_TIERS)[number] + +export type FiatLimitDenialReason = + | 'compliance_freeze' + | 'tier_zero' + | 'per_txn' + | 'daily' + | 'monthly' + | 'velocity_hourly' + | 'velocity_daily' + | 'fx_unavailable' + +export type FiatLimitWindowName = + 'per_txn' | 'daily' | 'monthly' | 'velocity_hourly' | 'velocity_daily' + +export interface DirectionLimitOverrides { + perTxnMax?: number + dailyMax?: number + monthlyMax?: number + maxTxnsPerHour?: number + maxTxnsPerDay?: number +} + +export interface FiatLimitPolicyConfig { + tier: KycTierValue + perTxnMax: number + dailyMax: number + monthlyMax: number + maxTxnsPerHour: number + maxTxnsPerDay: number + currency: string + offRampOverrides?: DirectionLimitOverrides | null +} + +export interface HistoricalFiatOrder { + id?: string + direction?: FiatDirection + fiatAmount: number | string | { toNumber?: () => number } + fiatCurrency: string + status: string + createdAt: Date | string + updatedAt?: Date | string | null + refundedAt?: Date | string | null + /** Optional explicit FX rate to policy base currency captured or supplied for this order. */ + fxRateToBase?: number | null +} + +export interface FiatLimitWarning { + window: FiatLimitWindowName + utilizationPct: number + used: number + limit: number + remaining: number + currency: string +} + +export interface FiatLimitEvaluationResult { + allowed: boolean + reason?: FiatLimitDenialReason + limit?: number + used?: number + remaining?: number + retryAfter?: number + baseAmount?: number + baseCurrency?: string + rateSource?: 'FX_FEED' + limitWarning?: FiatLimitWarning +} + +export interface EvaluateFiatOrderInput { + userId: string + direction: FiatDirection + amount: number + currency: string + tier: KycTierValue + recentOrders: HistoricalFiatOrder[] + now?: Date + /** Optional policy override (e.g. loaded from DB `FiatLimitPolicy`). Falls back to seeded tier defaults. */ + policy?: FiatLimitPolicyConfig | null + /** Optional explicit FX rate (policy baseCurrency units per 1 `currency` unit). Pass `null` to simulate FX outage. */ + fxRate?: number | null + /** When false, simulates FX feed outage — cross-currency evaluations fail closed with `fx_unavailable`. */ + fxFeedAvailable?: boolean + /** True when the user is subject to an active #321 compliance freeze or account deactivation. */ + isFrozen?: boolean + /** Soft warning threshold (0–1). Defaults to `LIMIT_SOFT_WARN_PCT` (0.8). */ + softWarnPct?: number + /** Monthly window mode: rolling 30 days (default) or UTC calendar month. */ + monthlyWindowMode?: 'rolling_30d' | 'calendar_month' +} + +export interface WindowUsageSummary { + limit: number + used: number + remaining: number + nextResetAt: string +} + +export interface FiatLimitsSummary { + tier: KycTierValue + direction: FiatDirection + currency: string + windows: { + perTxn: WindowUsageSummary + daily: WindowUsageSummary + monthly: WindowUsageSummary + velocityHourly: WindowUsageSummary + velocityDaily: WindowUsageSummary + } +} + +export const HOUR_MS = 60 * 60 * 1000 +export const DAY_MS = 24 * HOUR_MS +export const ROLLING_MONTH_MS = 30 * DAY_MS + +/** Soft warning threshold fraction (default 0.80 = 80% of any window limit). */ +export const LIMIT_SOFT_WARN_PCT = Number( + process.env.FIAT_LIMIT_SOFT_WARN_PCT || 0.8 +) + +/** Consecutive `skipped_limit` runs before a scheduled fiat plan auto-pauses. */ +export const SCHEDULED_SKIP_AUTO_PAUSE_THRESHOLD = Number( + process.env.FIAT_SCHEDULED_SKIP_AUTO_PAUSE_THRESHOLD || 3 +) + +/** + * Seeded default policies per KYC tier (#362). + * + * - TIER_0: unverified / view-only ($0 caps across all windows) + * - TIER_1: basic verification (small caps, stricter off-ramp) + * - TIER_2: standard verification (medium caps) + * - TIER_3: enhanced due diligence (high caps) + */ +export const DEFAULT_FIAT_LIMIT_POLICIES: Record< + KycTierValue, + FiatLimitPolicyConfig +> = { + TIER_0: { + tier: 'TIER_0', + perTxnMax: 0, + dailyMax: 0, + monthlyMax: 0, + maxTxnsPerHour: 0, + maxTxnsPerDay: 0, + currency: 'USD', + offRampOverrides: null, + }, + TIER_1: { + tier: 'TIER_1', + perTxnMax: 1_000, + dailyMax: 2_500, + monthlyMax: 10_000, + maxTxnsPerHour: 3, + maxTxnsPerDay: 10, + currency: 'USD', + offRampOverrides: { + perTxnMax: 500, + dailyMax: 1_000, + monthlyMax: 5_000, + maxTxnsPerHour: 2, + maxTxnsPerDay: 5, + }, + }, + TIER_2: { + tier: 'TIER_2', + perTxnMax: 10_000, + dailyMax: 25_000, + monthlyMax: 100_000, + maxTxnsPerHour: 5, + maxTxnsPerDay: 20, + currency: 'USD', + offRampOverrides: { + perTxnMax: 5_000, + dailyMax: 15_000, + monthlyMax: 50_000, + maxTxnsPerHour: 4, + maxTxnsPerDay: 10, + }, + }, + TIER_3: { + tier: 'TIER_3', + perTxnMax: 50_000, + dailyMax: 100_000, + monthlyMax: 500_000, + maxTxnsPerHour: 10, + maxTxnsPerDay: 50, + currency: 'USD', + offRampOverrides: { + perTxnMax: 25_000, + dailyMax: 50_000, + monthlyMax: 250_000, + maxTxnsPerHour: 6, + maxTxnsPerDay: 25, + }, + }, +} + +/** + * Reference FX feed table (units of currency per 1 USD), aligned with + * `src/utils/fxConvert.ts`. Used when converting cross-currency fiat orders + * into the policy base currency (`rateSource: 'FX_FEED'`). + */ +const FX_UNITS_PER_USD: Record = { + USD: 1, + EUR: 0.92, + GBP: 0.79, + JPY: 149.5, + CAD: 1.36, + AUD: 1.53, + CHF: 0.88, + CNY: 7.24, + INR: 83.12, + BRL: 4.97, + MXN: 17.05, + SGD: 1.34, + HKD: 7.82, + KRW: 1320.0, +} + +function roundCurrency(value: number): number { + return Math.round(value * 100) / 100 +} + +function toNumericAmount( + val: number | string | { toNumber?: () => number } +): number { + if (typeof val === 'number') return val + if (typeof val === 'string') return Number(val) + if (val && typeof val.toNumber === 'function') return val.toNumber() + return Number(val) +} + +function toDate(val: Date | string): Date { + return val instanceof Date ? val : new Date(val) +} + +/** + * Resolve the effective policy for a given tier and direction, applying + * `offRampOverrides` when `direction === 'OFF_RAMP'`. + */ +export function resolveEffectivePolicy( + tier: KycTierValue, + direction: FiatDirection, + policyOverride?: FiatLimitPolicyConfig | null +): FiatLimitPolicyConfig { + const base = + policyOverride ?? + DEFAULT_FIAT_LIMIT_POLICIES[tier] ?? + DEFAULT_FIAT_LIMIT_POLICIES.TIER_0 + if (direction === 'OFF_RAMP' && base.offRampOverrides) { + const o = base.offRampOverrides + return { + ...base, + perTxnMax: o.perTxnMax ?? base.perTxnMax, + dailyMax: o.dailyMax ?? base.dailyMax, + monthlyMax: o.monthlyMax ?? base.monthlyMax, + maxTxnsPerHour: o.maxTxnsPerHour ?? base.maxTxnsPerHour, + maxTxnsPerDay: o.maxTxnsPerDay ?? base.maxTxnsPerDay, + } + } + return base +} + +/** + * Convert an amount from `fromCurrency` into `baseCurrency` via the FX feed. + * + * Returns `null` when the FX feed is unavailable or either currency is + * unsupported — callers MUST fail closed (`fx_unavailable`) rather than + * assuming a 1.0 rate. + */ +export function convertFiatToBaseCurrency(params: { + amount: number + fromCurrency: string + baseCurrency: string + fxRate?: number | null + fxFeedAvailable?: boolean +}): { baseAmount: number; rate: number; rateSource: 'FX_FEED' } | null { + const from = params.fromCurrency.trim().toUpperCase() + const base = params.baseCurrency.trim().toUpperCase() + + if (from === base) { + return { + baseAmount: params.amount, + rate: 1, + rateSource: 'FX_FEED', + } + } + + // Explicit FX outage signal: fail closed for cross-currency orders. + if (params.fxFeedAvailable === false || params.fxRate === null) { + return null + } + + if (typeof params.fxRate === 'number') { + if (!Number.isFinite(params.fxRate) || params.fxRate <= 0) { + return null + } + return { + baseAmount: params.amount * params.fxRate, + rate: params.fxRate, + rateSource: 'FX_FEED', + } + } + + const fromPerUsd = FX_UNITS_PER_USD[from] + const basePerUsd = FX_UNITS_PER_USD[base] + if (fromPerUsd == null || basePerUsd == null || fromPerUsd <= 0) { + return null + } + + const rate = basePerUsd / fromPerUsd + return { + baseAmount: params.amount * rate, + rate, + rateSource: 'FX_FEED', + } +} + +/** + * Determine whether a historical `FiatOrder` reserves headroom at evaluation + * instant `now`. + * + * - `PENDING`, `PROCESSING`, `SETTLED`: reserve headroom from `createdAt`. + * - `FAILED`, `REFUNDED`: release headroom as of `refundedAt ?? updatedAt`. + * If `now` precedes that release timestamp (e.g. point-in-time historical + * evaluation before the refund/failure occurred), the order still counted + * at `now` — we never retroactively rewrite windows before the release time. + */ +export function doesOrderReserveHeadroom( + order: HistoricalFiatOrder, + now: Date +): boolean { + const createdAt = toDate(order.createdAt) + if (createdAt.getTime() > now.getTime()) { + return false + } + + const status = order.status.toUpperCase() + if (status === 'PENDING' || status === 'PROCESSING' || status === 'SETTLED') { + return true + } + + if (status === 'FAILED' || status === 'REFUNDED') { + const releaseRaw = order.refundedAt ?? order.updatedAt + if (releaseRaw) { + const releaseAt = toDate(releaseRaw) + // Before the refund/fail timestamp, the order was still active/settled + // and reserved headroom; at or after `releaseAt`, headroom is released. + return releaseAt.getTime() > now.getTime() + } + return false + } + + return false +} + +function getMonthlyWindowStart( + now: Date, + mode: 'rolling_30d' | 'calendar_month' +): Date { + if (mode === 'calendar_month') { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) + } + return new Date(now.getTime() - ROLLING_MONTH_MS) +} + +function getMonthlyNextReset( + oldestInWindow: Date | null, + now: Date, + mode: 'rolling_30d' | 'calendar_month' +): Date { + if (mode === 'calendar_month') { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) + } + if (oldestInWindow) { + return new Date(oldestInWindow.getTime() + ROLLING_MONTH_MS) + } + return new Date(now.getTime() + ROLLING_MONTH_MS) +} + +/** + * Pure, deterministic evaluation of a candidate fiat order against KYC-tier + * limits, rolling velocity counters, pending-order headroom, and compliance + * freeze status (#362). + */ +export function evaluateFiatOrder( + input: EvaluateFiatOrderInput +): FiatLimitEvaluationResult { + const now = input.now ?? new Date() + const monthlyMode = input.monthlyWindowMode ?? 'rolling_30d' + const softWarnPct = input.softWarnPct ?? LIMIT_SOFT_WARN_PCT + + // 1. Interaction with #321 freeze: a frozen user is denied regardless of limits. + if (input.isFrozen) { + return { + allowed: false, + reason: 'compliance_freeze', + limit: 0, + used: 0, + remaining: 0, + } + } + + const effectivePolicy = resolveEffectivePolicy( + input.tier, + input.direction, + input.policy + ) + + // 2. TIER_0 (unverified / view-only) check. + if ( + input.tier === 'TIER_0' || + (effectivePolicy.perTxnMax <= 0 && + effectivePolicy.dailyMax <= 0 && + effectivePolicy.monthlyMax <= 0) + ) { + return { + allowed: false, + reason: 'tier_zero', + limit: 0, + used: 0, + remaining: 0, + baseCurrency: effectivePolicy.currency, + } + } + + // 3. Convert candidate order amount to the policy base currency via FX feed. + const candidateFx = convertFiatToBaseCurrency({ + amount: input.amount, + fromCurrency: input.currency, + baseCurrency: effectivePolicy.currency, + fxRate: input.fxRate, + fxFeedAvailable: input.fxFeedAvailable, + }) + if (!candidateFx) { + return { + allowed: false, + reason: 'fx_unavailable', + limit: 0, + used: 0, + remaining: 0, + baseCurrency: effectivePolicy.currency, + } + } + + const orderBaseAmount = candidateFx.baseAmount + + // 4. Normalize and filter recent orders that reserve headroom as of `now`. + const oneHourAgo = new Date(now.getTime() - HOUR_MS) + const oneDayAgo = new Date(now.getTime() - DAY_MS) + const monthStart = getMonthlyWindowStart(now, monthlyMode) + + interface NormalizedHistoryOrder { + createdAt: Date + baseAmount: number + } + + const activeOrdersInMonth: NormalizedHistoryOrder[] = [] + + for (const raw of input.recentOrders) { + if (!doesOrderReserveHeadroom(raw, now)) continue + const createdAt = toDate(raw.createdAt) + if (createdAt.getTime() < monthStart.getTime()) continue + + const rawAmount = toNumericAmount(raw.fiatAmount) + const conv = convertFiatToBaseCurrency({ + amount: rawAmount, + fromCurrency: raw.fiatCurrency, + baseCurrency: effectivePolicy.currency, + fxRate: + raw.fxRateToBase !== undefined + ? raw.fxRateToBase + : raw.fiatCurrency.trim().toUpperCase() === + input.currency.trim().toUpperCase() + ? input.fxRate + : undefined, + fxFeedAvailable: input.fxFeedAvailable, + }) + + if (!conv) { + // Fail closed if a counting cross-currency order in the window cannot be valued. + return { + allowed: false, + reason: 'fx_unavailable', + limit: 0, + used: 0, + remaining: 0, + baseCurrency: effectivePolicy.currency, + } + } + + activeOrdersInMonth.push({ + createdAt, + baseAmount: conv.baseAmount, + }) + } + + activeOrdersInMonth.sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime() + ) + + const hourlyOrders = activeOrdersInMonth.filter( + (o) => o.createdAt.getTime() > oneHourAgo.getTime() + ) + const dailyOrders = activeOrdersInMonth.filter( + (o) => o.createdAt.getTime() > oneDayAgo.getTime() + ) + + const dailyUsed = dailyOrders.reduce((sum, o) => sum + o.baseAmount, 0) + const monthlyUsed = activeOrdersInMonth.reduce( + (sum, o) => sum + o.baseAmount, + 0 + ) + + // 5. Per-transaction cap check. + if (orderBaseAmount > effectivePolicy.perTxnMax) { + return { + allowed: false, + reason: 'per_txn', + limit: effectivePolicy.perTxnMax, + used: 0, + remaining: effectivePolicy.perTxnMax, + baseAmount: roundCurrency(orderBaseAmount), + baseCurrency: effectivePolicy.currency, + } + } + + // 6. Rolling 1-hour velocity check. + if (hourlyOrders.length + 1 > effectivePolicy.maxTxnsPerHour) { + const oldest = hourlyOrders[0]?.createdAt ?? now + const retryAfter = Math.max( + 1, + Math.ceil((oldest.getTime() + HOUR_MS - now.getTime()) / 1000) + ) + return { + allowed: false, + reason: 'velocity_hourly', + limit: effectivePolicy.maxTxnsPerHour, + used: hourlyOrders.length, + remaining: Math.max( + 0, + effectivePolicy.maxTxnsPerHour - hourlyOrders.length + ), + retryAfter, + baseAmount: roundCurrency(orderBaseAmount), + baseCurrency: effectivePolicy.currency, + } + } + + // 7. Rolling 24-hour velocity check. + if (dailyOrders.length + 1 > effectivePolicy.maxTxnsPerDay) { + const oldest = dailyOrders[0]?.createdAt ?? now + const retryAfter = Math.max( + 1, + Math.ceil((oldest.getTime() + DAY_MS - now.getTime()) / 1000) + ) + return { + allowed: false, + reason: 'velocity_daily', + limit: effectivePolicy.maxTxnsPerDay, + used: dailyOrders.length, + remaining: Math.max( + 0, + effectivePolicy.maxTxnsPerDay - dailyOrders.length + ), + retryAfter, + baseAmount: roundCurrency(orderBaseAmount), + baseCurrency: effectivePolicy.currency, + } + } + + // 8. Rolling 24-hour volume cap check. + if (dailyUsed + orderBaseAmount > effectivePolicy.dailyMax) { + const oldest = dailyOrders[0]?.createdAt ?? now + const retryAfter = Math.max( + 1, + Math.ceil((oldest.getTime() + DAY_MS - now.getTime()) / 1000) + ) + return { + allowed: false, + reason: 'daily', + limit: effectivePolicy.dailyMax, + used: roundCurrency(dailyUsed), + remaining: roundCurrency( + Math.max(0, effectivePolicy.dailyMax - dailyUsed) + ), + retryAfter, + baseAmount: roundCurrency(orderBaseAmount), + baseCurrency: effectivePolicy.currency, + } + } + + // 9. Monthly volume cap check. + if (monthlyUsed + orderBaseAmount > effectivePolicy.monthlyMax) { + const oldest = activeOrdersInMonth[0]?.createdAt ?? null + const resetAt = getMonthlyNextReset(oldest, now, monthlyMode) + const retryAfter = Math.max( + 1, + Math.ceil((resetAt.getTime() - now.getTime()) / 1000) + ) + return { + allowed: false, + reason: 'monthly', + limit: effectivePolicy.monthlyMax, + used: roundCurrency(monthlyUsed), + remaining: roundCurrency( + Math.max(0, effectivePolicy.monthlyMax - monthlyUsed) + ), + retryAfter, + baseAmount: roundCurrency(orderBaseAmount), + baseCurrency: effectivePolicy.currency, + } + } + + // 10. Soft warning check (> LIMIT_SOFT_WARN_PCT of any window). + const candidateWarnings: FiatLimitWarning[] = [] + const newDailyUsed = dailyUsed + orderBaseAmount + const newMonthlyUsed = monthlyUsed + orderBaseAmount + const newHourlyCount = hourlyOrders.length + 1 + const newDailyCount = dailyOrders.length + 1 + + const checks: Array<{ + window: FiatLimitWindowName + used: number + limit: number + }> = [ + { + window: 'daily', + used: roundCurrency(newDailyUsed), + limit: effectivePolicy.dailyMax, + }, + { + window: 'monthly', + used: roundCurrency(newMonthlyUsed), + limit: effectivePolicy.monthlyMax, + }, + { + window: 'velocity_hourly', + used: newHourlyCount, + limit: effectivePolicy.maxTxnsPerHour, + }, + { + window: 'velocity_daily', + used: newDailyCount, + limit: effectivePolicy.maxTxnsPerDay, + }, + { + window: 'per_txn', + used: roundCurrency(orderBaseAmount), + limit: effectivePolicy.perTxnMax, + }, + ] + + for (const c of checks) { + if (c.limit > 0) { + const ratio = c.used / c.limit + if (ratio > softWarnPct) { + candidateWarnings.push({ + window: c.window, + utilizationPct: Math.round(ratio * 10000) / 100, + used: c.used, + limit: c.limit, + remaining: roundCurrency(Math.max(0, c.limit - c.used)), + currency: effectivePolicy.currency, + }) + } + } + } + + // Pick the highest-utilization warning if multiple windows crossed the threshold. + candidateWarnings.sort((a, b) => b.utilizationPct - a.utilizationPct) + + return { + allowed: true, + baseAmount: roundCurrency(orderBaseAmount), + baseCurrency: effectivePolicy.currency, + rateSource: candidateFx.rateSource, + ...(candidateWarnings[0] ? { limitWarning: candidateWarnings[0] } : {}), + } +} + +/** + * Compute the caller's per-window limit/used/remaining and next reset time + * for `GET /api/v1/fiat/limits` (#362). + */ +export function computeFiatLimitsSummary(params: { + tier: KycTierValue + direction?: FiatDirection + recentOrders: HistoricalFiatOrder[] + now?: Date + policy?: FiatLimitPolicyConfig | null + monthlyWindowMode?: 'rolling_30d' | 'calendar_month' + fxRate?: number | null + fxFeedAvailable?: boolean +}): FiatLimitsSummary { + const now = params.now ?? new Date() + const direction = params.direction ?? 'ON_RAMP' + const monthlyMode = params.monthlyWindowMode ?? 'rolling_30d' + const effectivePolicy = resolveEffectivePolicy( + params.tier, + direction, + params.policy + ) + + const oneHourAgo = new Date(now.getTime() - HOUR_MS) + const oneDayAgo = new Date(now.getTime() - DAY_MS) + const monthStart = getMonthlyWindowStart(now, monthlyMode) + + const activeInMonth: Array<{ createdAt: Date; baseAmount: number }> = [] + + for (const raw of params.recentOrders) { + if (!doesOrderReserveHeadroom(raw, now)) continue + const createdAt = toDate(raw.createdAt) + if (createdAt.getTime() < monthStart.getTime()) continue + + const conv = convertFiatToBaseCurrency({ + amount: toNumericAmount(raw.fiatAmount), + fromCurrency: raw.fiatCurrency, + baseCurrency: effectivePolicy.currency, + fxRate: raw.fxRateToBase ?? params.fxRate, + fxFeedAvailable: params.fxFeedAvailable, + }) + if (!conv) continue + + activeInMonth.push({ createdAt, baseAmount: conv.baseAmount }) + } + + activeInMonth.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + + const hourlyOrders = activeInMonth.filter( + (o) => o.createdAt.getTime() > oneHourAgo.getTime() + ) + const dailyOrders = activeInMonth.filter( + (o) => o.createdAt.getTime() > oneDayAgo.getTime() + ) + + const dailyUsed = roundCurrency( + dailyOrders.reduce((s, o) => s + o.baseAmount, 0) + ) + const monthlyUsed = roundCurrency( + activeInMonth.reduce((s, o) => s + o.baseAmount, 0) + ) + + const hourlyResetAt = hourlyOrders[0] + ? new Date(hourlyOrders[0].createdAt.getTime() + HOUR_MS) + : new Date(now.getTime() + HOUR_MS) + + const dailyResetAt = dailyOrders[0] + ? new Date(dailyOrders[0].createdAt.getTime() + DAY_MS) + : new Date(now.getTime() + DAY_MS) + + const monthlyResetAt = getMonthlyNextReset( + activeInMonth[0]?.createdAt ?? null, + now, + monthlyMode + ) + + return { + tier: params.tier, + direction, + currency: effectivePolicy.currency, + windows: { + perTxn: { + limit: effectivePolicy.perTxnMax, + used: 0, + remaining: effectivePolicy.perTxnMax, + nextResetAt: now.toISOString(), + }, + daily: { + limit: effectivePolicy.dailyMax, + used: dailyUsed, + remaining: roundCurrency( + Math.max(0, effectivePolicy.dailyMax - dailyUsed) + ), + nextResetAt: dailyResetAt.toISOString(), + }, + monthly: { + limit: effectivePolicy.monthlyMax, + used: monthlyUsed, + remaining: roundCurrency( + Math.max(0, effectivePolicy.monthlyMax - monthlyUsed) + ), + nextResetAt: monthlyResetAt.toISOString(), + }, + velocityHourly: { + limit: effectivePolicy.maxTxnsPerHour, + used: hourlyOrders.length, + remaining: Math.max( + 0, + effectivePolicy.maxTxnsPerHour - hourlyOrders.length + ), + nextResetAt: hourlyResetAt.toISOString(), + }, + velocityDaily: { + limit: effectivePolicy.maxTxnsPerDay, + used: dailyOrders.length, + remaining: Math.max( + 0, + effectivePolicy.maxTxnsPerDay - dailyOrders.length + ), + nextResetAt: dailyResetAt.toISOString(), + }, + }, + } +} + +export interface ScheduledFiatPlanState { + id: string + userId: string + status: 'ACTIVE' | 'PAUSED' | 'CANCELLED' + consecutiveSkippedLimit?: number +} + +export interface ScheduledFiatRunEvaluation { + outcome: 'allowed' | 'skipped_limit' + evaluation: FiatLimitEvaluationResult + consecutiveSkippedLimit: number + shouldAutoPause: boolean + nextStatus: 'ACTIVE' | 'PAUSED' | 'CANCELLED' +} + +/** + * Evaluate a scheduled fiat purchase / payout run (`fiatPurchases` / `fiatPayouts`). + * + * When denied by limits, returns `outcome: 'skipped_limit'` and increments + * `consecutiveSkippedLimit`. After `maxConsecutiveSkips` (default + * `SCHEDULED_SKIP_AUTO_PAUSE_THRESHOLD = 3`) consecutive `skipped_limit` runs, + * transitions `nextStatus` to `'PAUSED'` (`shouldAutoPause: true`) so the + * caller can auto-pause the plan and notify the user instead of silently + * no-op'ing forever (#362). + */ +export function evaluateScheduledFiatRun(params: { + plan: ScheduledFiatPlanState + evaluationInput: EvaluateFiatOrderInput + maxConsecutiveSkips?: number +}): ScheduledFiatRunEvaluation { + const maxSkips = + params.maxConsecutiveSkips ?? SCHEDULED_SKIP_AUTO_PAUSE_THRESHOLD + const evaluation = evaluateFiatOrder(params.evaluationInput) + + if (evaluation.allowed) { + return { + outcome: 'allowed', + evaluation, + consecutiveSkippedLimit: 0, + shouldAutoPause: false, + nextStatus: params.plan.status, + } + } + + const consecutiveSkippedLimit = (params.plan.consecutiveSkippedLimit ?? 0) + 1 + const shouldAutoPause = consecutiveSkippedLimit >= maxSkips + + return { + outcome: 'skipped_limit', + evaluation, + consecutiveSkippedLimit, + shouldAutoPause, + nextStatus: shouldAutoPause ? 'PAUSED' : params.plan.status, + } +} diff --git a/src/fiat/service.ts b/src/fiat/service.ts index b38abdce..1a0c6529 100644 --- a/src/fiat/service.ts +++ b/src/fiat/service.ts @@ -57,7 +57,22 @@ import { recordFiatQuoteFailure, recordFiatOrder, recordFiatRateDrift, + recordFiatLimitEvaluation, + recordFiatLimitHeadroomRelease, } from '../utils/metrics' +import { + evaluateFiatOrder, + computeFiatLimitsSummary, + evaluateScheduledFiatRun, + DEFAULT_FIAT_LIMIT_POLICIES, + ROLLING_MONTH_MS, + DAY_MS, + type KycTierValue, + type FiatLimitPolicyConfig, + type FiatLimitEvaluationResult, + type FiatLimitsSummary, + type ScheduledFiatPlanState, +} from './limits' import type { CreateFiatOrderInput, FiatQuoteInput, @@ -282,6 +297,119 @@ export async function getBestExecutionQuote( export interface CreateOrderContext { /** Authenticated user's custodial/destination Stellar address. */ walletAddress: string + /** Force limit evaluation even when DB models are partially stubbed in unit tests. */ + enforceLimits?: boolean + /** Skip limit evaluation when a caller (e.g. executeScheduledFiatOrder) already ran it. */ + skipLimitCheck?: boolean + /** Optional explicit KYC tier override for evaluation (used in tests/internal callers). */ + tier?: KycTierValue + /** Optional explicit FX rate override (pass `null` to simulate FX outage). */ + fxRate?: number | null + /** Optional explicit FX feed availability flag. */ + fxFeedAvailable?: boolean + /** Optional explicit compliance freeze flag. */ + isFrozen?: boolean + /** Optional evaluation clock anchor. */ + now?: Date +} + +function mapDbPolicyToConfig( + row: any, + tier: KycTierValue +): FiatLimitPolicyConfig { + if (!row) { + return ( + DEFAULT_FIAT_LIMIT_POLICIES[tier] ?? DEFAULT_FIAT_LIMIT_POLICIES.TIER_0 + ) + } + return { + tier: (row.tier as KycTierValue) ?? tier, + perTxnMax: Number(row.perTxnMax), + dailyMax: Number(row.dailyMax), + monthlyMax: Number(row.monthlyMax), + maxTxnsPerHour: Number(row.maxTxnsPerHour), + maxTxnsPerDay: Number(row.maxTxnsPerDay), + currency: row.currency || 'USD', + offRampOverrides: (row.offRampOverrides as any) ?? null, + } +} + +async function resolveUserLimitInputs( + userId: string, + ctx: Partial, + database: Db +): Promise<{ + isFrozen: boolean + tier: KycTierValue + policy: FiatLimitPolicyConfig + recentOrders: any[] + now: Date +}> { + const now = ctx.now ?? new Date() + + let isFrozen = ctx.isFrozen ?? false + if (ctx.isFrozen === undefined) { + if (typeof (database as any).user?.findUnique === 'function') { + const user = await (database as any).user.findUnique({ + where: { id: userId }, + select: { isActive: true }, + }) + if (user && user.isActive === false) { + isFrozen = true + } + } + if ( + !isFrozen && + typeof (database as any).complianceCase?.findFirst === 'function' + ) { + const activeCase = await (database as any).complianceCase.findFirst({ + where: { + userId, + status: { + in: ['OPEN', 'TRIAGE', 'INVESTIGATING', 'ESCALATED', 'PENDING_SAR'], + }, + }, + select: { id: true }, + }) + if (activeCase) { + isFrozen = true + } + } + } + + let tier: KycTierValue = ctx.tier ?? 'TIER_0' + if ( + !ctx.tier && + typeof (database as any).kycProfile?.findUnique === 'function' + ) { + const profile = await (database as any).kycProfile.findUnique({ + where: { userId }, + }) + tier = (profile?.tier as KycTierValue) ?? 'TIER_0' + } + + let policyRow: any = null + if (typeof (database as any).fiatLimitPolicy?.findUnique === 'function') { + policyRow = await (database as any).fiatLimitPolicy.findUnique({ + where: { tier }, + }) + } + const policy = mapDbPolicyToConfig(policyRow, tier) + + let recentOrders: any[] = [] + if (typeof (database as any).fiatOrder?.findMany === 'function') { + const windowCutoff = new Date(now.getTime() - (ROLLING_MONTH_MS + DAY_MS)) + recentOrders = + (await (database as any).fiatOrder.findMany({ + where: { + userId, + createdAt: { gte: windowCutoff }, + }, + orderBy: { createdAt: 'asc' }, + })) ?? [] + } + + return { isFrozen, tier, policy, recentOrders, now } } async function resolveOrderProvider( @@ -378,6 +506,83 @@ export async function createFiatOrder( ctx: CreateOrderContext, database: Db = db ) { + let limitEval: FiatLimitEvaluationResult | null = null + let evaluatedTier: KycTierValue = ctx.tier ?? 'TIER_0' + + const shouldEnforceLimits = + !ctx.skipLimitCheck && + (ctx.enforceLimits === true || + ctx.isFrozen !== undefined || + ctx.tier !== undefined || + typeof (database as any).kycProfile?.findUnique === 'function') + + if (shouldEnforceLimits) { + const limitInputs = await resolveUserLimitInputs( + input.userId, + ctx, + database + ) + evaluatedTier = limitInputs.tier + limitEval = evaluateFiatOrder({ + userId: input.userId, + direction: input.direction, + amount: input.fiatAmount, + currency: input.fiatCurrency, + tier: limitInputs.tier, + recentOrders: limitInputs.recentOrders, + now: limitInputs.now, + policy: limitInputs.policy, + fxRate: ctx.fxRate, + fxFeedAvailable: ctx.fxFeedAvailable, + isFrozen: limitInputs.isFrozen, + }) + + if (!limitEval.allowed) { + recordFiatLimitEvaluation?.( + 'denied', + limitEval.reason ?? 'unknown', + evaluatedTier, + input.direction + ) + logger.warn('[Fiat] Order denied by limit/velocity/KYC check', { + userId: input.userId, + direction: input.direction, + amount: input.fiatAmount, + currency: input.fiatCurrency, + tier: evaluatedTier, + reason: limitEval.reason, + limit: limitEval.limit, + used: limitEval.used, + remaining: limitEval.remaining, + }) + throw new FiatOrderError( + 'fiat_limit_exceeded', + 403, + `Fiat order denied: ${limitEval.reason}`, + { + reason: limitEval.reason, + tier: evaluatedTier, + limit: limitEval.limit, + used: limitEval.used, + remaining: limitEval.remaining, + ...(limitEval.retryAfter != null + ? { retryAfter: limitEval.retryAfter } + : {}), + ...(limitEval.baseCurrency + ? { currency: limitEval.baseCurrency } + : {}), + } + ) + } + + recordFiatLimitEvaluation?.( + limitEval.limitWarning ? 'soft_warn' : 'allowed', + limitEval.limitWarning?.window ?? 'ok', + evaluatedTier, + input.direction + ) + } + const resolved = await resolveOrderProvider(input, database) const provider = getProvider(resolved.providerName) @@ -448,9 +653,153 @@ export async function createFiatOrder( status: initialStatus, }) + if (limitEval?.limitWarning) { + const warn = limitEval.limitWarning + publishUserEvent( + input.userId, + EVENT_TYPE_TOPIC['fiat.limit.approaching'], + 'fiat.limit.approaching', + { + orderId: order.id, + userId: input.userId, + direction: input.direction, + tier: evaluatedTier, + window: warn.window, + utilizationPct: warn.utilizationPct, + used: warn.used, + limit: warn.limit, + remaining: warn.remaining, + currency: warn.currency, + } + ).catch(() => {}) + + return { + ...order, + limitWarning: warn, + } + } + return order } +/** + * Compute the caller's KYC tier and per-window limit/used/remaining summary + * for `GET /api/v1/fiat/limits` (#362). + */ +export async function getUserFiatLimits( + userId: string, + options: { + direction?: FiatDirection + now?: Date + fxRate?: number | null + fxFeedAvailable?: boolean + } = {}, + database: Db = db +): Promise { + const limitInputs = await resolveUserLimitInputs( + userId, + { + now: options.now, + fxRate: options.fxRate, + fxFeedAvailable: options.fxFeedAvailable, + }, + database + ) + return computeFiatLimitsSummary({ + tier: limitInputs.tier, + direction: options.direction ?? 'ON_RAMP', + recentOrders: limitInputs.recentOrders, + now: limitInputs.now, + policy: limitInputs.policy, + fxRate: options.fxRate, + fxFeedAvailable: options.fxFeedAvailable, + }) +} + +/** + * Execute a scheduled fiat order (`fiatPurchases` / `fiatPayouts` jobs, #362). + * + * Calls `evaluateFiatOrder` first; on denial returns a `skipped_limit` outcome + * and auto-pauses the plan after N consecutive `skipped_limit` runs. + */ +export async function executeScheduledFiatOrder( + params: { + plan: ScheduledFiatPlanState + orderInput: CreateFiatOrderInput + ctx: CreateOrderContext + maxConsecutiveSkips?: number + }, + database: Db = db +) { + const limitInputs = await resolveUserLimitInputs( + params.orderInput.userId, + params.ctx, + database + ) + const runEval = evaluateScheduledFiatRun({ + plan: params.plan, + maxConsecutiveSkips: params.maxConsecutiveSkips, + evaluationInput: { + userId: params.orderInput.userId, + direction: params.orderInput.direction, + amount: params.orderInput.fiatAmount, + currency: params.orderInput.fiatCurrency, + tier: limitInputs.tier, + recentOrders: limitInputs.recentOrders, + now: limitInputs.now, + policy: limitInputs.policy, + fxRate: params.ctx.fxRate, + fxFeedAvailable: params.ctx.fxFeedAvailable, + isFrozen: limitInputs.isFrozen, + }, + }) + + if (runEval.outcome === 'skipped_limit') { + recordFiatLimitEvaluation?.( + 'denied', + runEval.evaluation.reason ?? 'unknown', + limitInputs.tier, + params.orderInput.direction + ) + if (runEval.shouldAutoPause) { + logger.warn( + '[Fiat] Scheduled fiat plan auto-paused after repeated skipped_limit runs', + { + planId: params.plan.id, + userId: params.plan.userId, + consecutiveSkippedLimit: runEval.consecutiveSkippedLimit, + reason: runEval.evaluation.reason, + } + ) + } + return { + outcome: 'skipped_limit' as const, + reason: runEval.evaluation.reason, + limit: runEval.evaluation.limit, + used: runEval.evaluation.used, + remaining: runEval.evaluation.remaining, + retryAfter: runEval.evaluation.retryAfter, + consecutiveSkippedLimit: runEval.consecutiveSkippedLimit, + autoPaused: runEval.shouldAutoPause, + nextStatus: runEval.nextStatus, + } + } + + const order = await createFiatOrder( + params.orderInput, + { ...params.ctx, skipLimitCheck: true }, + database + ) + + return { + outcome: 'executed' as const, + order, + consecutiveSkippedLimit: 0, + autoPaused: false, + nextStatus: params.plan.status, + } +} + // ── Webhook processing (idempotent) ───────────────────────────────────────── export interface ProcessWebhookResult { @@ -552,6 +901,10 @@ export async function processProviderWebhook( // Emit outbound webhook for terminal failure/refund so subscribers react. if (updated.status === 'FAILED' || updated.status === 'REFUNDED') { recordFiatOrder(providerName, updated.status) + recordFiatLimitHeadroomRelease?.( + updated.status as 'FAILED' | 'REFUNDED', + updated.direction ?? 'ON_RAMP' + ) publishUserEvent( updated.userId, EVENT_TYPE_TOPIC['fiat.order.failed'], diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 5a459cbd..1b1adec6 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -21,6 +21,12 @@ import { validateScopesInput, } from '../middleware/adminAuth' import { getAllProviderHealth, adminSetProviderCircuit } from '../fiat/registry' +import { + DEFAULT_FIAT_LIMIT_POLICIES, + KYC_TIERS, + type KycTierValue, + type FiatLimitPolicyConfig, +} from '../fiat/limits' import db from '../db' import { revokeSession } from '../services/refresh-token.service' import { alertingService } from '../services/alerting' @@ -58,6 +64,8 @@ const HIGH_RISK_ADMIN_ACTIONS = new Set([ 'TREASURY_SIGNER_ROTATION_FINALIZE', 'TREASURY_POLICY_ENABLE', 'FIAT_PROVIDER_FAILOVER', + 'UPDATE_FIAT_LIMIT_POLICIES', + 'OVERRIDE_USER_KYC_TIER', 'DLQ_RESOLVE', 'DLQ_REPLAY', 'OUTBOX_CANCEL', @@ -1687,6 +1695,274 @@ router.post( } ) +/** + * GET /api/admin/fiat/limit-policies + * Returns the active per-tier fiat limit and velocity policy table (#362). + * Merges DB overrides over `DEFAULT_FIAT_LIMIT_POLICIES`. + * Required scope: fiat:read + */ +router.get( + '/fiat/limit-policies', + requireAdminScope('fiat:read'), + async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } + try { + const rows = + typeof (prisma as any).fiatLimitPolicy?.findMany === 'function' + ? await (prisma as any).fiatLimitPolicy.findMany() + : [] + const byTier = new Map() + for (const r of rows ?? []) { + byTier.set(r.tier, r) + } + + const policies: FiatLimitPolicyConfig[] = KYC_TIERS.map((tier) => { + const row = byTier.get(tier) + if (!row) return DEFAULT_FIAT_LIMIT_POLICIES[tier] + return { + tier, + perTxnMax: Number(row.perTxnMax), + dailyMax: Number(row.dailyMax), + monthlyMax: Number(row.monthlyMax), + maxTxnsPerHour: Number(row.maxTxnsPerHour), + maxTxnsPerDay: Number(row.maxTxnsPerDay), + currency: row.currency || 'USD', + offRampOverrides: row.offRampOverrides ?? null, + } + }) + + await auditLog(req, res, 'GET_FIAT_LIMIT_POLICIES', 'success') + return res.status(200).json({ success: true, data: { policies } }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('[Admin] Failed to get fiat limit policies', { + error: message, + }) + await auditLog(req, res, 'GET_FIAT_LIMIT_POLICIES', 'failure', { + error: message, + }) + return res + .status(500) + .json({ success: false, error: 'Failed to get fiat limit policies' }) + } + } +) + +/** + * PUT /api/admin/fiat/limit-policies + * Upsert one or more per-tier fiat limit policies (#362). Audit-logged. + * Required scope: fiat:write + */ +router.put( + '/fiat/limit-policies', + requireAdminScope('fiat:write'), + async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } + + const rawPolicies: any[] = Array.isArray(req.body?.policies) + ? req.body.policies + : req.body?.tier + ? [req.body] + : [] + + if (rawPolicies.length === 0) { + return res.status(400).json({ + success: false, + error: + 'Request body must contain a policy object or { policies: [...] }', + }) + } + + for (const p of rawPolicies) { + if (!KYC_TIERS.includes(p?.tier)) { + return res.status(400).json({ + success: false, + error: `Invalid tier "${String(p?.tier)}". Must be one of ${KYC_TIERS.join(', ')}`, + }) + } + if ( + typeof p.perTxnMax !== 'number' || + p.perTxnMax < 0 || + typeof p.dailyMax !== 'number' || + p.dailyMax < 0 || + typeof p.monthlyMax !== 'number' || + p.monthlyMax < 0 || + typeof p.maxTxnsPerHour !== 'number' || + p.maxTxnsPerHour < 0 || + typeof p.maxTxnsPerDay !== 'number' || + p.maxTxnsPerDay < 0 || + typeof p.currency !== 'string' || + !p.currency.trim() + ) { + return res.status(400).json({ + success: false, + error: + 'Each policy must provide non-negative numeric perTxnMax, dailyMax, monthlyMax, maxTxnsPerHour, maxTxnsPerDay and a non-empty currency', + }) + } + } + + try { + const updated: FiatLimitPolicyConfig[] = [] + for (const p of rawPolicies) { + const tier = p.tier as KycTierValue + const currency = p.currency.trim().toUpperCase() + const row = await (prisma as any).fiatLimitPolicy.upsert({ + where: { tier }, + create: { + tier, + perTxnMax: p.perTxnMax, + dailyMax: p.dailyMax, + monthlyMax: p.monthlyMax, + maxTxnsPerHour: Math.floor(p.maxTxnsPerHour), + maxTxnsPerDay: Math.floor(p.maxTxnsPerDay), + currency, + offRampOverrides: p.offRampOverrides ?? null, + }, + update: { + perTxnMax: p.perTxnMax, + dailyMax: p.dailyMax, + monthlyMax: p.monthlyMax, + maxTxnsPerHour: Math.floor(p.maxTxnsPerHour), + maxTxnsPerDay: Math.floor(p.maxTxnsPerDay), + currency, + offRampOverrides: + p.offRampOverrides !== undefined ? p.offRampOverrides : undefined, + }, + }) + updated.push({ + tier: row.tier, + perTxnMax: Number(row.perTxnMax), + dailyMax: Number(row.dailyMax), + monthlyMax: Number(row.monthlyMax), + maxTxnsPerHour: Number(row.maxTxnsPerHour), + maxTxnsPerDay: Number(row.maxTxnsPerDay), + currency: row.currency, + offRampOverrides: row.offRampOverrides ?? null, + }) + } + + await auditLog(req, res, 'UPDATE_FIAT_LIMIT_POLICIES', 'success', { + policies: updated, + }) + + return res + .status(200) + .json({ success: true, data: { policies: updated } }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('[Admin] Failed to update fiat limit policies', { + error: message, + }) + await auditLog(req, res, 'UPDATE_FIAT_LIMIT_POLICIES', 'failure', { + error: message, + }) + return res + .status(500) + .json({ success: false, error: 'Failed to update fiat limit policies' }) + } + } +) + +/** + * POST /api/admin/users/:id/kyc-tier + * Manual KYC tier override for a user with a mandatory `reviewNote` (#362). + * Audit-logged as a high-risk admin action. + * Required scope: fiat:write + */ +router.post( + '/users/:id/kyc-tier', + requireAdminScope('fiat:write'), + async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } + + const userId = req.params.id + const { tier, reviewNote, providerRef } = req.body ?? {} + + if (!KYC_TIERS.includes(tier)) { + return res.status(400).json({ + success: false, + error: `tier must be one of ${KYC_TIERS.join(', ')}`, + }) + } + + if (typeof reviewNote !== 'string' || reviewNote.trim().length === 0) { + return res.status(400).json({ + success: false, + error: 'reviewNote is required for manual KYC tier override', + }) + } + + try { + const verifiedAt = tier === 'TIER_0' ? null : new Date() + const profile = await (prisma as any).kycProfile.upsert({ + where: { userId }, + create: { + userId, + tier, + providerRef: typeof providerRef === 'string' ? providerRef : null, + verifiedAt, + reviewNote: reviewNote.trim(), + }, + update: { + tier, + ...(typeof providerRef === 'string' ? { providerRef } : {}), + verifiedAt, + reviewNote: reviewNote.trim(), + }, + }) + + await auditLog(req, res, 'OVERRIDE_USER_KYC_TIER', 'success', { + userId, + tier: profile.tier, + reviewNote: profile.reviewNote, + }) + + return res.status(200).json({ + success: true, + data: { + userId: profile.userId, + tier: profile.tier, + providerRef: profile.providerRef ?? null, + verifiedAt: profile.verifiedAt + ? new Date(profile.verifiedAt).toISOString() + : null, + reviewNote: profile.reviewNote, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('[Admin] Failed to override user KYC tier', { + userId, + error: message, + }) + await auditLog(req, res, 'OVERRIDE_USER_KYC_TIER', 'failure', { + userId, + tier, + error: message, + }) + return res + .status(500) + .json({ success: false, error: 'Failed to override user KYC tier' }) + } + } +) + // ── Durable outbox admin tooling (#325) ───────────────────────────────────── /** diff --git a/src/routes/fiat.ts b/src/routes/fiat.ts index 7feb9f00..dbd14aa4 100644 --- a/src/routes/fiat.ts +++ b/src/routes/fiat.ts @@ -29,12 +29,17 @@ import { getFiatQuote, getBestExecutionQuote, createFiatOrder, + getUserFiatLimits, hasProviderWebhookReplay, processProviderWebhook, recordProviderWebhookDelivery, } from '../fiat/service' import { getProvider } from '../fiat/registry' -import { NoHealthyProvidersError, FiatOrderError } from '../fiat/types' +import { + NoHealthyProvidersError, + FiatOrderError, + type FiatDirection, +} from '../fiat/types' import db from '../db' const router = Router() @@ -129,6 +134,31 @@ router.post( } ) +// ── Caller's KYC tier & per-window limit/usage summary (#362) ─────────────── +router.get('/limits', requireAuth, async (req: Request, res: Response) => { + const userId = req.userId + if (!userId) return sendError(res, 401, 'Unauthorized') + + const rawDir = + typeof req.query.direction === 'string' + ? req.query.direction.toUpperCase() + : 'ON_RAMP' + if (rawDir !== 'ON_RAMP' && rawDir !== 'OFF_RAMP') { + return sendError(res, 400, 'direction must be ON_RAMP or OFF_RAMP') + } + + try { + const summary = await getUserFiatLimits( + userId, + { direction: rawDir as FiatDirection }, + db + ) + return res.json(summary) + } catch (err) { + return respondToFiatError(res, err, 'Failed to fetch fiat limits') + } +}) + // ── Order history (caller-scoped) ───────────────────────────────────────────── router.get('/orders', requireAuth, async (req: Request, res: Response) => { const userId = req.userId diff --git a/src/routes/kyc.ts b/src/routes/kyc.ts new file mode 100644 index 00000000..8a153ff0 --- /dev/null +++ b/src/routes/kyc.ts @@ -0,0 +1,208 @@ +/** + * KYC verification initiation & provider callback routes (#362). + * + * POST /api/v1/kyc/verify — auth; initiate provider-hosted KYC flow + * POST /api/v1/kyc/webhook/:provider — signature-verified provider callback that updates KycProfile.tier + * + * Security invariants: + * - KYC tier is NEVER accepted from user request input on `/verify`. + * - `KycProfile.tier` is only updated by a signature-verified provider callback + * or an audited admin override (`POST /api/v1/admin/users/:id/kyc-tier`). + * - `KycProfile.providerRef` stores only an opaque provider reference token; + * no PII or identity documents are persisted on the platform. + */ +import { Router, Request, Response } from 'express' +import express from 'express' +import { createHmac, timingSafeEqual } from 'crypto' +import { z } from 'zod' +import { requireAuth } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import { sendError } from '../utils/errors' +import { logger } from '../utils/logger' +import { getDefaultProvider, getProvider } from '../fiat/registry' +import { KYC_TIERS, type KycTierValue } from '../fiat/limits' +import db from '../db' + +const router = Router() + +const verifyKycRequestSchema = z + .object({ + provider: z.string().min(1).max(64).optional(), + redirectUrl: z.string().url().optional(), + }) + .strict() + +const kycWebhookPayloadSchema = z.object({ + userId: z.string().min(1), + tier: z.enum(KYC_TIERS), + providerRef: z.string().min(1).max(256), + verifiedAt: z.string().datetime().optional(), + reviewNote: z.string().max(1000).optional(), +}) + +function verifyKycWebhookSignature( + providerName: string, + rawBody: string, + headers: Record +): boolean { + const kycSecret = process.env.KYC_WEBHOOK_SECRET + const sigHeader = + headers['x-kyc-signature'] ?? + headers['x-webhook-signature'] ?? + headers['moonpay-signature-v2'] + + if (kycSecret && sigHeader) { + const expected = createHmac('sha256', kycSecret) + .update(rawBody) + .digest('hex') + const cleanSig = sigHeader.replace(/^sha256=/i, '').trim() + try { + const a = Buffer.from(expected, 'utf8') + const b = Buffer.from(cleanSig, 'utf8') + if (a.length === b.length && timingSafeEqual(a, b)) { + return true + } + } catch { + // Fall through to provider verifier. + } + } + + try { + const provider = getProvider(providerName) + return provider.verifyWebhookSignature(rawBody, headers) + } catch { + return false + } +} + +// ── Initiate provider-hosted KYC verification ──────────────────────────────── +router.post( + '/verify', + requireAuth, + validate({ body: verifyKycRequestSchema, errorMessage: 'Validation error' }), + async (req: Request, res: Response) => { + const userId = req.userId + if (!userId) return sendError(res, 401, 'Unauthorized') + + // Reject if caller attempted to sneak in a `tier` field (enforced by .strict() too) + if (req.body && 'tier' in req.body) { + return sendError(res, 400, 'KYC tier cannot be asserted by client') + } + + let providerName = req.body?.provider + try { + const provider = providerName + ? getProvider(providerName) + : getDefaultProvider() + providerName = provider.name + } catch { + return sendError(res, 400, 'Unknown KYC provider') + } + + const existing = + typeof (db as any).kycProfile?.findUnique === 'function' + ? await (db as any).kycProfile.findUnique({ where: { userId } }) + : null + + const currentTier: KycTierValue = + (existing?.tier as KycTierValue) ?? 'TIER_0' + const sessionRef = `kyc_${providerName}_${userId.slice(0, 8)}_${Date.now()}` + const verificationUrl = `https://kyc.${providerName}.example.com/verify?ref=${encodeURIComponent(sessionRef)}` + + logger.info('[KYC] Verification flow initiated', { + userId, + provider: providerName, + currentTier, + }) + + return res.status(200).json({ + provider: providerName, + status: 'PENDING', + currentTier, + providerRef: sessionRef, + verificationUrl, + }) + } +) + +// ── Provider KYC verification callback (signature-verified) ────────────────── +router.post( + '/webhook/:provider', + express.raw({ type: '*/*', limit: '1mb' }), + async (req: Request, res: Response) => { + const providerName = req.params.provider + + const rawBody = Buffer.isBuffer(req.body) + ? req.body.toString('utf8') + : typeof req.body === 'string' + ? req.body + : JSON.stringify(req.body ?? {}) + + const headers: Record = {} + for (const [k, v] of Object.entries(req.headers)) { + headers[k.toLowerCase()] = Array.isArray(v) ? v[0] : v + } + + if (!verifyKycWebhookSignature(providerName, rawBody, headers)) { + logger.warn('[KYC] Webhook signature verification failed', { + provider: providerName, + }) + return sendError(res, 401, 'Invalid signature') + } + + let parsedJson: unknown + try { + parsedJson = JSON.parse(rawBody) + } catch { + return sendError(res, 400, 'Malformed webhook JSON') + } + + const parsed = kycWebhookPayloadSchema.safeParse(parsedJson) + if (!parsed.success) { + return sendError(res, 400, 'Invalid KYC webhook payload') + } + + const { userId, tier, providerRef, verifiedAt, reviewNote } = parsed.data + const verifiedTimestamp = verifiedAt + ? new Date(verifiedAt) + : tier === 'TIER_0' + ? null + : new Date() + + const profile = await (db as any).kycProfile.upsert({ + where: { userId }, + create: { + userId, + tier, + providerRef, + verifiedAt: verifiedTimestamp, + reviewNote: reviewNote ?? `Verified via ${providerName} callback`, + }, + update: { + tier, + providerRef, + verifiedAt: verifiedTimestamp, + reviewNote: reviewNote ?? `Updated via ${providerName} callback`, + }, + }) + + logger.info('[KYC] User KYC tier updated via verified provider webhook', { + userId, + provider: providerName, + tier: profile.tier, + providerRef: profile.providerRef, + }) + + return res.status(200).json({ + received: true, + userId: profile.userId, + tier: profile.tier, + providerRef: profile.providerRef, + verifiedAt: profile.verifiedAt + ? new Date(profile.verifiedAt).toISOString() + : null, + }) + } +) + +export default router diff --git a/src/routes/registry.ts b/src/routes/registry.ts index 9ce7a9a0..66fea290 100644 --- a/src/routes/registry.ts +++ b/src/routes/registry.ts @@ -34,6 +34,7 @@ import assistantRouter from './assistant' import authRouter from './auth' import depositRouter from './deposit' import fiatRouter from './fiat' +import kycRouter from './kyc' import healthRouter from './health' import keysRouter from './keys' import liquidityFloorRouter from './liquidity-floor' @@ -112,6 +113,7 @@ export const API_ROUTES: ApiRoute[] = [ { path: 'analytics', handlers: [analyticsRouter] }, { path: 'stellar', handlers: [stellarRouter] }, { path: 'fiat', handlers: [fiatRouter] }, + { path: 'kyc', handlers: [kycRouter] }, { path: 'referrals', handlers: [referralsRouter] }, { path: 'deposit/recurring', handlers: [recurringDepositRouter] }, { path: 'recurring-withdrawals', handlers: [recurringWithdrawalRouter] }, diff --git a/src/utils/api-formatters.ts b/src/utils/api-formatters.ts index 243415e5..bd099f95 100644 --- a/src/utils/api-formatters.ts +++ b/src/utils/api-formatters.ts @@ -244,6 +244,17 @@ const USER_EVENT_PAYLOAD_ALLOWLIST: Record = { 'settledCryptoAmount', 'driftPct', ], + 'fiat.limit.approaching': [ + 'orderId', + 'direction', + 'tier', + 'window', + 'utilizationPct', + 'used', + 'limit', + 'remaining', + 'currency', + ], 'recurring_deposit.executed': [ 'planId', 'amount', diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index f6d3a967..3cdbf660 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -518,6 +518,20 @@ export const fiatRateDriftPct = new client.Histogram({ registers: [register], }) +export const fiatLimitEvaluationsTotal = new client.Counter({ + name: 'fiat_limit_evaluations_total', + help: 'Total fiat transaction limit and velocity evaluations (#362)', + labelNames: ['outcome', 'reason', 'tier', 'direction'] as const, + registers: [register], +}) + +export const fiatLimitHeadroomReleasedTotal = new client.Counter({ + name: 'fiat_limit_headroom_released_total', + help: 'Total pending/settled fiat order headroom releases due to FAILED or REFUNDED status (#362)', + labelNames: ['status', 'direction'] as const, + registers: [register], +}) + // ── Helper Functions ───────────────────────────────────────────────────────────── /** @@ -784,6 +798,28 @@ export function recordFiatRateDrift( fiatRateDriftPct.observe({ provider, direction }, absDriftPct) } +/** + * Record a fiat transaction limit / velocity evaluation (#362). + */ +export function recordFiatLimitEvaluation( + outcome: 'allowed' | 'denied' | 'soft_warn', + reason: string, + tier: string, + direction: string +): void { + fiatLimitEvaluationsTotal.inc({ outcome, reason, tier, direction }) +} + +/** + * Record a pending/settled fiat order headroom release on FAILED or REFUNDED (#362). + */ +export function recordFiatLimitHeadroomRelease( + status: 'FAILED' | 'REFUNDED', + direction: string +): void { + fiatLimitHeadroomReleasedTotal.inc({ status, direction }) +} + // ── Outbox Metrics (#325) ──────────────────────────────────────────────────── export const outboxOpsTotal = new client.Counter({ diff --git a/src/validators/webhook-validators.ts b/src/validators/webhook-validators.ts index 5b64ec82..f3f79b99 100644 --- a/src/validators/webhook-validators.ts +++ b/src/validators/webhook-validators.ts @@ -34,6 +34,7 @@ const WEBHOOK_EVENTS = [ 'fiat.order.settled', 'fiat.order.failed', 'fiat.order.rate_mismatch', + 'fiat.limit.approaching', 'recurring_deposit.executed', 'recurring_deposit.failed', 'alert_rule.triggered', diff --git a/tests/integration/fiat-limits.integration.test.ts b/tests/integration/fiat-limits.integration.test.ts new file mode 100644 index 00000000..286f61c5 --- /dev/null +++ b/tests/integration/fiat-limits.integration.test.ts @@ -0,0 +1,413 @@ +/** + * Integration tests for Fiat Transaction Limits, Velocity Controls & KYC-Tier Enforcement (#362). + * + * Covers: + * - GET /api/v1/fiat/limits + * - POST /api/v1/fiat/orders (403 fiat_limit_exceeded on denial, soft warning + fiat.limit.approaching on >80%, compliance_freeze check) + * - POST /api/v1/kyc/verify & POST /api/v1/kyc/webhook/:provider + * - GET/PUT /api/v1/admin/fiat/limit-policies & POST /api/v1/admin/users/:id/kyc-tier + */ +process.env.NODE_ENV = 'test' +process.env.STELLAR_NETWORK = 'testnet' +process.env.STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org' +process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55) +process.env.VAULT_CONTRACT_ID = 'C' + 'A'.repeat(55) +process.env.USDC_TOKEN_ADDRESS = 'C' + 'B'.repeat(55) +process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key' +process.env.DATABASE_URL = 'postgresql://localhost:5432/test' +process.env.JWT_SEED = '0'.repeat(64) +process.env.WALLET_ENCRYPTION_KEY = '0'.repeat(64) +process.env.TWILIO_AUTH_TOKEN = '0'.repeat(32) +process.env.KYC_WEBHOOK_SECRET = 'kyc-test-webhook-secret' + +import { createHmac } from 'crypto' +import request from 'supertest' +import express from 'express' + +const mockUserId = '11111111-1111-4111-8111-111111111111' + +jest.mock('../../src/middleware/authenticate', () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.userId = mockUserId + req.auth = { userId: mockUserId, walletAddress: 'GWALLET_USER_1' } + next() + }, + enforceUserAccess: (req: any, res: any, next: any) => { + const target = req.params.userId ?? req.body?.userId + if (target && target !== req.auth.userId) { + return res.status(403).json({ error: 'Forbidden' }) + } + next() + }, +})) + +jest.mock('../../src/middleware/idempotency', () => ({ + idempotent: () => (_req: any, _res: any, next: any) => next(), +})) + +jest.mock('../../src/middleware/adminAuth', () => ({ + requireAdminAuth: (_req: any, res: any, next: any) => { + res.locals.adminAuth = { + id: 'admin-key-1', + name: 'ComplianceAdmin', + role: 'super', + scopes: ['super', 'fiat:read', 'fiat:write'], + } + next() + }, + requireAdminScope: () => (_req: any, _res: any, next: any) => next(), + getAdminAuth: (res: any) => res.locals.adminAuth, + hasAdminScope: () => true, + validateScopesInput: () => true, +})) + +const mockPublishUserEvent = jest.fn().mockResolvedValue(undefined) +jest.mock('../../src/events/publisher', () => ({ + publishUserEvent: (...args: unknown[]) => mockPublishUserEvent(...args), +})) + +jest.mock('../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +const kycProfiles = new Map() +const limitPolicies = new Map() +const fiatOrders: any[] = [] +const complianceCases: any[] = [] +const adminAuditLogs: any[] = [] + +jest.mock('../../src/db', () => ({ + __esModule: true, + default: { + user: { + findUnique: jest.fn(async ({ where }: any) => ({ + id: where.id, + isActive: true, + })), + }, + complianceCase: { + findFirst: jest.fn(async ({ where }: any) => { + return ( + complianceCases.find( + (c) => + c.userId === where.userId && where.status.in.includes(c.status) + ) ?? null + ) + }), + }, + kycProfile: { + findUnique: jest.fn( + async ({ where }: any) => kycProfiles.get(where.userId) ?? null + ), + upsert: jest.fn(async ({ where, create, update }: any) => { + const existing = kycProfiles.get(where.userId) + const next = existing + ? { ...existing, ...update, updatedAt: new Date() } + : { ...create, updatedAt: new Date() } + kycProfiles.set(where.userId, next) + return next + }), + }, + fiatLimitPolicy: { + findUnique: jest.fn( + async ({ where }: any) => limitPolicies.get(where.tier) ?? null + ), + findMany: jest.fn(async () => Array.from(limitPolicies.values())), + upsert: jest.fn(async ({ where, create, update }: any) => { + const existing = limitPolicies.get(where.tier) + const next = existing + ? { ...existing, ...update, updatedAt: new Date() } + : { ...create, updatedAt: new Date() } + limitPolicies.set(where.tier, next) + return next + }), + }, + fiatOrder: { + findMany: jest.fn(async ({ where }: any) => + fiatOrders.filter((o) => o.userId === where.userId) + ), + findUnique: jest.fn( + async ({ where }: any) => + fiatOrders.find((o) => o.id === where.id) ?? null + ), + create: jest.fn(async ({ data }: any) => { + const created = { + id: `order-${fiatOrders.length + 1}`, + createdAt: new Date(), + updatedAt: new Date(), + ...data, + } + fiatOrders.push(created) + return created + }), + }, + adminAuditLog: { + create: jest.fn(async ({ data }: any) => { + adminAuditLogs.push(data) + return { id: `audit-${adminAuditLogs.length}`, ...data } + }), + }, + }, +})) + +jest.mock('../../src/fiat/registry', () => ({ + getDefaultProvider: () => ({ + name: 'moonpay', + getQuote: jest.fn().mockResolvedValue({ + provider: 'moonpay', + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + cryptoAmount: 98.5, + rate: 0.985, + fees: { providerFee: 1.5, networkFee: 0, fxSpread: 0 }, + unpriced: false, + }), + createOrder: jest.fn().mockResolvedValue({ + providerOrderId: `mp_${Date.now()}`, + checkoutUrl: 'https://buy.moonpay.com/tx', + status: 'PENDING', + cryptoAmount: 98.5, + }), + verifyWebhookSignature: () => false, + }), + getProvider: (name: string) => ({ + name, + getQuote: jest.fn().mockResolvedValue({ + provider: name, + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + cryptoAmount: 98.5, + rate: 0.985, + fees: { providerFee: 1.5, networkFee: 0, fxSpread: 0 }, + unpriced: false, + }), + createOrder: jest.fn().mockResolvedValue({ + providerOrderId: `mp_${Date.now()}_${Math.random()}`, + checkoutUrl: 'https://buy.moonpay.com/tx', + status: 'PENDING', + cryptoAmount: 98.5, + }), + verifyWebhookSignature: () => false, + }), + selectProviderForOrder: () => ({ + name: 'moonpay', + getQuote: jest.fn().mockResolvedValue({ + provider: 'moonpay', + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + cryptoAmount: 98.5, + rate: 0.985, + fees: { providerFee: 1.5, networkFee: 0, fxSpread: 0 }, + unpriced: false, + }), + }), + recordProviderSuccess: jest.fn(), + recordProviderFailure: jest.fn(), + getAllProviderHealth: () => [], + adminSetProviderCircuit: jest.fn(), +})) + +import fiatRouter from '../../src/routes/fiat' +import kycRouter from '../../src/routes/kyc' +import adminRouter from '../../src/routes/admin' + +function buildApp() { + const app = express() + // Keep raw body for webhook routes + app.use('/api/v1/kyc/webhook', express.raw({ type: '*/*', limit: '1mb' })) + app.use(express.json()) + app.use('/api/v1/fiat', fiatRouter) + app.use('/api/v1/kyc', kycRouter) + app.use('/api/v1/admin', adminRouter) + return app +} + +const app = buildApp() + +beforeEach(() => { + kycProfiles.clear() + limitPolicies.clear() + fiatOrders.length = 0 + complianceCases.length = 0 + adminAuditLogs.length = 0 + jest.clearAllMocks() +}) + +describe('Fiat Limits, Velocity & KYC-Tier Enforcement Integration (#362)', () => { + it('denies unverified (TIER_0) users on POST /api/v1/fiat/orders with 403 fiat_limit_exceeded (tier_zero)', async () => { + const res = await request(app).post('/api/v1/fiat/orders').send({ + userId: mockUserId, + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + + expect(res.status).toBe(403) + expect(res.body.details.code).toBe('fiat_limit_exceeded') + expect(res.body.details.reason).toBe('tier_zero') + expect(res.body.details.tier).toBe('TIER_0') + }) + + it('initiates KYC verification via POST /api/v1/kyc/verify and updates tier only via signed webhook', async () => { + // Client cannot pass `tier` to /kyc/verify + const badRes = await request(app) + .post('/api/v1/kyc/verify') + .send({ provider: 'moonpay', tier: 'TIER_3' }) + expect(badRes.status).toBe(400) + + const verifyRes = await request(app) + .post('/api/v1/kyc/verify') + .send({ provider: 'moonpay' }) + expect(verifyRes.status).toBe(200) + expect(verifyRes.body.status).toBe('PENDING') + expect(verifyRes.body.currentTier).toBe('TIER_0') + + // Unsigned / bad signature webhook is rejected with 401 + const payload = JSON.stringify({ + userId: mockUserId, + tier: 'TIER_1', + providerRef: 'prov_ref_abc123', + }) + const unauthWebhook = await request(app) + .post('/api/v1/kyc/webhook/moonpay') + .set('Content-Type', 'application/json') + .set('x-kyc-signature', 'invalid-sig') + .send(payload) + expect(unauthWebhook.status).toBe(401) + + // Valid HMAC-signed provider callback updates KycProfile.tier to TIER_1 + const validSig = createHmac('sha256', process.env.KYC_WEBHOOK_SECRET!) + .update(payload) + .digest('hex') + const validWebhook = await request(app) + .post('/api/v1/kyc/webhook/moonpay') + .set('Content-Type', 'application/json') + .set('x-kyc-signature', validSig) + .send(payload) + expect(validWebhook.status).toBe(200) + expect(validWebhook.body.tier).toBe('TIER_1') + + // GET /api/v1/fiat/limits now reflects TIER_1 + const limitsRes = await request(app).get('/api/v1/fiat/limits') + expect(limitsRes.status).toBe(200) + expect(limitsRes.body.tier).toBe('TIER_1') + expect(limitsRes.body.windows.perTxn.limit).toBe(1000) + expect(limitsRes.body.windows.daily.limit).toBe(2500) + }) + + it('attaches limitWarning and emits fiat.limit.approaching when order exceeds 80% of a window limit', async () => { + kycProfiles.set(mockUserId, { + userId: mockUserId, + tier: 'TIER_1', + providerRef: 'ref-1', + }) + + // $900 is 90% of TIER_1 perTxnMax ($1,000) -> > 80% soft warning threshold + const res = await request(app).post('/api/v1/fiat/orders').send({ + userId: mockUserId, + direction: 'ON_RAMP', + fiatAmount: 900, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + + expect(res.status).toBe(201) + expect(res.body.limitWarning).toBeDefined() + expect(res.body.limitWarning.utilizationPct).toBe(90) + expect(mockPublishUserEvent).toHaveBeenCalledWith( + mockUserId, + 'alerts', + 'fiat.limit.approaching', + expect.objectContaining({ + tier: 'TIER_1', + window: 'per_txn', + utilizationPct: 90, + }) + ) + }) + + it('denies frozen user (#321 active ComplianceCase) with compliance_freeze even at TIER_3', async () => { + kycProfiles.set(mockUserId, { + userId: mockUserId, + tier: 'TIER_3', + providerRef: 'ref-3', + }) + complianceCases.push({ + id: 'case-1', + userId: mockUserId, + status: 'INVESTIGATING', + }) + + const res = await request(app).post('/api/v1/fiat/orders').send({ + userId: mockUserId, + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) + + expect(res.status).toBe(403) + expect(res.body.details.code).toBe('fiat_limit_exceeded') + expect(res.body.details.reason).toBe('compliance_freeze') + }) + + it('supports admin limit-policy tuning and audited manual KYC tier overrides', async () => { + // Manual KYC override without reviewNote is rejected with 400 + const missingNote = await request(app) + .post(`/api/v1/admin/users/${mockUserId}/kyc-tier`) + .send({ tier: 'TIER_2' }) + expect(missingNote.status).toBe(400) + + // Manual KYC override with reviewNote succeeds and logs to adminAuditLog + const overrideRes = await request(app) + .post(`/api/v1/admin/users/${mockUserId}/kyc-tier`) + .send({ + tier: 'TIER_2', + reviewNote: 'Enhanced verification reviewed manually by compliance', + }) + expect(overrideRes.status).toBe(200) + expect(overrideRes.body.data.tier).toBe('TIER_2') + expect( + adminAuditLogs.some((a) => a.action === 'OVERRIDE_USER_KYC_TIER') + ).toBe(true) + + // Admin updates TIER_2 policy via PUT /api/v1/admin/fiat/limit-policies + const putPolicy = await request(app) + .put('/api/v1/admin/fiat/limit-policies') + .send({ + tier: 'TIER_2', + perTxnMax: 15000, + dailyMax: 30000, + monthlyMax: 120000, + maxTxnsPerHour: 6, + maxTxnsPerDay: 25, + currency: 'USD', + }) + expect(putPolicy.status).toBe(200) + expect(putPolicy.body.data.policies[0].perTxnMax).toBe(15000) + expect( + adminAuditLogs.some((a) => a.action === 'UPDATE_FIAT_LIMIT_POLICIES') + ).toBe(true) + + const getPolicies = await request(app).get( + '/api/v1/admin/fiat/limit-policies' + ) + expect(getPolicies.status).toBe(200) + const tier2 = getPolicies.body.data.policies.find( + (p: any) => p.tier === 'TIER_2' + ) + expect(tier2.perTxnMax).toBe(15000) + }) +}) diff --git a/tests/unit/fiat/limits.test.ts b/tests/unit/fiat/limits.test.ts new file mode 100644 index 00000000..77029bdf --- /dev/null +++ b/tests/unit/fiat/limits.test.ts @@ -0,0 +1,463 @@ +/** + * Unit tests for Fiat Transaction Limits, Velocity Controls & KYC-Tier Enforcement (#362). + */ +import { + evaluateFiatOrder, + computeFiatLimitsSummary, + evaluateScheduledFiatRun, + DEFAULT_FIAT_LIMIT_POLICIES, + HOUR_MS, + DAY_MS, + type HistoricalFiatOrder, + type ScheduledFiatPlanState, +} from '../../../src/fiat/limits' + +describe('evaluateFiatOrder (#362)', () => { + const now = new Date('2026-10-27T12:00:00.000Z') + + it('checks compliance freeze (#321) FIRST before tier or limits', () => { + const res = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 50, + currency: 'USD', + tier: 'TIER_3', + recentOrders: [], + now, + isFrozen: true, + }) + + expect(res.allowed).toBe(false) + expect(res.reason).toBe('compliance_freeze') + }) + + it('denies TIER_0 users with tier_zero reason', () => { + const res = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 10, + currency: 'USD', + tier: 'TIER_0', + recentOrders: [], + now, + }) + + expect(res.allowed).toBe(false) + expect(res.reason).toBe('tier_zero') + expect(res.limit).toBe(0) + }) + + it('enforces per_txn limit and stricter offRampOverrides for OFF_RAMP', () => { + // TIER_1 on-ramp perTxnMax is $1,000; off-ramp perTxnMax is $500 + const onRampAllowed = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 800, + currency: 'USD', + tier: 'TIER_1', + recentOrders: [], + now, + }) + expect(onRampAllowed.allowed).toBe(true) + + const offRampDenied = evaluateFiatOrder({ + userId: 'user-1', + direction: 'OFF_RAMP', + amount: 800, + currency: 'USD', + tier: 'TIER_1', + recentOrders: [], + now, + }) + expect(offRampDenied.allowed).toBe(false) + expect(offRampDenied.reason).toBe('per_txn') + expect(offRampDenied.limit).toBe(500) + + const onRampDenied = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 1200, + currency: 'USD', + tier: 'TIER_1', + recentOrders: [], + now, + }) + expect(onRampDenied.allowed).toBe(false) + expect(onRampDenied.reason).toBe('per_txn') + expect(onRampDenied.limit).toBe(1000) + }) + + it('enforces rolling 1-hour velocity (velocity_hourly) and computes retryAfter', () => { + // TIER_1 allows maxTxnsPerHour = 3 + const recentOrders: HistoricalFiatOrder[] = [ + { + fiatAmount: 100, + fiatCurrency: 'USD', + status: 'SETTLED', + createdAt: new Date(now.getTime() - 50 * 60 * 1000), + }, + { + fiatAmount: 100, + fiatCurrency: 'USD', + status: 'PROCESSING', + createdAt: new Date(now.getTime() - 30 * 60 * 1000), + }, + { + fiatAmount: 100, + fiatCurrency: 'USD', + status: 'PENDING', + createdAt: new Date(now.getTime() - 10 * 60 * 1000), + }, + ] + + const res = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 100, + currency: 'USD', + tier: 'TIER_1', + recentOrders, + now, + }) + + expect(res.allowed).toBe(false) + expect(res.reason).toBe('velocity_hourly') + expect(res.limit).toBe(3) + expect(res.used).toBe(3) + expect(res.remaining).toBe(0) + // Oldest order was 50m ago, so it ages out of the 1h window in 10m (600s) + expect(res.retryAfter).toBe(600) + }) + + it('enforces rolling 24-hour velocity (velocity_daily) across midnight boundaries', () => { + // TIER_1 allows maxTxnsPerDay = 10, maxTxnsPerHour = 3 + // Spread 10 orders across the last 20 hours (at most 1 every 2 hours so hourly velocity is fine) + const recentOrders: HistoricalFiatOrder[] = Array.from( + { length: 10 }, + (_, i) => ({ + fiatAmount: 50, + fiatCurrency: 'USD', + status: 'SETTLED', + createdAt: new Date(now.getTime() - (20 - i * 2) * HOUR_MS), + }) + ) + + const res = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 50, + currency: 'USD', + tier: 'TIER_1', + recentOrders, + now, + }) + + expect(res.allowed).toBe(false) + expect(res.reason).toBe('velocity_daily') + expect(res.limit).toBe(10) + expect(res.used).toBe(10) + expect(res.remaining).toBe(0) + expect(res.retryAfter).toBeGreaterThan(0) + }) + + it('enforces rolling 24-hour volume cap (daily) and rolling 30-day volume cap (monthly)', () => { + // TIER_1 dailyMax = $2,500, monthlyMax = $10,000 + const recentDailyOrders: HistoricalFiatOrder[] = [ + { + fiatAmount: 1000, + fiatCurrency: 'USD', + status: 'SETTLED', + createdAt: new Date(now.getTime() - 10 * HOUR_MS), + }, + { + fiatAmount: 1000, + fiatCurrency: 'USD', + status: 'PENDING', + createdAt: new Date(now.getTime() - 5 * HOUR_MS), + }, + ] + + const dailyDenied = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 600, + currency: 'USD', + tier: 'TIER_1', + recentOrders: recentDailyOrders, + now, + }) + expect(dailyDenied.allowed).toBe(false) + expect(dailyDenied.reason).toBe('daily') + expect(dailyDenied.limit).toBe(2500) + expect(dailyDenied.used).toBe(2000) + expect(dailyDenied.remaining).toBe(500) + + // Spread $9,500 across days 3..15 ago so daily used is $0 but monthly used is $9,500 + const recentMonthlyOrders: HistoricalFiatOrder[] = Array.from( + { length: 10 }, + (_, i) => ({ + fiatAmount: 950, + fiatCurrency: 'USD', + status: 'SETTLED', + createdAt: new Date(now.getTime() - (3 + i) * DAY_MS), + }) + ) + + const monthlyDenied = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 600, + currency: 'USD', + tier: 'TIER_1', + recentOrders: recentMonthlyOrders, + now, + }) + expect(monthlyDenied.allowed).toBe(false) + expect(monthlyDenied.reason).toBe('monthly') + expect(monthlyDenied.limit).toBe(10000) + expect(monthlyDenied.used).toBe(9500) + expect(monthlyDenied.remaining).toBe(500) + }) + + it('reserves headroom for PENDING orders in a burst (10 concurrent attempts, only N fit) and releases when FAILED', () => { + // TIER_1: maxTxnsPerHour = 3, perTxnMax = 1000, dailyMax = 2500 + const orderHistory: HistoricalFiatOrder[] = [] + let allowedCount = 0 + let deniedCount = 0 + + for (let i = 0; i < 10; i++) { + const evalRes = evaluateFiatOrder({ + userId: 'user-burst', + direction: 'ON_RAMP', + amount: 500, + currency: 'USD', + tier: 'TIER_1', + recentOrders: orderHistory, + now, + }) + if (evalRes.allowed) { + allowedCount++ + // Immediately record as PENDING in history + orderHistory.push({ + id: `pending-${i}`, + fiatAmount: 500, + fiatCurrency: 'USD', + status: 'PENDING', + createdAt: now, + }) + } else { + deniedCount++ + } + } + + expect(allowedCount).toBe(3) + expect(deniedCount).toBe(7) + + // Now one of the PENDING orders fails 1 minute later -> headroom is released as of failure time + const failTime = new Date(now.getTime() + 60_000) + orderHistory[0].status = 'FAILED' + orderHistory[0].updatedAt = failTime + + const afterFailEval = evaluateFiatOrder({ + userId: 'user-burst', + direction: 'ON_RAMP', + amount: 500, + currency: 'USD', + tier: 'TIER_1', + recentOrders: orderHistory, + now: failTime, + }) + expect(afterFailEval.allowed).toBe(true) + }) + + it('releases REFUNDED order headroom as of refund time without retroactively rewriting prior window state', () => { + const orderCreated = new Date('2026-10-05T10:00:00.000Z') + const refundTime = new Date('2026-10-15T10:00:00.000Z') + + const refundedOrder: HistoricalFiatOrder = { + id: 'ord-refunded', + fiatAmount: 9500, + fiatCurrency: 'USD', + status: 'REFUNDED', + createdAt: orderCreated, + updatedAt: refundTime, + refundedAt: refundTime, + } + + // Evaluating at 2026-10-10 (BEFORE refundTime): the order was still active/settled then, so it counted! + const beforeRefund = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 800, + currency: 'USD', + tier: 'TIER_1', + recentOrders: [refundedOrder], + now: new Date('2026-10-10T10:00:00.000Z'), + }) + expect(beforeRefund.allowed).toBe(false) + expect(beforeRefund.reason).toBe('monthly') + + // Evaluating at 2026-10-16 (AFTER refundTime): headroom is released as of refundTime! + const afterRefund = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 800, + currency: 'USD', + tier: 'TIER_1', + recentOrders: [refundedOrder], + now: new Date('2026-10-16T10:00:00.000Z'), + }) + expect(afterRefund.allowed).toBe(true) + }) + + it('converts cross-currency orders via FX feed (rateSource: FX_FEED) and fails closed when FX feed is down', () => { + // 920 EUR at 0.92 EUR/USD = 1,000 USD (fits TIER_1 perTxnMax = 1000) + const allowedCrossCcy = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 920, + currency: 'EUR', + tier: 'TIER_1', + recentOrders: [], + now, + }) + expect(allowedCrossCcy.allowed).toBe(true) + expect(allowedCrossCcy.rateSource).toBe('FX_FEED') + expect(allowedCrossCcy.baseAmount).toBeCloseTo(1000, 1) + + // 950 EUR > 1,000 USD -> denied by per_txn + const deniedCrossCcy = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 950, + currency: 'EUR', + tier: 'TIER_1', + recentOrders: [], + now, + }) + expect(deniedCrossCcy.allowed).toBe(false) + expect(deniedCrossCcy.reason).toBe('per_txn') + + // FX feed down -> fail closed with fx_unavailable + const fxDown = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 100, + currency: 'EUR', + tier: 'TIER_1', + recentOrders: [], + now, + fxFeedAvailable: false, + }) + expect(fxDown.allowed).toBe(false) + expect(fxDown.reason).toBe('fx_unavailable') + + // Unknown currency -> fail closed with fx_unavailable + const unknownCcy = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 100, + currency: 'ZZZ', + tier: 'TIER_1', + recentOrders: [], + now, + }) + expect(unknownCcy.allowed).toBe(false) + expect(unknownCcy.reason).toBe('fx_unavailable') + }) + + it('attaches a soft limitWarning when an allowed order pushes a window above LIMIT_SOFT_WARN_PCT', () => { + // TIER_1 dailyMax = 2500; prior 24h used = 1500; new order = 600 -> 2100 / 2500 = 84% (> 80%) + const res = evaluateFiatOrder({ + userId: 'user-1', + direction: 'ON_RAMP', + amount: 600, + currency: 'USD', + tier: 'TIER_1', + recentOrders: [ + { + fiatAmount: 1000, + fiatCurrency: 'USD', + status: 'SETTLED', + createdAt: new Date(now.getTime() - 4 * HOUR_MS), + }, + { + fiatAmount: 500, + fiatCurrency: 'USD', + status: 'SETTLED', + createdAt: new Date(now.getTime() - 2 * HOUR_MS), + }, + ], + now, + }) + + expect(res.allowed).toBe(true) + expect(res.limitWarning).toBeDefined() + expect(res.limitWarning?.window).toBe('daily') + expect(res.limitWarning?.utilizationPct).toBe(84) + }) + + it('auto-pauses a scheduled fiat plan after N consecutive skipped_limit runs', () => { + let plan: ScheduledFiatPlanState = { + id: 'plan-1', + userId: 'user-1', + status: 'ACTIVE', + consecutiveSkippedLimit: 0, + } + + for (let i = 1; i <= 3; i++) { + const run = evaluateScheduledFiatRun({ + plan, + maxConsecutiveSkips: 3, + evaluationInput: { + userId: 'user-1', + direction: 'ON_RAMP', + amount: 1500, // exceeds TIER_1 perTxnMax = 1000 + currency: 'USD', + tier: 'TIER_1', + recentOrders: [], + now, + }, + }) + expect(run.outcome).toBe('skipped_limit') + expect(run.consecutiveSkippedLimit).toBe(i) + if (i < 3) { + expect(run.shouldAutoPause).toBe(false) + expect(run.nextStatus).toBe('ACTIVE') + } else { + expect(run.shouldAutoPause).toBe(true) + expect(run.nextStatus).toBe('PAUSED') + } + plan = { + ...plan, + status: run.nextStatus, + consecutiveSkippedLimit: run.consecutiveSkippedLimit, + } + } + }) + + it('computes caller limits summary for GET /api/v1/fiat/limits', () => { + const summary = computeFiatLimitsSummary({ + tier: 'TIER_2', + direction: 'ON_RAMP', + recentOrders: [ + { + fiatAmount: 2000, + fiatCurrency: 'USD', + status: 'SETTLED', + createdAt: new Date(now.getTime() - 20 * 60 * 1000), + }, + ], + now, + policy: DEFAULT_FIAT_LIMIT_POLICIES.TIER_2, + }) + + expect(summary.tier).toBe('TIER_2') + expect(summary.currency).toBe('USD') + expect(summary.windows.daily.limit).toBe(25000) + expect(summary.windows.daily.used).toBe(2000) + expect(summary.windows.daily.remaining).toBe(23000) + expect(summary.windows.velocityHourly.used).toBe(1) + expect(summary.windows.velocityHourly.remaining).toBe(4) + }) +})