diff --git a/.agents/skills/sentry-vortex/SKILL.md b/.agents/skills/sentry-vortex/SKILL.md new file mode 100644 index 000000000..bd8942fe1 --- /dev/null +++ b/.agents/skills/sentry-vortex/SKILL.md @@ -0,0 +1,73 @@ +--- +name: sentry-vortex +description: Audit code against Vortex's Sentry conventions and guide correct error instrumentation. Triggers on: sentry, captureException, error reporting, error monitoring, beforeSend, ignoreErrors, adding an API service method, new error class, new XState machine error handling, ErrorBoundary, "is this reported to Sentry". +user-invocable: true +argument-hint: "[files or 'current changes' — defaults to the working-tree diff]" +--- + +Audit changed code against Vortex's established Sentry conventions and report violations with concrete fixes; also the authority for how to instrument new code correctly. + +## MANDATORY PREPARATION + +1. Read the canonical implementation so you check against real code, not memory: + - `apps/frontend/src/helpers/sentry.ts` + - `apps/frontend/src/services/api/api-client.ts` + - `apps/frontend/src/machines/ramp.machine.ts` +2. Determine scope: the argument, or `git diff --name-only HEAD` if blank. + +--- + +## Diagnostic Scan + +Check each rule. Report a finding only when violated. + +### Configuration (single source of truth) +- All filtering lives in `helpers/sentry.ts`. New ignore/deny/scrub logic edits that file — **NEVER** inline new filter config into `Sentry.init` in `main.tsx`. +- `environment: config.env` (never `config.isProd ? ... : ...`). Sampling stays prod-gated. +- Session Replay keeps `maskAllText: true` + `blockAllMedia: true`. + +### Error classes +- A custom `Error` whose failures should be filterable by business area **must** `implements DomainError` and set `domain`. API errors derive it from `getApiDomain()` (the source of truth for domain values); non-API errors set it directly (e.g. `SignRampError` → `wallet`). + +### API layer (`services/api/`) +- A new top-level path segment (e.g. `/payouts/...`) **must** be added to `getApiDomain()`. +- `ApiError` messages run through `normalizePath()` — **CRITICAL**: never interpolate raw ids/addresses into an error message or Sentry tag; it fragments grouping and can leak PII. + +### Capture points +- Money-flow / machine failures are captured at the machine's terminal error state (`captureActorError` pattern), skipping `SignRampError` `UserRejected`. +- **NEVER** call `Sentry.captureException` inside React components, render paths, or fetch/TanStack-Query interceptors — let errors propagate to the machine error state or the global handler. +- Risky subtrees (widget/KYC/ramp) are wrapped in `Sentry.ErrorBoundary` with a fallback. + +### Noise & privacy +- `ignoreErrors` covers wallet user-rejections, `ResizeObserver` loops, extension-context errors, `AbortError`. **IMPORTANT**: keep `TimeoutError` reportable — a timeout can mean a slow backend. +- `beforeSend` drops expected client 4xx (`401/403/404/409/429`); `400/422` and all `5xx` are kept. +- No PII in query params, tags, contexts, or messages — `beforeSend` strips query strings, but new code must not route PII somewhere it can't reach. +- **User context**: `Sentry.setUser` is set/cleared only in `AuthService` (plus a startup seed in `main.tsx`) and carries the **pseudonymous Supabase id only** (`{ id: userId }`) — **NEVER** email, wallet, or IP. New auth code must keep this invariant. + +## Generate Report + +Group findings by file, `file:line`, each with the violated rule and a concrete fix. End with a one-line compliance verdict. + +``` +## apps/frontend/src/services/api/payouts.service.ts +payouts.service.ts:14 - new "/payouts" segment not mapped in getApiDomain() → add case "payouts": return "ramp" + +## apps/frontend/src/components/Foo.tsx +Foo.tsx:88 - Sentry.captureException in a component → remove; let it propagate to the machine Error state + +Verdict: 2 violations — not compliant. +``` + +**NEVER:** +- Report a violation without the exact fix. +- Flag `Sentry.captureException` at the sanctioned capture point (`captureActorError`) as a violation. +- Recommend blanket-ignoring `Failed to fetch` / `NetworkError` / `Loading chunk failed` — monitor volume first. +- Recommend adding `SENTRY_AUTH_TOKEN` or `VITE_ENVIRONMENT` in code — they are Netlify build env vars; flag as infra TODOs only. + +## Verify Audit + +- Every changed `services/api/`, error class, and XState machine error path was checked. +- Every finding has a `file:line` and a fix. +- No false positives against the sanctioned patterns in the canonical files. + +A trustworthy Sentry is one where every reported issue is real and actionable — audit to keep noise out and signal in. diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index 17df141d8..cb71f976e 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -503,7 +503,7 @@ try { ## Current corridor reality (May 2026) - **BRL via PIX**: onramp and offramp both live. - **EUR via SEPA**: SDK types exist (`EurOnrampQuote`, `EurOfframpQuote`) but handlers throw `"Euro onramp/offramp handler not implemented yet"` at runtime. Treat as `planned`. -- **ARS via CBU**: offramp only. +- **ARS via CBU**: supported via the AlfredPay corridor; route resolver determines availability per-combination. - **USD / MXN / COP via ACH / SPEI / WIRE**: supported via the AlfredPay corridor; route resolver determines availability per-combination. ## Common failures diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77d24ba5b..f55d17c93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,56 @@ jobs: - name: ✏️ Typecheck run: bun run typecheck + + test: + name: Running tests + runs-on: ubuntu-latest + env: + CI: true + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: vortex_test + ports: + - 54329:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - name: 🛒 Checkout code + uses: actions/checkout@v3 + + - name: 🧩 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.1 + + - name: 🧩 Install dependencies + run: bun install --frozen-lockfile + + - name: 🔨 Build shared package + run: bun run build:shared + + - name: 🧪 Shared tests (coverage-gated) + run: cd packages/shared && bun run test:coverage + + - name: 🧪 SDK tests + run: bun run test:sdk + + - name: 🧪 SDK coverage gate + run: cd packages/sdk && bun run test:coverage + + - name: 🧪 Rebalancer tests (coverage-gated) + run: cd apps/rebalancer && bun run test:coverage + + - name: 🧪 API tests (unit + integration, coverage-gated) + run: cd apps/api && bun run test:coverage + + - name: 🧪 Frontend tests (coverage-gated) + run: cd apps/frontend && bun run test:coverage diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..54be8861d --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,62 @@ +# Non-PR-blocking Playwright E2E journeys (see docs/testing-strategy.md). +# Runs nightly and on demand; failures alert but never gate merges. +name: e2e + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +jobs: + e2e: + name: Playwright E2E journeys + runs-on: ubuntu-latest + env: + CI: true + + steps: + - name: 🛒 Checkout code + uses: actions/checkout@v3 + + - name: 🧩 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.1 + + - name: 🧩 Install dependencies + run: bun install --frozen-lockfile + + - name: 🔨 Build shared package + run: bun run build:shared + + - name: 🌐 Install Playwright browsers + working-directory: apps/frontend + run: bunx playwright install --with-deps chromium + + - name: 🧪 E2E journeys + working-directory: apps/frontend + run: bun run test:e2e + + - name: 📤 Upload report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: apps/frontend/playwright-report/ + retention-days: 7 + + # Non-blocking runs are only useful if somebody hears about failures. + # Uses the same webhook token the backend's Slack notifier uses + # (repo secret SLACK_WEB_HOOK_TOKEN); skips silently when unset. + - name: 📣 Notify Slack on failure + if: failure() + env: + SLACK_WEB_HOOK_TOKEN: ${{ secrets.SLACK_WEB_HOOK_TOKEN }} + run: | + if [ -z "$SLACK_WEB_HOOK_TOKEN" ]; then + echo "SLACK_WEB_HOOK_TOKEN secret not configured; skipping notification." + exit 0 + fi + curl -sf -X POST -H 'Content-Type: application/json' \ + -d "{\"text\":\"Nightly Playwright e2e run failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \ + "https://hooks.slack.com/services/${SLACK_WEB_HOOK_TOKEN}" diff --git a/.gitignore b/.gitignore index a97f19a87..64e43d4ec 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,11 @@ dist dist-ssr *.local +# Coverage reports (any workspace; bun/vitest lcov output) +coverage + # Environment files (any package, any name) -**/.env +packages/sdk/.env **/.env.local **/.env.*.local **/.env.development @@ -57,5 +60,74 @@ CLAUDE.local.md # hardhat generated files in workspace contract projects contracts/*/artifacts contracts/*/cache +contracts/*/.env + +.mcp.json + +# Playwright E2E artifacts +apps/frontend/test-results/ +apps/frontend/playwright-report/ -.mcp.json \ No newline at end of file +# Local credentials and agent-tool artifacts +.api-key.json +.playwright-mcp/ +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/interfaces/IERC1363.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/interfaces/index.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/index.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/token/ERC20/utils/index.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/token/ERC20/ERC20.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/token/ERC20/IERC20.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/token/ERC20/index.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/token/index.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/utils/introspection/IERC165.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/utils/introspection/index.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/utils/index.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/utils/ReentrancyGuard.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/contracts/index.ts +/contracts/cctp-settlement/typechain-types/@openzeppelin/index.ts +/contracts/cctp-settlement/typechain-types/contracts/interfaces/index.ts +/contracts/cctp-settlement/typechain-types/contracts/interfaces/ITokenMessengerV2.ts +/contracts/cctp-settlement/typechain-types/contracts/mocks/index.ts +/contracts/cctp-settlement/typechain-types/contracts/mocks/MockERC20.ts +/contracts/cctp-settlement/typechain-types/contracts/mocks/MockTokenMessengerV2.ts +/contracts/cctp-settlement/typechain-types/contracts/index.ts +/contracts/cctp-settlement/typechain-types/contracts/PerUserCctpSettlement.ts +/contracts/cctp-settlement/typechain-types/contracts/PerUserCctpSettlementFactory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/interfaces/IERC1363__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/interfaces/index.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/index.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/token/ERC20/utils/index.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/token/ERC20/utils/SafeERC20__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/token/ERC20/ERC20__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/token/ERC20/IERC20__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/token/ERC20/index.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/token/index.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/utils/introspection/IERC165__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/utils/introspection/index.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/utils/index.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/utils/ReentrancyGuard__factory.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/contracts/index.ts +/contracts/cctp-settlement/typechain-types/factories/@openzeppelin/index.ts +/contracts/cctp-settlement/typechain-types/factories/contracts/interfaces/index.ts +/contracts/cctp-settlement/typechain-types/factories/contracts/interfaces/ITokenMessengerV2__factory.ts +/contracts/cctp-settlement/typechain-types/factories/contracts/mocks/index.ts +/contracts/cctp-settlement/typechain-types/factories/contracts/mocks/MockERC20__factory.ts +/contracts/cctp-settlement/typechain-types/factories/contracts/mocks/MockTokenMessengerV2__factory.ts +/contracts/cctp-settlement/typechain-types/factories/contracts/index.ts +/contracts/cctp-settlement/typechain-types/factories/contracts/PerUserCctpSettlement__factory.ts +/contracts/cctp-settlement/typechain-types/factories/contracts/PerUserCctpSettlementFactory__factory.ts +/contracts/cctp-settlement/typechain-types/factories/index.ts +/contracts/cctp-settlement/typechain-types/common.ts +/contracts/cctp-settlement/typechain-types/hardhat.d.ts +/contracts/cctp-settlement/typechain-types/index.ts diff --git a/apps/api/bunfig.toml b/apps/api/bunfig.toml new file mode 100644 index 000000000..d82ef9e57 --- /dev/null +++ b/apps/api/bunfig.toml @@ -0,0 +1,18 @@ +[test] +# Only discover tests under src/ — the swc build in dist/ contains compiled +# copies of test files that would otherwise run (stale) a second time. +root = "src" +preload = ["./src/test-utils/preload.ts"] + +# Coverage report config; the gate itself lives in scripts/check-coverage.ts. +# Excluded from the denominator: built/foreign packages (sdk has its own suite), +# migrations (run-once DDL), and generated contract ABIs (const data, always "covered"). +coverageSkipTestFiles = true +coveragePathIgnorePatterns = [ + "**/packages/shared/dist/**", + "**/packages/sdk/**", + "**/test-utils/**", + "**/database/migrations/**", + "**/src/contracts/**" +] + diff --git a/apps/api/package.json b/apps/api/package.json index efe344e22..fdb4f9c98 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -95,7 +95,10 @@ "seed:phase-metadata": "bun -r @swc-node/register src/database/seeders/phase-metadata.ts", "serve": "bun dist/index.js", "start": "bun run build && bun run serve", - "test": "bun test" + "test": "bun test", + "test:coverage": "bun test --coverage --coverage-reporter=lcov && bun ../../scripts/check-coverage.ts coverage/lcov.info 0.55 0.64", + "test:db:start": "./scripts/test-db.sh start", + "test:db:stop": "./scripts/test-db.sh stop" }, "version": "1.0.0" } diff --git a/apps/api/scripts/test-db.sh b/apps/api/scripts/test-db.sh new file mode 100755 index 000000000..0d5aedaa0 --- /dev/null +++ b/apps/api/scripts/test-db.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Manages the dedicated Postgres container for integration tests. +set -euo pipefail + +CONTAINER=vortex-test-db +PORT="${TEST_DB_PORT:-54329}" + +case "${1:-start}" in + start) + if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then + docker start "$CONTAINER" >/dev/null + else + docker run -d --name "$CONTAINER" \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=vortex_test \ + -p "${PORT}:5432" \ + postgres:16-alpine >/dev/null + fi + for _ in $(seq 1 30); do + if docker exec "$CONTAINER" pg_isready -U postgres >/dev/null 2>&1; then + echo "test db ready on port ${PORT}" + exit 0 + fi + sleep 1 + done + echo "test db failed to become ready" >&2 + exit 1 + ;; + stop) + docker rm -f "$CONTAINER" >/dev/null + echo "test db removed" + ;; + *) + echo "usage: $0 [start|stop]" >&2 + exit 1 + ;; +esac diff --git a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts index 075273ef1..35ce23c4c 100644 --- a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts @@ -4,6 +4,7 @@ import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import ApiKey from "../../../models/apiKey.model"; import Partner from "../../../models/partner.model"; +import User from "../../../models/user.model"; import { generateApiKey, getKeyPrefix, hashApiKey } from "../../middlewares/apiKeyAuth.helpers"; /** @@ -13,7 +14,7 @@ import { generateApiKey, getKeyPrefix, hashApiKey } from "../../middlewares/apiK export async function createApiKey(req: Request<{ partnerName: string }>, res: Response): Promise { try { const partnerName = req.params.partnerName; - const { name, expiresAt } = req.body; + const { name, expiresAt, userId } = req.body; // Verify at least one partner with this name exists and is active const partners = await Partner.findAll({ @@ -34,6 +35,34 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R return; } + // Optionally bind the new key pair to a profile (api_keys.user_id). + // The user must already exist; null is the default for partner-only keys. + let resolvedUserId: string | null = null; + if (userId !== undefined && userId !== null && userId !== "") { + if (typeof userId !== "string") { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_USER_ID", + message: "userId must be a string", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + const user = await User.findByPk(userId); + if (!user) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "USER_NOT_FOUND", + message: "Profile was not found", + status: httpStatus.NOT_FOUND + } + }); + return; + } + resolvedUserId = user.id; + } + // Determine environment const environment = config.sandboxEnabled ? "test" : "live"; @@ -57,7 +86,8 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R keyType: "public", keyValue: publicKey, name: name ? `${name} (Public)` : "Public Key", - partnerName + partnerName, + userId: resolvedUserId }); // Create secret key record @@ -69,7 +99,8 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R keyType: "secret", keyValue: null, name: name ? `${name} (Secret)` : "Secret Key", - partnerName + partnerName, + userId: resolvedUserId }); // Return both keys (secret shown only once!) @@ -84,15 +115,18 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R key: publicKey, // Can be shown anytime (it's public) keyPrefix: publicKeyRecord.keyPrefix, name: publicKeyRecord.name, - type: "public" + type: "public", + userId: publicKeyRecord.userId }, secretKey: { id: secretKeyRecord.id, key: secretKey, // Shown only once! keyPrefix: secretKeyRecord.keyPrefix, name: secretKeyRecord.name, - type: "secret" - } + type: "secret", + userId: secretKeyRecord.userId + }, + userId: resolvedUserId }); } catch (error) { logger.error("Error creating API keys:", error); @@ -141,6 +175,7 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re "lastUsedAt", "expiresAt", "isActive", + "userId", "createdAt", "updatedAt" ], @@ -159,7 +194,8 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re lastUsedAt: key.lastUsedAt, name: key.name, type: key.keyType, - updatedAt: key.updatedAt + updatedAt: key.updatedAt, + userId: key.userId })), partnerCount: partners.length, partnerName diff --git a/apps/api/src/api/controllers/alfredpay.controller.test.ts b/apps/api/src/api/controllers/alfredpay.controller.test.ts new file mode 100644 index 000000000..be53c0690 --- /dev/null +++ b/apps/api/src/api/controllers/alfredpay.controller.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import { AlfredpayController } from "./alfredpay.controller"; + +function createResponse() { + const res = { + body: undefined as unknown, + json: mock((body: unknown) => { + res.body = body; + return res; + }), + status: mock((statusCode: number) => { + res.statusCode = statusCode; + return res; + }), + statusCode: Number(httpStatus.OK) + }; + + return res; +} + +describe("Alfredpay fiat account endpoints", () => { + const originalLoggerError = logger.error; + + afterEach(() => { + logger.error = originalLoggerError; + }); + + it("returns a user-binding error for valid secret keys that are not linked to a user", async () => { + logger.error = mock(() => logger) as typeof logger.error; + + const res = createResponse(); + await AlfredpayController.listFiatAccounts( + { + authenticatedPartner: { id: "partner-1", name: "Partner" }, + query: { country: "MX" } + } as never, + res as never + ); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ + error: "This endpoint requires an API key linked to a user or Supabase user authentication." + }); + }); +}); diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 9ec108ff5..6ea82acd0 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -22,8 +22,11 @@ import { SubmitKycInformationRequest } from "@vortexfi/shared"; import { Request, Response } from "express"; +import httpStatus from "http-status"; import logger from "../../config/logger"; import AlfredPayCustomer from "../../models/alfredPayCustomer.model"; +import { getEffectiveUserId } from "../middlewares/effectiveUser"; +import { ALFREDPAY_EFFECTIVE_USER_REQUIRED_MESSAGE } from "../services/quote/alfredpay-customer"; export class AlfredpayController { private static getRequiredUserId(req: Request): string { @@ -34,6 +37,24 @@ export class AlfredpayController { return req.userId; } + private static getFiatAccountUserId(req: Request): string { + const userId = getEffectiveUserId(req); + if (!userId) { + throw new Error(ALFREDPAY_EFFECTIVE_USER_REQUIRED_MESSAGE); + } + return userId; + } + + private static handleFiatAccountError(operation: string, error: unknown, res: Response) { + logger.error(`Error ${operation} fiat account:`, error); + if (error instanceof Error && error.message === ALFREDPAY_EFFECTIVE_USER_REQUIRED_MESSAGE) { + return res.status(httpStatus.BAD_REQUEST).json({ + error: "This endpoint requires an API key linked to a user or Supabase user authentication." + }); + } + return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: "Internal server error" }); + } + private static getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -788,7 +809,7 @@ export class AlfredpayController { documentNumber, isExternal = false } = req.body as AlfredpayAddFiatAccountRequest; - const userId = AlfredpayController.getRequiredUserId(req); + const userId = AlfredpayController.getFiatAccountUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ order: [["updatedAt", "DESC"]], @@ -866,15 +887,14 @@ export class AlfredpayController { res.json(result); } catch (error) { - logger.error("Error adding fiat account:", error); - res.status(500).json({ error: "Internal server error" }); + AlfredpayController.handleFiatAccountError("adding", error, res); } } static async listFiatAccounts(req: Request, res: Response) { try { const { country } = req.query as { country: string }; - const userId = AlfredpayController.getRequiredUserId(req); + const userId = AlfredpayController.getFiatAccountUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ order: [["updatedAt", "DESC"]], @@ -890,8 +910,7 @@ export class AlfredpayController { res.json(accounts); } catch (error) { - logger.error("Error listing fiat accounts:", error); - res.status(500).json({ error: "Internal server error" }); + AlfredpayController.handleFiatAccountError("listing", error, res); } } @@ -899,7 +918,7 @@ export class AlfredpayController { try { const { fiatAccountId } = req.params as { fiatAccountId: string }; const { country } = req.query as { country: string }; - const userId = AlfredpayController.getRequiredUserId(req); + const userId = AlfredpayController.getFiatAccountUserId(req); const alfredPayCustomer = await AlfredPayCustomer.findOne({ order: [["updatedAt", "DESC"]], @@ -915,8 +934,7 @@ export class AlfredpayController { res.status(204).send(); } catch (error) { - logger.error("Error deleting fiat account:", error); - res.status(500).json({ error: "Internal server error" }); + AlfredpayController.handleFiatAccountError("deleting", error, res); } } } diff --git a/apps/api/src/api/controllers/auth.controller.ts b/apps/api/src/api/controllers/auth.controller.ts index 64e6f6c01..b3ab0de36 100644 --- a/apps/api/src/api/controllers/auth.controller.ts +++ b/apps/api/src/api/controllers/auth.controller.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; import logger from "../../config/logger"; import User from "../../models/user.model"; -import { SupabaseAuthService } from "../services/auth"; +import { RefreshTokenError, SupabaseAuthService } from "../services/auth"; export class AuthController { /** @@ -124,9 +124,18 @@ export class AuthController { success: true }); } catch (error) { + // Only a confirmed-invalid refresh token yields a 401 (which the frontend treats as a + // definitive logout). Transient failures — and anything unexpected — return 503 so the + // frontend keeps the session and retries instead of forcing re-login. + if (error instanceof RefreshTokenError && !error.transient) { + return res.status(401).json({ + error: "Invalid refresh token" + }); + } + logger.error("Error in refreshToken:", error); - return res.status(401).json({ - error: "Invalid refresh token" + return res.status(503).json({ + error: "Auth service temporarily unavailable" }); } } diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 3f88a8a3b..66a4d5743 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -1,9 +1,9 @@ -import { AveniaAccountType, BrlaApiService } from "@vortexfi/shared"; -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import {AveniaAccountType, BrlaApiService} from "@vortexfi/shared"; +import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; import httpStatus from "http-status"; import logger from "../../config/logger"; -import TaxId, { TaxIdInternalStatus } from "../../models/taxId.model"; -import { createSubaccount, getAveniaUser } from "./brla.controller"; +import TaxId, {TaxIdInternalStatus} from "../../models/taxId.model"; +import {createSubaccount, getAveniaUser} from "./brla.controller"; function createResponse() { const res = { @@ -60,7 +60,7 @@ describe("getAveniaUser", () => { subAccountId: "subaccount-1" }; - it("returns 400 when taxId is missing", async () => { + it("returns 400 when no effective user is present (anonymous caller)", async () => { mockConfirmedAveniaUser(); const res = createResponse(); @@ -72,10 +72,10 @@ describe("getAveniaUser", () => { ); expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); - expect(res.body).toEqual({ error: "Missing taxId query parameters" }); + expect(res.body).toEqual({ error: "Missing or invalid authentication." }); }); - it("allows partner API key lookups without quoteId", async () => { + it("rejects unlinked partner API key lookups (no effective user)", async () => { mockConfirmedAveniaUser(); const res = createResponse(); @@ -87,6 +87,23 @@ describe("getAveniaUser", () => { res as any ); + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error: "Missing or invalid authentication." }); + }); + + it("allows user-linked API key lookups for the key user's own taxId", async () => { + mockConfirmedAveniaUser("user-1"); + + const res = createResponse(); + await getAveniaUser( + { + apiKeyUserId: "user-1", + authenticatedPartner: { id: "partner-1", name: "Partner" }, + query: { taxId: "08786985906" } + } as any, + res as any + ); + expect(res.statusCode).toBe(httpStatus.OK); expect(res.body).toEqual(expectedConfirmedBody); }); @@ -120,7 +137,7 @@ describe("getAveniaUser", () => { ); expect(res.statusCode).toBe(httpStatus.FORBIDDEN); - expect(res.body).toEqual({ error: "Forbidden" }); + expect(res.body).toEqual({ error: "This tax ID is not linked to your user profile and cannot be used." }); }); }); @@ -181,11 +198,13 @@ describe("createSubaccount", () => { expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); }); - it("rejects when an authenticated caller targets an anonymously-owned existing subaccount", async () => { + it("lets an authenticated caller claim an anonymously-owned existing subaccount record", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); + const updateMock = mock(async () => undefined); TaxId.findByPk = mock(async () => ({ internalStatus: TaxIdInternalStatus.Requested, + update: updateMock, userId: null })) as typeof TaxId.findByPk; @@ -198,8 +217,9 @@ describe("createSubaccount", () => { res as any ); - expect(res.statusCode).toBe(httpStatus.CONFLICT); - expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(httpStatus.OK); + expect(updateMock).toHaveBeenCalledWith({ userId: "some-user" }); + expect(createAveniaSubaccountMock).toHaveBeenCalled(); }); it("allows an authenticated user to (re)create their own subaccount", async () => { diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 189f0cf35..209d757a1 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -37,6 +37,8 @@ import { Op } from "sequelize"; import logger from "../../config/logger"; import TaxId, { TaxIdInternalStatus } from "../../models/taxId.model"; import { APIError } from "../errors/api-error"; +import { getEffectiveUserId } from "../middlewares/effectiveUser"; +import { resolveAveniaAccountForUser } from "../services/avenia-account"; // Helper functions for TaxId updates @@ -148,32 +150,48 @@ export const getAveniaUser = async ( ): Promise => { try { const { taxId } = req.query; + const effectiveUserId = getEffectiveUserId(req); - if (!taxId) { - res.status(httpStatus.BAD_REQUEST).json({ error: "Missing taxId query parameters" }); + if (!effectiveUserId) { + res.status(httpStatus.BAD_REQUEST).json({ + error: "Missing or invalid authentication." + }); return; } const brlaApiService = BrlaApiService.getInstance(); - const taxIdRecord = await TaxId.findOne({ - where: { - internalStatus: { - [Op.ne]: TaxIdInternalStatus.Consulted - }, - taxId: normalizeTaxId(taxId) + let taxIdRecord: TaxId | null; + + if (taxId) { + const normalized = normalizeTaxId(taxId); + taxIdRecord = await TaxId.findOne({ + where: { + internalStatus: { [Op.ne]: TaxIdInternalStatus.Consulted }, + taxId: normalized + } + }); + + if (!taxIdRecord) { + res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount not found" }); + return; } - }); - if (!taxIdRecord) { - res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount not found" }); - return; - } - // When the caller authenticated as a Supabase user, only the owning user may read this taxId. - // Partner SDK callers (no req.userId) are intentionally exempt: they authenticate via API key - // and may need to look up any taxId for their integration flow. - if (req.userId && taxIdRecord.userId !== req.userId) { - res.status(httpStatus.FORBIDDEN).json({ error: "Forbidden" }); - return; + // TaxId must be owned by the effective user. + if (taxIdRecord.userId !== effectiveUserId) { + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); + return; + } + } else { + try { + const resolved = await resolveAveniaAccountForUser(effectiveUserId); + taxIdRecord = resolved.taxIdRecord; + } catch (error) { + if (error instanceof APIError) { + res.status(error.status ?? httpStatus.BAD_REQUEST).json({ error: error.message }); + return; + } + throw error; + } } const accountInfo = await brlaApiService.subaccountInfo(taxIdRecord.subAccountId); @@ -209,6 +227,7 @@ export const recordInitialKycAttempt = async ( ): Promise => { try { const { taxId, quoteId, sessionId } = req.body; + const effectiveUserId = getEffectiveUserId(req); if (!taxId) { res.status(httpStatus.BAD_REQUEST).json({ error: "Missing taxId query parameters" }); @@ -237,7 +256,7 @@ export const recordInitialKycAttempt = async ( internalStatus: TaxIdInternalStatus.Consulted, subAccountId: "", taxId, - userId: req.userId ?? null + userId: effectiveUserId ?? null }); } } @@ -255,25 +274,50 @@ export const getAveniaUserRemainingLimit = async ( ): Promise => { try { const { taxId, direction } = req.query; + const effectiveUserId = getEffectiveUserId(req); - if (!taxId || !direction) { - res.status(httpStatus.BAD_REQUEST).json({ error: "Missing taxId or direction query parameter" }); + if (!direction) { + res.status(httpStatus.BAD_REQUEST).json({ error: "Missing direction query parameter" }); return; } - const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); - if (!taxIdRecord) { - throw new APIError({ - message: "Ramp disabled", - status: httpStatus.BAD_REQUEST + if (!effectiveUserId) { + res.status(httpStatus.BAD_REQUEST).json({ + error: "This endpoint requires authentication." }); + return; } - const brlaApiService = BrlaApiService.getInstance(); - if (!taxIdRecord) { - res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount not found" }); - return; + let taxIdRecord: TaxId | null; + if (taxId) { + taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); + if (!taxIdRecord) { + throw new APIError({ + message: "taxId does not match existing records", + status: httpStatus.BAD_REQUEST + }); + } + + // TaxId must be owned by the effective user. The legacy partner-key + // exemption that allowed reading any taxId has been removed. + if (taxIdRecord.userId !== effectiveUserId) { + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); + return; + } + } else { + try { + const resolved = await resolveAveniaAccountForUser(effectiveUserId); + taxIdRecord = resolved.taxIdRecord; + } catch (error) { + if (error instanceof APIError) { + res.status(error.status ?? httpStatus.BAD_REQUEST).json({ error: error.message }); + return; + } + throw error; + } } + + const brlaApiService = BrlaApiService.getInstance(); const limitsData = await brlaApiService.getSubaccountUsedLimit(taxIdRecord.subAccountId); if (!limitsData || !limitsData.limitInfo || !limitsData.limitInfo.limits) { @@ -316,6 +360,16 @@ export const createSubaccount = async ( ): Promise => { try { const { name, taxId, accountType: requestAccountType, quoteId, sessionId } = req.body; + const effectiveUserId = getEffectiveUserId(req); + + // Reject callers that do not resolve to a user (anonymous requests + // or unlinked secret keys) so the resulting TaxId is always owned by a real profile. + if (!effectiveUserId) { + res.status(httpStatus.BAD_REQUEST).json({ + error: "This endpoint requires authentication." + }); + return; + } const isCnpj = isValidCnpj(taxId); @@ -328,7 +382,7 @@ export const createSubaccount = async ( // on every conflict and to prevent account-takeover via subAccountId overwrite. const existingTaxId = await TaxId.findByPk(normalizedTaxId); if (existingTaxId && existingTaxId.internalStatus !== TaxIdInternalStatus.Consulted) { - const ownedByAnotherUser = existingTaxId.userId !== null && existingTaxId.userId !== (req.userId ?? null); + const ownedByAnotherUser = existingTaxId.userId !== null && existingTaxId.userId !== effectiveUserId; if (ownedByAnotherUser) { res.status(httpStatus.CONFLICT).json({ error: "A subaccount already exists for this taxId" @@ -336,8 +390,8 @@ export const createSubaccount = async ( return; } // Allow authenticated users to claim anonymous records by updating userId - if (existingTaxId.userId === null && req.userId) { - await existingTaxId.update({ userId: req.userId }); + if (existingTaxId.userId === null) { + await existingTaxId.update({ userId: effectiveUserId }); } } @@ -363,7 +417,7 @@ export const createSubaccount = async ( requestedDate: new Date(), subAccountId: id, taxId: normalizedTaxId, - userId: req.userId ?? null + userId: effectiveUserId }); } @@ -544,7 +598,7 @@ export const getUploadUrls = async ( } if (!req.userId || taxIdRecord.userId !== req.userId) { - res.status(httpStatus.FORBIDDEN).json({ error: "Forbidden" }); + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } @@ -600,7 +654,7 @@ export const newKyc = async ( } if (!req.userId || taxIdRecord.userId !== req.userId) { - res.status(httpStatus.FORBIDDEN).json({ error: "Forbidden" }); + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } @@ -642,7 +696,7 @@ export const initiateKybLevel1 = async ( } if (!req.userId || taxIdRecord.userId !== req.userId) { - res.status(httpStatus.FORBIDDEN).json({ error: "Forbidden" }); + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } diff --git a/apps/api/src/api/controllers/quote.controller.ts b/apps/api/src/api/controllers/quote.controller.ts index 471f36b85..b17c06fc2 100644 --- a/apps/api/src/api/controllers/quote.controller.ts +++ b/apps/api/src/api/controllers/quote.controller.ts @@ -11,6 +11,7 @@ import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; import { APIError } from "../errors/api-error"; +import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { buildApiClientRequestMetadata, getSafeApiKeyPrefix, @@ -44,6 +45,7 @@ export const createQuote = async ( // Get apiKey from body or from validated public key middleware const publicApiKey = apiKey || req.validatedPublicKey?.apiKey; const publicKeyPartnerName = req.validatedPublicKey?.partnerName; + const effectiveUserId = getEffectiveUserId(req); // Create quote with public key and partner name for discount application const quote = await quoteService.createQuote({ @@ -57,7 +59,7 @@ export const createQuote = async ( partnerName: publicKeyPartnerName, rampType, to, - userId: req.userId + userId: effectiveUserId }); observeApiClientEvent({ @@ -73,7 +75,7 @@ export const createQuote = async ( rampType, requestId: req.requestId, status: "success", - userId: req.userId || null + userId: effectiveUserId || null }); res.status(httpStatus.CREATED).json(quote); @@ -107,6 +109,7 @@ export const createBestQuote = async ( // Get apiKey from body or from validated public key middleware const publicApiKey = apiKey || req.validatedPublicKey?.apiKey; const publicKeyPartnerName = req.validatedPublicKey?.partnerName; + const effectiveUserId = getEffectiveUserId(req); // Create best quote by querying all eligible networks const quote = await quoteService.createBestQuote({ @@ -121,7 +124,7 @@ export const createBestQuote = async ( partnerName: publicKeyPartnerName, rampType, to, - userId: req.userId + userId: effectiveUserId }); observeApiClientEvent({ @@ -137,7 +140,7 @@ export const createBestQuote = async ( rampType, requestId: req.requestId, status: "success", - userId: req.userId || null + userId: effectiveUserId || null }); res.status(httpStatus.CREATED).json(quote); diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index 71c7be684..95cf087c9 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -16,6 +16,7 @@ import httpStatus from "http-status"; import logger from "../../config/logger"; import { APIError } from "../errors/api-error"; import { enrichAdditionalDataWithClientIp } from "../helpers/clientIp"; +import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { assertQuoteOwnership, assertRampOwnership } from "../middlewares/ownershipAuth"; import { buildApiClientRequestMetadata, observeApiClientEvent } from "../observability/apiClientEvent.service"; import { classifyApiClientError, getErrorMessage } from "../observability/errorClassifier"; @@ -43,11 +44,13 @@ export const registerRamp = async (req: Request, res: Response, nex const enrichedAdditionalData = await enrichAdditionalDataWithClientIp(additionalData, req); + const effectiveUserId = getEffectiveUserId(req); + const ramp = await rampService.registerRamp({ additionalData: enrichedAdditionalData, quoteId, signingAccounts, - userId: req.userId + userId: effectiveUserId }); observeRampSuccess(req, "ramp_register", httpStatus.CREATED, { @@ -257,10 +260,11 @@ export const getRampHistory = async ( }); } + const effectiveUserId = getEffectiveUserId(req); const owner = req.authenticatedPartner ? { partnerId: req.authenticatedPartner.id } - : req.userId - ? { userId: req.userId } + : effectiveUserId + ? { userId: effectiveUserId } : null; if (!owner) { throw new APIError({ message: "Authentication required", status: httpStatus.UNAUTHORIZED }); diff --git a/apps/api/src/api/controllers/userApiKeys.controller.test.ts b/apps/api/src/api/controllers/userApiKeys.controller.test.ts new file mode 100644 index 000000000..4269157f0 --- /dev/null +++ b/apps/api/src/api/controllers/userApiKeys.controller.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import httpStatus from "http-status"; +import ApiKey from "../../models/apiKey.model"; +import { createUserApiKey, MAX_ACTIVE_KEYS_PER_USER, revokeUserApiKey } from "./userApiKeys.controller"; + +function createResponse() { + const res = { + body: undefined as unknown, + send: mock(() => res), + statusCode: Number(httpStatus.OK), + json: mock((body: unknown) => { + res.body = body; + return res; + }), + status: mock((statusCode: number) => { + res.statusCode = statusCode; + return res; + }) + }; + + return res; +} + +describe("createUserApiKey", () => { + const originalCount = ApiKey.count; + + afterEach(() => { + ApiKey.count = originalCount; + }); + + it("rejects creation with 409 when the per-user active key cap is reached", async () => { + ApiKey.count = mock(async () => MAX_ACTIVE_KEYS_PER_USER) as unknown as typeof ApiKey.count; + + const res = createResponse(); + await createUserApiKey({ body: {}, userId: "user-1" } as never, res as never); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect((res.body as { error: { code: string } }).error.code).toBe("API_KEY_LIMIT_REACHED"); + }); +}); + +describe("revokeUserApiKey", () => { + const originalFindOne = ApiKey.findOne; + + afterEach(() => { + ApiKey.findOne = originalFindOne; + }); + + function stubKeyPair() { + const updates: Array<{ id: string; changes: unknown }> = []; + const secretKey = { + id: "secret-key-id", + keyType: "secret", + name: "Secret Key", + update: mock(async (changes: unknown) => { + updates.push({ changes, id: "secret-key-id" }); + }) + }; + const publicKey = { + id: "public-key-id", + keyType: "public", + name: "Public Key", + update: mock(async (changes: unknown) => { + updates.push({ changes, id: "public-key-id" }); + }) + }; + + ApiKey.findOne = mock(async ({ where }: { where: { id: string } }) => { + if (where.id === "secret-key-id") return secretKey; + if (where.id === "public-key-id") return publicKey; + return null; + }) as unknown as typeof ApiKey.findOne; + + return updates; + } + + const expectedPairUpdates = [ + { changes: { isActive: false }, id: "secret-key-id" }, + { changes: { isActive: false }, id: "public-key-id" } + ]; + + it("revokes default-named public and secret keys as one pair via pairedKeyId", async () => { + const updates = stubKeyPair(); + + const res = createResponse(); + await revokeUserApiKey( + { + body: { pairedKeyId: "public-key-id" }, + params: { keyId: "secret-key-id" }, + userId: "user-1" + } as never, + res as never + ); + + expect(res.statusCode).toBe(httpStatus.NO_CONTENT); + expect(updates).toEqual(expectedPairUpdates); + }); + + it("still accepts the legacy publicKeyId alias", async () => { + const updates = stubKeyPair(); + + const res = createResponse(); + await revokeUserApiKey( + { + body: { publicKeyId: "public-key-id" }, + params: { keyId: "secret-key-id" }, + userId: "user-1" + } as never, + res as never + ); + + expect(res.statusCode).toBe(httpStatus.NO_CONTENT); + expect(updates).toEqual(expectedPairUpdates); + }); +}); diff --git a/apps/api/src/api/controllers/userApiKeys.controller.ts b/apps/api/src/api/controllers/userApiKeys.controller.ts new file mode 100644 index 000000000..7b6d1fae8 --- /dev/null +++ b/apps/api/src/api/controllers/userApiKeys.controller.ts @@ -0,0 +1,317 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import sequelize from "../../config/database"; +import logger from "../../config/logger"; +import { config } from "../../config/vars"; +import ApiKey from "../../models/apiKey.model"; +import { generateApiKey, getKeyPrefix, hashApiKey } from "../middlewares/apiKeyAuth.helpers"; + +interface CreateApiKeyBody { + expiresAt?: string; + name?: string; +} + +// Secret-key validation bcrypt-compares against every active key sharing the constant +// 8-char prefix (e.g. "sk_live_"), so the total number of active keys directly bounds +// auth latency. Cap what a single user can mint. +export const MAX_ACTIVE_KEYS_PER_USER = 10; + +// Keys must expire; cap client-supplied expiry at 2 years (default is 1 year). +const MAX_EXPIRY_MS = 2 * 365 * 24 * 60 * 60 * 1000; + +export async function createUserApiKey(req: Request, res: Response): Promise { + const userId = req.userId; + if (!userId) { + res.status(httpStatus.UNAUTHORIZED).json({ + error: { + code: "AUTHENTICATION_REQUIRED", + message: "Authentication required to create API keys", + status: httpStatus.UNAUTHORIZED + } + }); + return; + } + + const { name, expiresAt } = (req.body ?? {}) as CreateApiKeyBody; + + try { + const environment = config.sandboxEnabled ? "test" : "live"; + + const activeKeyCount = await ApiKey.count({ where: { isActive: true, userId } }); + if (activeKeyCount + 2 > MAX_ACTIVE_KEYS_PER_USER) { + res.status(httpStatus.CONFLICT).json({ + error: { + code: "API_KEY_LIMIT_REACHED", + message: `Active API key limit reached (${MAX_ACTIVE_KEYS_PER_USER} keys). Revoke unused keys before creating new ones.`, + status: httpStatus.CONFLICT + } + }); + return; + } + + const publicKey = generateApiKey("public", environment); + const publicKeyPrefix = getKeyPrefix(publicKey); + + const secretKey = generateApiKey("secret", environment); + const secretKeyHash = await hashApiKey(secretKey); + const secretKeyPrefix = getKeyPrefix(secretKey); + + const expirationDate = expiresAt ? new Date(expiresAt) : new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); // Default to 1 year from now + + if (expiresAt && Number.isNaN(expirationDate.getTime())) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_EXPIRES_AT", + message: "expiresAt must be a valid ISO-8601 date", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + if (expiresAt && expirationDate.getTime() > Date.now() + MAX_EXPIRY_MS) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_EXPIRES_AT", + message: "expiresAt must be at most 2 years from now", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + // Create the pair atomically so a failure cannot leave an orphaned half. + const { publicKeyRecord, secretKeyRecord } = await sequelize.transaction(async transaction => { + const createdPublicKey = await ApiKey.create( + { + expiresAt: expirationDate, + isActive: true, + keyHash: null, + keyPrefix: publicKeyPrefix, + keyType: "public", + keyValue: publicKey, + name: `${name || "API Key"} (Public)`, + partnerName: null, + userId + }, + { transaction } + ); + + const createdSecretKey = await ApiKey.create( + { + expiresAt: expirationDate, + isActive: true, + keyHash: secretKeyHash, + keyPrefix: secretKeyPrefix, + keyType: "secret", + keyValue: null, + name: `${name || "API Key"} (Secret)`, + partnerName: null, + userId + }, + { transaction } + ); + + return { publicKeyRecord: createdPublicKey, secretKeyRecord: createdSecretKey }; + }); + + res.status(httpStatus.CREATED).json({ + createdAt: publicKeyRecord.createdAt, + expiresAt: expirationDate, + isActive: true, + publicKey: { + id: publicKeyRecord.id, + key: publicKey, + keyPrefix: publicKeyRecord.keyPrefix, + name: publicKeyRecord.name, + type: "public" + }, + secretKey: { + id: secretKeyRecord.id, + key: secretKey, + keyPrefix: secretKeyRecord.keyPrefix, + name: secretKeyRecord.name, + type: "secret" + } + }); + } catch (error) { + logger.error("Error creating user API keys:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create API keys", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +export async function listUserApiKeys(req: Request, res: Response): Promise { + const userId = req.userId; + if (!userId) { + res.status(httpStatus.UNAUTHORIZED).json({ + error: { + code: "AUTHENTICATION_REQUIRED", + message: "Authentication required to list API keys", + status: httpStatus.UNAUTHORIZED + } + }); + return; + } + + try { + const apiKeys = await ApiKey.findAll({ + attributes: [ + "id", + "keyType", + "keyPrefix", + "keyValue", + "name", + "lastUsedAt", + "expiresAt", + "isActive", + "createdAt", + "updatedAt" + ], + order: [["createdAt", "DESC"]], + where: { isActive: true, userId } + }); + + res.status(httpStatus.OK).json({ + apiKeys: apiKeys.map(key => ({ + createdAt: key.createdAt, + expiresAt: key.expiresAt, + id: key.id, + isActive: key.isActive, + key: key.keyType === "public" ? key.keyValue : undefined, + keyPrefix: key.keyPrefix, + lastUsedAt: key.lastUsedAt, + name: key.name, + type: key.keyType, + updatedAt: key.updatedAt + })) + }); + } catch (error) { + logger.error("Error listing user API keys:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to list API keys", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +function stripSuffix(name: string): string { + return name.replace(/\s*\((Public|Secret)\)$/, ""); +} + +function keyPairBaseName(name: string): string { + const stripped = stripSuffix(name); + if (stripped === "Public Key" || stripped === "Secret Key") { + return "API Key"; + } + return stripped; +} + +export async function revokeUserApiKey(req: Request<{ keyId: string }>, res: Response): Promise { + const userId = req.userId; + if (!userId) { + res.status(httpStatus.UNAUTHORIZED).json({ + error: { + code: "AUTHENTICATION_REQUIRED", + message: "Authentication required to revoke API keys", + status: httpStatus.UNAUTHORIZED + } + }); + return; + } + + const keyId = req.params.keyId; + if (!keyId) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "KEY_ID_REQUIRED", + message: "keyId path parameter is required", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + // The paired key may be either the public or secret half — the type check below enforces the + // pair is one of each. Accept the legacy `publicKeyId` alias for backward compatibility. + const { pairedKeyId, publicKeyId } = req.body ?? {}; + const otherKeyId = pairedKeyId ?? publicKeyId; + + try { + const primaryKey = await ApiKey.findOne({ where: { id: keyId, isActive: true, userId } }); + if (!primaryKey) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "API_KEY_NOT_FOUND", + message: "API key not found or not owned by the authenticated user", + status: httpStatus.NOT_FOUND + } + }); + return; + } + + if (!otherKeyId) { + await primaryKey.update({ isActive: false }); + res.status(httpStatus.NO_CONTENT).send(); + return; + } + + const pairedKey = await ApiKey.findOne({ where: { id: otherKeyId, isActive: true, userId } }); + if (!pairedKey) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "PAIRED_PUBLIC_KEY_NOT_FOUND", + message: "Paired key not found or not owned by the authenticated user", + status: httpStatus.NOT_FOUND + } + }); + return; + } + + const types = new Set([primaryKey.keyType, pairedKey.keyType]); + if (!types.has("public") || !types.has("secret")) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_KEY_PAIR", + message: + "Both keys must be of different types (one public, one secret). A single key can be deleted without pairedKeyId.", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + const baseName = keyPairBaseName(primaryKey.name ?? ""); + const pairedBaseName = keyPairBaseName(pairedKey.name ?? ""); + if (primaryKey.name && pairedKey.name && baseName !== pairedBaseName) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "KEY_PAIR_MISMATCH", + message: `Key names do not match: "${primaryKey.name}" and "${pairedKey.name}" appear to be from different pairs`, + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + await Promise.all([primaryKey.update({ isActive: false }), pairedKey.update({ isActive: false })]); + res.status(httpStatus.NO_CONTENT).send(); + } catch (error) { + logger.error("Error revoking user API key:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to revoke API key", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/helpers/clientIp.test.ts b/apps/api/src/api/helpers/clientIp.test.ts index 8b0434978..dba3c5ff4 100644 --- a/apps/api/src/api/helpers/clientIp.test.ts +++ b/apps/api/src/api/helpers/clientIp.test.ts @@ -17,10 +17,12 @@ describe("normalizeClientIp", () => { describe("enrichAdditionalDataWithClientIp", () => { it("adds the normalized request IP when additional data does not include one", async () => { - const additionalData = await enrichAdditionalDataWithClientIp({ email: "user@example.com" }, { ip: "::1" }); + // A non-loopback request IP: loopback would trigger the real public-IP lookup + // (fetchHostPublicIp) in non-production, making the result network-dependent. + const additionalData = await enrichAdditionalDataWithClientIp({ email: "user@example.com" }, { ip: "::ffff:203.0.113.42" }); expect(additionalData?.email).toBe("user@example.com"); - expect(typeof additionalData?.ipAddress).toBe("string"); + expect(additionalData?.ipAddress).toBe("203.0.113.42"); }); it("keeps a provided IPv4 address over the request IP", async () => { diff --git a/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts b/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts new file mode 100644 index 000000000..56d8e4fe8 --- /dev/null +++ b/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts @@ -0,0 +1,121 @@ +import {afterEach, describe, expect, it, mock} from "bun:test"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; +import Partner from "../../models/partner.model"; +import ApiKey from "../../models/apiKey.model"; +import { + AuthenticatedPartner, + generateApiKey, + getKeyPrefix, + hashApiKey, + validateSecretApiKey +} from "./apiKeyAuth.helpers"; + +const originalApiKeyFindAll = ApiKey.findAll; +const originalApiKeyFindOne = ApiKey.findOne; +const originalPartnerFindOne = Partner.findOne; + +function createSecretKeyRecord({ + userId = null, + partnerName = "TestPartner" +}: { userId?: string | null; partnerName?: string | null } = {}): ApiKey & { raw: string } { + const secret = generateApiKey("secret", "test"); + const secretHash = bcrypt.hashSync(secret, 4); + const record = Object.assign(new ApiKey(), { + id: crypto.randomUUID(), + isActive: true, + keyHash: secretHash, + keyPrefix: getKeyPrefix(secret), + keyType: "secret" as const, + partnerName, + raw: secret, + userId + }); + // validateSecretApiKey fire-and-forgets keyRecord.update({lastUsedAt}); on a + // real instance that issues live SQL against whatever DB the env points at. + record.update = (async () => record) as typeof record.update; + return record; +} + +describe("validateSecretApiKey - apiKeyUserId propagation", () => { + afterEach(() => { + ApiKey.findAll = originalApiKeyFindAll; + ApiKey.findOne = originalApiKeyFindOne; + Partner.findOne = originalPartnerFindOne; + }); + + it("returns apiKeyId and apiKeyUserId with a partner for partner-scoped keys", async () => { + const key = createSecretKeyRecord({userId: "user-bound", partnerName: "TestPartner"}); + ApiKey.findAll = mock( + async () => [key as unknown as ApiKey] + ) as typeof ApiKey.findAll; + Partner.findOne = mock( + async () => ({id: "partner-id", isActive: true, name: "TestPartner"}) + ) as typeof Partner.findOne; + + const result = await validateSecretApiKey(key.raw); + expect(result).not.toBeNull(); + expect(result?.apiKeyId).toBe(key.id); + expect(result?.apiKeyUserId).toBe("user-bound"); + expect(result?.partner).not.toBeNull(); + expect((result?.partner as AuthenticatedPartner).name).toBe("TestPartner"); + expect((result?.partner as AuthenticatedPartner).id).toBe("partner-id"); + }); + + it("returns apiKeyUserId = null for an unlinked partner-scoped key", async () => { + const key = createSecretKeyRecord({userId: null, partnerName: "TestPartner"}); + ApiKey.findAll = mock( + async () => [key as unknown as ApiKey] + ) as typeof ApiKey.findAll; + Partner.findOne = mock( + async () => ({id: "partner-id", isActive: true, name: "TestPartner"}) + ) as typeof Partner.findOne; + + const result = await validateSecretApiKey(key.raw); + expect(result).not.toBeNull(); + expect(result?.apiKeyUserId).toBeNull(); + expect(result?.partner).not.toBeNull(); + }); + + it("returns partner=null for a user-scoped key (no partnerName, userId set)", async () => { + const key = createSecretKeyRecord({userId: "user-scoped", partnerName: null}); + ApiKey.findAll = mock( + async () => [key as unknown as ApiKey] + ) as typeof ApiKey.findAll; + Partner.findOne = mock( + async () => ({id: "partner-id", isActive: true, name: "TestPartner"}) + ) as typeof Partner.findOne; + + const result = await validateSecretApiKey(key.raw); + expect(result).not.toBeNull(); + expect(result?.apiKeyId).toBe(key.id); + expect(result?.apiKeyUserId).toBe("user-scoped"); + expect(result?.partner).toBeNull(); + expect(Partner.findOne).toHaveBeenCalledTimes(0); + }); + + it("returns null for a key with no partnerName and no userId (unusable)", async () => { + const key = createSecretKeyRecord({userId: null, partnerName: null}); + ApiKey.findAll = mock( + async () => [key as unknown as ApiKey] + ) as typeof ApiKey.findAll; + + const result = await validateSecretApiKey(key.raw); + expect(result).toBeNull(); + }); + + it("returns null when no matching key exists", async () => { + ApiKey.findAll = mock(async () => []) as typeof ApiKey.findAll; + const result = await validateSecretApiKey("sk_test_no_such_key_xxxxxxxxxxxxxxxx"); + expect(result).toBeNull(); + }); +}); + +describe("hashApiKey + getKeyPrefix consistency", () => { + it("produces a hash that validates against the original secret", async () => { + const secret = generateApiKey("secret", "test"); + const hash = await hashApiKey(secret); + expect(await bcrypt.compare(secret, hash)).toBe(true); + expect(getKeyPrefix(secret)).toBe(secret.substring(0, 8)); + }); +}); \ No newline at end of file diff --git a/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts b/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts index 9a2416c3d..d799bf97b 100644 --- a/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts +++ b/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts @@ -9,6 +9,26 @@ export interface AuthenticatedPartner { name: string; } +/** + * Validation result for a secret API key. `partner` may be null for user-scoped + * keys (created via the self-serve API key endpoints) which may have no + * `partner_name` binding; in that case the request authenticates purely as + * the linked user via `apiKeyUserId`. + */ +export interface ValidatedSecretKey { + apiKeyId: string; + apiKeyUserId: string | null; + partner: AuthenticatedPartner | null; +} + +/** + * Validation result for a public API key. `partnerName` may be null for + * user-scoped public keys (no partner binding). + */ +export interface ValidatedPublicKey { + partnerName: string | null; +} + /** * Validate API key format for both public and secret keys * Public: pk_(live|test)_[32 alphanumeric chars] @@ -76,9 +96,9 @@ export function getKeyPrefix(key: string): string { /** * Validate public API key (simple lookup, no hashing) * @param apiKey - The public API key to validate - * @returns Promise resolving to partner name or null if invalid + * @returns Promise resolving to validation result, or null if the key is invalid/expired/inactive */ -export async function validatePublicApiKey(apiKey: string): Promise { +export async function validatePublicApiKey(apiKey: string): Promise { try { const keyRecord = await ApiKey.findOne({ where: { @@ -102,7 +122,7 @@ export async function validatePublicApiKey(apiKey: string): Promise { +export async function validateSecretApiKey(apiKey: string): Promise { try { // Extract prefix for quick lookup const prefix = getKeyPrefix(apiKey); @@ -143,7 +163,27 @@ export async function validateSecretApiKey(apiKey: string): Promise { + logger.error("Failed to update lastUsedAt for secret key:", err); + }); + + return { + apiKeyId: keyRecord.id, + apiKeyUserId: keyRecord.userId, + partner: null + }; + } + + // Partner-scoped keys: find any active partner with this name const partner = await Partner.findOne({ where: { isActive: true, @@ -162,8 +202,12 @@ export async function validateSecretApiKey(apiKey: string): Promise { +export async function validateApiKey(apiKey: string): Promise { const keyType = getKeyType(apiKey); if (keyType === "secret") { diff --git a/apps/api/src/api/middlewares/apiKeyAuth.ts b/apps/api/src/api/middlewares/apiKeyAuth.ts index 76fa3a03e..34da068ae 100644 --- a/apps/api/src/api/middlewares/apiKeyAuth.ts +++ b/apps/api/src/api/middlewares/apiKeyAuth.ts @@ -9,6 +9,7 @@ import { import { getRequestDurationMs } from "../observability/requestContext"; import { ApiClientErrorType } from "../observability/types"; import { AuthenticatedPartner, getKeyType, isValidSecretKeyFormat, validateApiKey } from "./apiKeyAuth.helpers"; +import { setApiKeyUserId } from "./effectiveUser"; // Extend Express Request type to include authenticatedPartner declare global { @@ -78,9 +79,9 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { } // Find and validate API key - const partner = await validateApiKey(apiKey); + const result = await validateApiKey(apiKey); - if (!partner) { + if (!result) { recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); return res.status(401).json({ error: { @@ -91,8 +92,13 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { }); } - // Attach authenticated partner to request - req.authenticatedPartner = partner; + const partner = result.partner; + + // Attach authenticated partner to request (null for user-scoped keys, leaving the field unset). + if (partner) { + req.authenticatedPartner = partner; + } + setApiKeyUserId(req, result.apiKeyUserId); // If validatePartnerMatch enabled, check payload partnerId if (options.validatePartnerMatch && req.body?.partnerId) { @@ -108,7 +114,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { const requestedPartner = await Partner.findByPk(partnerIdOrName); if (!requestedPartner) { - recordAuthFailure(req, 404, "auth_partner_not_found", getSafeApiKeyPrefix(apiKey, ["sk_"]), partner); + recordAuthFailure(req, 404, "auth_partner_not_found", getSafeApiKeyPrefix(apiKey, ["sk_"]), partner ?? undefined); return res.status(404).json({ error: { code: "PARTNER_NOT_FOUND", @@ -125,13 +131,13 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { } // Compare partner names since one API key works for all partners with same name - if (requestedPartnerName !== partner.name) { - recordAuthFailure(req, 403, "auth_partner_mismatch", getSafeApiKeyPrefix(apiKey, ["sk_"]), partner); + if (!partner || requestedPartnerName !== partner.name) { + recordAuthFailure(req, 403, "auth_partner_mismatch", getSafeApiKeyPrefix(apiKey, ["sk_"]), partner ?? undefined); return res.status(403).json({ error: { code: "PARTNER_MISMATCH", details: { - authenticatedPartnerName: partner.name, + authenticatedPartnerName: partner?.name ?? null, requestedPartnerName: requestedPartnerName }, message: "The authenticated partner name does not match the requested partner's name", @@ -246,6 +252,6 @@ function recordAuthFailure( partnerName: partner?.name || req.authenticatedPartner?.name || null, requestId: req.requestId, status: "failure", - userId: req.userId || null + userId: req.userId || req.apiKeyUserId || null }); } diff --git a/apps/api/src/api/middlewares/dualAuth.ts b/apps/api/src/api/middlewares/dualAuth.ts index 361021d69..aafb78e3e 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -8,6 +8,7 @@ import { import { getRequestDurationMs } from "../observability/requestContext"; import { SupabaseAuthService } from "../services/auth"; import { getKeyType, isValidSecretKeyFormat, validateSecretApiKey } from "./apiKeyAuth.helpers"; +import { setApiKeyUserId } from "./effectiveUser"; export { assertQuoteOwnership, assertRampOwnership } from "./ownershipAuth"; @@ -51,8 +52,8 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } }); } - const partner = await validateSecretApiKey(apiKey); - if (!partner) { + const result = await validateSecretApiKey(apiKey); + if (!result) { recordDualAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); return res.status(401).json({ error: { @@ -63,7 +64,10 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } }); } - req.authenticatedPartner = partner; + if (result.partner) { + req.authenticatedPartner = result.partner; + } + setApiKeyUserId(req, result.apiKeyUserId); return next(); } @@ -122,6 +126,6 @@ function recordDualAuthFailure( partnerName: req.authenticatedPartner?.name || null, requestId: req.requestId, status: "failure", - userId: req.userId || null + userId: req.userId || req.apiKeyUserId || null }); } diff --git a/apps/api/src/api/middlewares/effectiveUser.test.ts b/apps/api/src/api/middlewares/effectiveUser.test.ts new file mode 100644 index 000000000..fc666eb86 --- /dev/null +++ b/apps/api/src/api/middlewares/effectiveUser.test.ts @@ -0,0 +1,52 @@ +import {describe, expect, it} from "bun:test"; +import {getEffectiveUserId, setApiKeyUserId} from "./effectiveUser"; + +function fakeReq({userId, apiKeyUserId}: {userId?: string; apiKeyUserId?: string} = {}): { + userId?: string; + apiKeyUserId?: string; +} { + const req: {userId?: string; apiKeyUserId?: string} = {}; + if (userId !== undefined) { + req.userId = userId; + } + if (apiKeyUserId !== undefined) { + req.apiKeyUserId = apiKeyUserId; + } + return req; +} + +describe("getEffectiveUserId", () => { + it("prefers req.userId (Supabase) over req.apiKeyUserId", () => { + expect(getEffectiveUserId(fakeReq({userId: "supabase-user", apiKeyUserId: "key-user"}))).toBe( + "supabase-user" + ); + }); + + it("falls back to req.apiKeyUserId when no Supabase user", () => { + expect(getEffectiveUserId(fakeReq({apiKeyUserId: "key-user"}))).toBe("key-user"); + }); + + it("returns undefined when no identity is present", () => { + expect(getEffectiveUserId(fakeReq())).toBeUndefined(); + }); +}); + +describe("setApiKeyUserId", () => { + it("sets req.apiKeyUserId from a non-empty string", () => { + const req: {userId?: string; apiKeyUserId?: string} = {}; + setApiKeyUserId(req as never, "key-user"); + expect(req.apiKeyUserId).toBe("key-user"); + }); + + it("does not set req.apiKeyUserId when value is null", () => { + const req: {userId?: string; apiKeyUserId?: string} = {}; + setApiKeyUserId(req as never, null); + expect(req.apiKeyUserId).toBeUndefined(); + }); + + it("does not set req.apiKeyUserId when value is undefined", () => { + const req: {userId?: string; apiKeyUserId?: string} = {}; + setApiKeyUserId(req as never, undefined); + expect(req.apiKeyUserId).toBeUndefined(); + }); +}); diff --git a/apps/api/src/api/middlewares/effectiveUser.ts b/apps/api/src/api/middlewares/effectiveUser.ts new file mode 100644 index 000000000..eaab99101 --- /dev/null +++ b/apps/api/src/api/middlewares/effectiveUser.ts @@ -0,0 +1,47 @@ +import { NextFunction, Request, Response } from "express"; + +// Augment Express Request with the optional user id derived from a secret API key. +// supabaseAuth.ts already declares req.userId (Supabase) and req.userEmail. +declare global { + // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. + namespace Express { + interface Request { + apiKeyUserId?: string; + } + } +} + +// Use a permissive type for the helpers below: controllers and middlewares +// instantiate Express Request with narrower generics (e.g. +// `Request`), but the helpers only ever +// touch `userId` / `apiKeyUserId` / `authenticatedPartner` fields. Treating +// the argument as `Pick` keeps the +// call sites type-clean without forcing every consumer to widen the request +// type. +type RequestLike = Pick; + +/** + * Returns the effective user identity for a request. + * + * Order of preference: Supabase-authenticated user (`req.userId`) first, then the + * nullable `api_keys.user_id` resolved during secret API-key validation + * (`req.apiKeyUserId`). Returns `undefined` for fully anonymous requests. + * + */ +export function getEffectiveUserId(req: RequestLike): string | undefined { + return req.userId ?? req.apiKeyUserId; +} + +/** + * Attach an `apiKeyUserId` to a request from a secret API key validation result. + * Intended for the auth middlewares (`apiKeyAuth`, `dualAuth`) that call + * `validateSecretApiKey`. Public API keys do not populate this field. + */ +export function setApiKeyUserId(req: Request, userId: string | null | undefined): void { + if (userId) { + req.apiKeyUserId = userId; + } +} + +export type EffectiveUserRequest = Request; +export type EffectiveUserMiddleware = (req: Request, res: Response, next: NextFunction) => void; diff --git a/apps/api/src/api/middlewares/maintenanceGuard.test.ts b/apps/api/src/api/middlewares/maintenanceGuard.test.ts index a67bb9fc2..eb6e8bdaf 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.test.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.test.ts @@ -1,12 +1,32 @@ -import {afterEach, describe, expect, it, mock} from "bun:test"; +import {afterAll, afterEach, describe, expect, it, mock} from "bun:test"; import type {NextFunction, Request, Response} from "express"; import express from "express"; import httpStatus from "http-status"; import {APIError} from "../errors/api-error"; +// Real modules are captured (value copies, not live bindings) before the +// mock.module calls below so afterAll can restore them — bun keeps module +// mocks for the whole process otherwise, poisoning every later test file. +import * as authServiceReal from "../services/auth"; +import * as quoteControllerReal from "../controllers/quote.controller"; +import * as rampControllerReal from "../controllers/ramp.controller"; +import * as apiClientEventServiceReal from "../observability/apiClientEvent.service"; import type {ApiClientEventInput} from "../observability/types"; import {MaintenanceService} from "../services/maintenance.service"; import {handler as errorHandler} from "./error"; +const realModules: Array<[string, Record]> = [ + ["../observability/apiClientEvent.service", { ...apiClientEventServiceReal }], + ["../controllers/quote.controller", { ...quoteControllerReal }], + ["../controllers/ramp.controller", { ...rampControllerReal }], + ["../services/auth", { ...authServiceReal }] +]; + +afterAll(() => { + for (const [path, real] of realModules) { + mock.module(path, () => real); + } +}); + const observedEvents: ApiClientEventInput[] = []; const controllerCalls: string[] = []; diff --git a/apps/api/src/api/middlewares/dualAuth.test.ts b/apps/api/src/api/middlewares/ownershipAuth.test.ts similarity index 71% rename from apps/api/src/api/middlewares/dualAuth.test.ts rename to apps/api/src/api/middlewares/ownershipAuth.test.ts index 41a40fcd0..24f109265 100644 --- a/apps/api/src/api/middlewares/dualAuth.test.ts +++ b/apps/api/src/api/middlewares/ownershipAuth.test.ts @@ -95,6 +95,72 @@ describe("assertQuoteOwnership", () => { await expect(assertQuoteOwnership({}, "quote-1")).rejects.toThrow("Authentication required"); }); + + it("rejects a linked API key from operating on another linked user's provider-bound quote", async () => { + QuoteTicket.findByPk = mock(async () => ({ + partnerId: "quote-partner-id", + userId: "victim-user" + })) as typeof QuoteTicket.findByPk; + Partner.findByPk = mock(async () => ({ + id: "quote-partner-id", + isActive: true, + name: "Partner" + })) as typeof Partner.findByPk; + + await expect( + assertQuoteOwnership( + { + apiKeyUserId: "attacker-user", + authenticatedPartner: {id: "api-key-partner-id", name: "Partner"} + }, + "quote-1" + ) + ).rejects.toThrow("Authenticated API key user does not own this quote"); + }); + + it("allows a linked API key to operate on its own user's provider-bound quote", async () => { + QuoteTicket.findByPk = mock(async () => ({ + partnerId: "quote-partner-id", + userId: "user-1" + })) as typeof QuoteTicket.findByPk; + Partner.findByPk = mock(async () => ({ + id: "quote-partner-id", + isActive: true, + name: "Partner" + })) as typeof Partner.findByPk; + + await expect( + assertQuoteOwnership( + { + apiKeyUserId: "user-1", + authenticatedPartner: {id: "api-key-partner-id", name: "Partner"} + }, + "quote-1" + ) + ).resolves.toBeUndefined(); + }); + + it("allows an unlinked partner key to operate on a partner-owned anonymous-user quote", async () => { + QuoteTicket.findByPk = mock(async () => ({ + partnerId: "quote-partner-id", + userId: null + })) as typeof QuoteTicket.findByPk; + Partner.findByPk = mock(async () => ({ + id: "quote-partner-id", + isActive: true, + name: "Partner" + })) as typeof Partner.findByPk; + + await expect( + assertQuoteOwnership( + { + apiKeyUserId: undefined, + authenticatedPartner: {id: "api-key-partner-id", name: "Partner"} + }, + "quote-1" + ) + ).resolves.toBeUndefined(); + }); }); describe("assertRampOwnership", () => { diff --git a/apps/api/src/api/middlewares/ownershipAuth.ts b/apps/api/src/api/middlewares/ownershipAuth.ts index 1d84bbfe4..826ede689 100644 --- a/apps/api/src/api/middlewares/ownershipAuth.ts +++ b/apps/api/src/api/middlewares/ownershipAuth.ts @@ -6,9 +6,11 @@ import { APIError } from "../errors/api-error"; import { buildApiClientRequestMetadata, observeApiClientEvent } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; import type { AuthenticatedPartner } from "./apiKeyAuth.helpers"; +import { getEffectiveUserId } from "./effectiveUser"; interface OwnershipRequest { authenticatedPartner?: AuthenticatedPartner; + apiKeyUserId?: string; body?: unknown; method?: string; params?: unknown; @@ -56,11 +58,25 @@ export async function assertRampOwnership(req: OwnershipRequest, rampId: string) status: httpStatus.FORBIDDEN }); } + // Enforce user consistency on the underlying + // quote so one partner key cannot operate on a different linked user's + // provider-backed ramp. + if (req.apiKeyUserId && quote.userId && quote.userId !== req.apiKeyUserId) { + recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId: ramp.quoteId, rampId }); + throw new APIError({ + message: "Authenticated API key user does not own this ramp", + status: httpStatus.FORBIDDEN + }); + } return; } - if (req.userId) { - if (ramp.userId !== req.userId) { + const userId = getEffectiveUserId(req); + if (userId) { + // A ramp with `userId === null` is fully anonymous: it carries no privileged owner and is + // already reachable by unauthenticated callers below, so an authenticated principal driving it + // is not an escalation. Only reject when the ramp is owned by a *different* user. + if (ramp.userId !== null && ramp.userId !== userId) { recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId: ramp.quoteId, rampId }); throw new APIError({ message: "Authenticated user does not own this ramp", @@ -106,10 +122,21 @@ export async function assertQuoteOwnership(req: OwnershipRequest, quoteId: strin status: httpStatus.FORBIDDEN }); } + // Enforce user consistency on the quote so one + // partner key cannot operate on a different linked user's provider-bound + // quote. + if (req.apiKeyUserId && quote.userId && quote.userId !== req.apiKeyUserId) { + recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId }); + throw new APIError({ + message: "Authenticated API key user does not own this quote", + status: httpStatus.FORBIDDEN + }); + } return; } - if (req.userId) { + const userId = getEffectiveUserId(req); + if (userId) { if (quote.partnerId !== null) { recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId }); throw new APIError({ @@ -117,7 +144,7 @@ export async function assertQuoteOwnership(req: OwnershipRequest, quoteId: strin status: httpStatus.FORBIDDEN }); } - if (quote.userId !== null && quote.userId !== req.userId) { + if (quote.userId !== null && quote.userId !== userId) { recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId }); throw new APIError({ message: "Authenticated user does not own this quote", @@ -155,6 +182,6 @@ function recordOwnershipFailure( partnerName: req.authenticatedPartner?.name || null, requestId: req.requestId, status: "failure", - userId: req.userId || null + userId: getEffectiveUserId(req) || null }); } diff --git a/apps/api/src/api/middlewares/publicKeyAuth.ts b/apps/api/src/api/middlewares/publicKeyAuth.ts index 9b66e542b..4dbae016e 100644 --- a/apps/api/src/api/middlewares/publicKeyAuth.ts +++ b/apps/api/src/api/middlewares/publicKeyAuth.ts @@ -15,7 +15,7 @@ declare global { interface Request { validatedPublicKey?: { apiKey: string; - partnerName: string; + partnerName: string | null; }; } } @@ -64,9 +64,9 @@ export function validatePublicKey() { } // Validate the public key exists and is active - const partnerName = await validatePublicApiKey(apiKey); + const result = await validatePublicApiKey(apiKey); - if (!partnerName) { + if (!result) { recordPublicKeyFailure(req, 401, getSafeApiKeyPrefix(apiKey)); return res.status(401).json({ error: { @@ -80,7 +80,7 @@ export function validatePublicKey() { // Attach validated public key info to request req.validatedPublicKey = { apiKey, - partnerName + partnerName: result.partnerName }; next(); @@ -101,6 +101,6 @@ function recordPublicKeyFailure(req: Request, httpStatus: number, apiKeyPrefix: operation: "auth_public_key", requestId: req.requestId, status: "failure", - userId: req.userId || null + userId: req.userId || req.apiKeyUserId || null }); } diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 862fc869f..5ff0abb55 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import multer from "multer"; import { AlfredpayController } from "../../controllers/alfredpay.controller"; import { validateResultCountry } from "../../middlewares/alfredpay.middleware"; +import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { requireAuth } from "../../middlewares/supabaseAuth"; import { validateKycSubmission } from "../../middlewares/validators"; @@ -42,9 +43,16 @@ router.post( ); router.post("/sendKybSubmission", requireAuth, validateResultCountry, AlfredpayController.sendKybSubmission); -// Fiat accounts (USD + MXN) -router.post("/fiatAccounts", requireAuth, validateResultCountry, AlfredpayController.addFiatAccount); -router.get("/fiatAccounts", requireAuth, validateResultCountry, AlfredpayController.listFiatAccounts); -router.delete("/fiatAccounts/:fiatAccountId", requireAuth, validateResultCountry, AlfredpayController.deleteFiatAccount); +// Fiat accounts (USD + MXN) — accept user-scoped secret API keys (sk_*) or Supabase Bearer +// via requirePartnerOrUserAuth, so SDK/server integrations can manage fiat accounts without +// a Supabase session. +router.post("/fiatAccounts", requirePartnerOrUserAuth(), validateResultCountry, AlfredpayController.addFiatAccount); +router.get("/fiatAccounts", requirePartnerOrUserAuth(), validateResultCountry, AlfredpayController.listFiatAccounts); +router.delete( + "/fiatAccounts/:fiatAccountId", + requirePartnerOrUserAuth(), + validateResultCountry, + AlfredpayController.deleteFiatAccount +); export default router; diff --git a/apps/api/src/api/routes/v1/api-keys.route.ts b/apps/api/src/api/routes/v1/api-keys.route.ts new file mode 100644 index 000000000..7cc2458e1 --- /dev/null +++ b/apps/api/src/api/routes/v1/api-keys.route.ts @@ -0,0 +1,30 @@ +import { Request, Response, Router } from "express"; +import { createUserApiKey, listUserApiKeys, revokeUserApiKey } from "../../controllers/userApiKeys.controller"; +import { requireAuth } from "../../middlewares/supabaseAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(requireAuth); + +/** + * POST /v1/api-keys + * Create a new public + secret API key pair bound to the authenticated Supabase user. + */ +router.post("/", createUserApiKey as unknown as (req: Request, res: Response) => void); + +/** + * GET /v1/api-keys + * List the authenticated user's active API keys. + */ +router.get("/", listUserApiKeys as unknown as (req: Request, res: Response) => void); + +/** + * DELETE /v1/api-keys/:keyId + * Revoke (soft delete) one or both keys of a pair. + * Body: { pairedKeyId?: string } — if provided, both keys of the pair are revoked together + * (the legacy `publicKeyId` alias is still accepted). The two keys must be opposite types + * (one public, one secret) and share the same base name. + */ +router.delete("/:keyId", revokeUserApiKey as unknown as (req: Request<{ keyId: string }>, res: Response) => void); + +export default router; diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index 115d185f5..db0bc4c2b 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -1,6 +1,6 @@ import { RequestHandler, Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; -import { optionalPartnerOrUserAuth } from "../../middlewares/dualAuth"; +import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { optionalAuth, requireAuth } from "../../middlewares/supabaseAuth"; import { validateStartKyc2, validateSubaccountCreation } from "../../middlewares/validators"; @@ -12,8 +12,7 @@ const router: Router = Router({ mergeParams: true }); // // /getUser, /getUserRemainingLimit, and /validatePixKey use optionalPartnerOrUserAuth so that SDK // clients without API keys can drive a BRL ramp pre-flight against fully-anonymous quotes. The -// controllers themselves apply ownership scoping when req.userId is set; anonymous callers see -// the same data surface a partner X-API-Key caller would (taxId is the lookup key in both cases). +// controllers themselves apply ownership scoping using `getEffectiveUserId`; router.get("/getUser", optionalPartnerOrUserAuth(), brlaController.getAveniaUser as unknown as RequestHandler); router.get( @@ -28,7 +27,9 @@ router.get("/getSelfieLivenessUrl", requireAuth, brlaController.getSelfieLivenes router.get("/validatePixKey", optionalPartnerOrUserAuth(), brlaController.validatePixKey as unknown as RequestHandler); -router.route("/createSubaccount").post(validateSubaccountCreation, optionalAuth, brlaController.createSubaccount); +router + .route("/createSubaccount") + .post(validateSubaccountCreation, requirePartnerOrUserAuth(), brlaController.createSubaccount as unknown as RequestHandler); router.route("/getUploadUrls").post(validateStartKyc2, requireAuth, brlaController.getUploadUrls); diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 88c3203bc..2d5c50d17 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -5,6 +5,7 @@ import apiClientEventsRoutes from "./admin/api-client-events.route"; import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; import profilePartnerAssignmentsRoutes from "./admin/profile-partner-assignments.route"; import alfredpayRoutes from "./alfredpay.route"; +import apiKeysRoutes from "./api-keys.route"; import authRoutes from "./auth.route"; import brlaRoutes from "./brla.route"; import contactRoutes from "./contact.route"; @@ -167,6 +168,16 @@ router.use("/public-key", publicKeyRoutes); */ router.use("/metrics", metricsRoutes); +/** + * Self-serve API key management for authenticated Supabase users. + * Keys created here are user-scoped (no partner binding) and authenticate + * via the X-API-Key header on quote/ramp endpoints as the linked user. + * POST /v1/api-keys + * GET /v1/api-keys + * DELETE /v1/api-keys/:keyId + */ +router.use("/api-keys", apiKeysRoutes); + /** * Admin routes for partner API key management * Uses partner name (not ID) to manage keys for all partner configurations diff --git a/apps/api/src/api/routes/v1/ramp.route.ts b/apps/api/src/api/routes/v1/ramp.route.ts index aa8e589b3..3d73aaeef 100644 --- a/apps/api/src/api/routes/v1/ramp.route.ts +++ b/apps/api/src/api/routes/v1/ramp.route.ts @@ -34,7 +34,7 @@ const router = Router(); router.post( "/register", rejectDuringActiveMaintenance("ramp_register"), - optionalPartnerOrUserAuth(), + requirePartnerOrUserAuth(), rampController.registerRamp as unknown as RequestHandler ); diff --git a/apps/api/src/api/services/auth/index.ts b/apps/api/src/api/services/auth/index.ts index 5f6ff90ae..91db21e5e 100644 --- a/apps/api/src/api/services/auth/index.ts +++ b/apps/api/src/api/services/auth/index.ts @@ -1 +1 @@ -export { SupabaseAuthService } from "./supabase.service"; +export { RefreshTokenError, SupabaseAuthService } from "./supabase.service"; diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index 66e0d5710..747806ce1 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -1,7 +1,22 @@ -import type { User } from "@supabase/supabase-js"; +import { isAuthRetryableFetchError, type User } from "@supabase/supabase-js"; import logger from "../../../config/logger"; import { supabase, supabaseAdmin } from "../../../config/supabase"; +/** + * Thrown by `refreshToken` to distinguish a confirmed-invalid refresh token (the session is + * over) from a transient failure (Supabase unreachable / 5xx). Callers must only end the + * session on `transient === false`; transient failures are retryable. + */ +export class RefreshTokenError extends Error { + constructor( + message: string, + readonly transient: boolean + ) { + super(message); + this.name = this.constructor.name; + } +} + // Supported BCP 47 locale values and their canonical forms. // The Supabase email templates branch on `.Data.locale` using these values. const LOCALE_MAP: Record = { @@ -170,8 +185,17 @@ export class SupabaseAuthService { refresh_token: refreshToken }); - if (error || !data.session) { - throw new Error("Failed to refresh token"); + if (error) { + // Network/transport failures and upstream 5xx are transient: the refresh token may still + // be valid, so callers must retry rather than tear down the session. Only a definite 4xx + // auth error means the refresh token itself is invalid/revoked. + const status = error.status ?? 0; + const transient = isAuthRetryableFetchError(error) || status === 0 || status >= 500; + throw new RefreshTokenError(error.message, transient); + } + + if (!data.session) { + throw new RefreshTokenError("No session returned from refresh", false); } return { diff --git a/apps/api/src/api/services/avenia-account.ts b/apps/api/src/api/services/avenia-account.ts new file mode 100644 index 000000000..491120817 --- /dev/null +++ b/apps/api/src/api/services/avenia-account.ts @@ -0,0 +1,71 @@ +import { AveniaAccountType, normalizeTaxId } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import TaxId, { TaxIdInternalStatus } from "../../models/taxId.model"; +import { APIError } from "../errors/api-error"; + +export interface ResolvedAveniaAccount { + taxId: string; + subAccountId: string; + accountType: AveniaAccountType; + taxIdRecord: TaxId; +} + +/** + * Resolve the canonical Avenia account for a user. Subaccounts in `Consulted`/`Requested` states + * are not considered ramp-execution ready; they are reserved for KYC flows. + */ +export async function resolveAveniaAccountForUser(userId: string): Promise { + const candidates = await TaxId.findAll({ + where: { + internalStatus: TaxIdInternalStatus.Accepted, + userId + } + }); + + if (candidates.length === 0) { + throw new APIError({ + message: "No completed Avenia profile found for this API key user.", + status: httpStatus.BAD_REQUEST + }); + } + + if (candidates.length > 1) { + throw new APIError({ + message: `Multiple completed Avenia profiles found for this API key user (${candidates.length}). Account selection is not yet supported.`, + status: httpStatus.BAD_REQUEST + }); + } + + const taxIdRecord = candidates[0]; + if (!taxIdRecord.subAccountId) { + throw new APIError({ + message: "Avenia subaccount is not yet provisioned for this user.", + status: httpStatus.BAD_REQUEST + }); + } + + return { + accountType: taxIdRecord.accountType, + subAccountId: taxIdRecord.subAccountId, + taxId: normalizeTaxId(taxIdRecord.taxId), + taxIdRecord + }; +} + +/** + * Mirrors `resolveAveniaAccountForUser` but allows the request to provide an + * optional override taxId; if provided, it MUST match the derived one or + * registration is rejected. + */ +export async function resolveAveniaAccountForRamp(userId: string, providedTaxId?: string): Promise { + const resolved = await resolveAveniaAccountForUser(userId); + + if (providedTaxId && normalizeTaxId(providedTaxId) !== resolved.taxId) { + throw new APIError({ + message: "taxId does not match existing records", + status: httpStatus.BAD_REQUEST + }); + } + + return resolved; +} diff --git a/apps/api/src/api/services/mykobo/mykobo-customer.service.test.ts b/apps/api/src/api/services/mykobo/mykobo-customer.service.test.ts new file mode 100644 index 000000000..1349de708 --- /dev/null +++ b/apps/api/src/api/services/mykobo/mykobo-customer.service.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { MykoboApiService, MykoboCustomerStatus } from "@vortexfi/shared"; +import MykoboCustomer from "../../../models/mykoboCustomer.model"; +import User from "../../../models/user.model"; +import { APIError } from "../../errors/api-error"; +import { resolveMykoboCustomerForUser } from "./mykobo-customer.service"; + +const PROFILE_EMAIL = "user@example.com"; + +function stub({ profileEmail, reviewStatus }: { profileEmail: string | null; reviewStatus: string }) { + User.findByPk = mock(async () => + profileEmail ? { email: profileEmail, id: "user-1" } : null + ) as unknown as typeof User.findByPk; + + const customer = { + status: MykoboCustomerStatus.CONSULTED, + update: mock(async (changes: { status: MykoboCustomerStatus }) => { + customer.status = changes.status; + }) + }; + MykoboCustomer.findOne = mock(async () => customer) as unknown as typeof MykoboCustomer.findOne; + + MykoboApiService.getInstance = mock(() => ({ + getProfileByEmail: async () => ({ + profile: { email_address: profileEmail, kyc_status: { review_status: reviewStatus } } + }) + })) as unknown as typeof MykoboApiService.getInstance; + + return customer; +} + +describe("resolveMykoboCustomerForUser", () => { + const originals = { + customerFindOne: MykoboCustomer.findOne, + getInstance: MykoboApiService.getInstance, + userFindByPk: User.findByPk + }; + + afterEach(() => { + User.findByPk = originals.userFindByPk; + MykoboCustomer.findOne = originals.customerFindOne; + MykoboApiService.getInstance = originals.getInstance; + }); + + it("derives the email from the profile and returns it when Mykobo KYC is approved", async () => { + stub({ profileEmail: PROFILE_EMAIL, reviewStatus: "approved" }); + const result = await resolveMykoboCustomerForUser("user-1"); + expect(result.email).toBe(PROFILE_EMAIL); + }); + + it("rejects a provided email that does not match the profile", async () => { + stub({ profileEmail: PROFILE_EMAIL, reviewStatus: "approved" }); + await expect(resolveMykoboCustomerForUser("user-1", "someone-else@example.com")).rejects.toBeInstanceOf(APIError); + }); + + it("rejects when no profile exists for the user", async () => { + stub({ profileEmail: null, reviewStatus: "approved" }); + await expect(resolveMykoboCustomerForUser("user-1")).rejects.toBeInstanceOf(APIError); + }); + + it("rejects when Mykobo KYC is not approved", async () => { + stub({ profileEmail: PROFILE_EMAIL, reviewStatus: "pending" }); + await expect(resolveMykoboCustomerForUser("user-1")).rejects.toBeInstanceOf(APIError); + }); +}); diff --git a/apps/api/src/api/services/mykobo/mykobo-customer.service.ts b/apps/api/src/api/services/mykobo/mykobo-customer.service.ts index 767570fa0..3cced82cd 100644 --- a/apps/api/src/api/services/mykobo/mykobo-customer.service.ts +++ b/apps/api/src/api/services/mykobo/mykobo-customer.service.ts @@ -1,6 +1,9 @@ import { MykoboApiError, MykoboApiService, MykoboCustomerStatus, MykoboProfile, mapMykoboReviewStatus } from "@vortexfi/shared"; +import httpStatus from "http-status"; import logger from "../../../config/logger"; import MykoboCustomer from "../../../models/mykoboCustomer.model"; +import User from "../../../models/user.model"; +import { APIError } from "../../errors/api-error"; interface UpsertArgs { userId: string; @@ -28,6 +31,52 @@ export async function upsertMykoboCustomerFromProfile(userId: string, email: str }); } +export interface ResolvedMykoboCustomer { + email: string; +} + +/** + * Resolve the Mykobo identity for an EUR ramp from the authenticated user's profile, and require an + * approved Mykobo KYC status. The Mykobo email is the user's profile email (`profiles.email` is + * unique and keyed by `userId`); it is never taken from the request body. A client-supplied email + * is accepted only as a redundant check and MUST match the derived value. + * + * Mirrors `resolveAveniaAccountForUser` (BRL) and `resolveAlfredpayCustomerId` (Alfredpay): the + * sender identity is derived server-side and the corridor's KYC-completion status is enforced + * before any provider intent is created. + */ +export async function resolveMykoboCustomerForUser(userId: string, providedEmail?: string): Promise { + const user = await User.findByPk(userId); + if (!user) { + throw new APIError({ + message: "No profile found for this user; cannot resolve the Mykobo customer.", + status: httpStatus.BAD_REQUEST + }); + } + + const email = user.email; + + if (providedEmail && providedEmail.trim().toLowerCase() !== email.trim().toLowerCase()) { + throw new APIError({ + message: "Provided email does not match the profile bound to the authenticated user.", + status: httpStatus.BAD_REQUEST + }); + } + + // Refresh the KYC mirror from the live Mykobo profile, then gate on an approved customer. + await syncMykoboCustomerKyc(userId, email); + + const customer = await MykoboCustomer.findOne({ where: { userId } }); + if (!customer || customer.status !== MykoboCustomerStatus.APPROVED) { + throw new APIError({ + message: "Mykobo KYC is not approved for this user. Complete Mykobo KYC before requesting an EUR ramp.", + status: httpStatus.BAD_REQUEST + }); + } + + return { email }; +} + export async function syncMykoboCustomerKyc(userId: string, email: string): Promise { try { const { profile } = await MykoboApiService.getInstance().getProfileByEmail(email); diff --git a/apps/api/src/api/services/phases/base-phase-handler.ts b/apps/api/src/api/services/phases/base-phase-handler.ts index 1743b7cac..6670eaa79 100644 --- a/apps/api/src/api/services/phases/base-phase-handler.ts +++ b/apps/api/src/api/services/phases/base-phase-handler.ts @@ -3,7 +3,7 @@ import { PresignedTx, RampErrorLog, RampPhase } from "@vortexfi/shared"; import httpStatus from "http-status"; import logger from "../../../config/logger"; import RampState from "../../../models/rampState.model"; -import Subsidy, { SubsidyToken } from "../../../models/subsidy.model"; +import Subsidy from "../../../models/subsidy.model"; import { APIError } from "../../errors/api-error"; import { PhaseError, RecoverablePhaseError, UnrecoverablePhaseError } from "../../errors/phase-error"; import rampService from "../ramp/ramp.service"; @@ -152,7 +152,7 @@ export abstract class BasePhaseHandler implements PhaseHandler { protected async createSubsidy( state: RampState, amount: number, - token: SubsidyToken, + token: string, payerAccount: string, transactionHash: string, paymentDate: Date = new Date() @@ -182,8 +182,13 @@ export abstract class BasePhaseHandler implements PhaseHandler { logger.info(`Subsidy created successfully with id ${subsidy.id} for ramp ${state.id}`); } catch (error) { - logger.error(`Error creating subsidy for ramp ${state.id}:`, error); - // We do not want to throw an error here, as it should not block the phase execution. + // Deliberately swallowed so bookkeeping can't block the phase — but the subsidy + // was already paid on-chain, so an unrecorded row is real money lost to + // accounting. Keep this line alertable. + logger.error( + `SUBSIDY_RECORDING_FAILED: subsidy paid but not recorded. ramp=${state.id} phase=${this.getPhaseName()} token=${token} amount=${amount} txHash=${transactionHash}:`, + error + ); } } diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts index d4ef4dcc1..1673a60f8 100644 --- a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts @@ -14,6 +14,7 @@ import logger from "../../../../config/logger"; import RampState from "../../../../models/rampState.model"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; +import { ensurePresignedTransferFunded } from "./helpers"; const ALFREDPAY_POLL_INTERVAL_MS = 30000; const ALFREDPAY_OFFRAMP_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes @@ -76,6 +77,20 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { const { txData: offrampTransfer } = this.getPresignedTransaction(state, "alfredpayOfframpTransfer"); + // The presigned transfer is single-use (fixed nonce, consumed even on revert); confirm the + // ephemeral can cover it before broadcasting. + try { + await ensurePresignedTransferFunded( + offrampTransfer as `0x${string}`, + Networks.Polygon as EvmNetworks, + this.getPhaseName() + ); + } catch (error) { + throw this.createRecoverableError( + `AlfredpayOfframpTransferHandler: ephemeral balance does not cover the presigned final transfer: ${error instanceof Error ? error.message : String(error)}` + ); + } + const txHash = await evmClientManager.sendRawTransactionWithRetry( Networks.Polygon as EvmNetworks, offrampTransfer as `0x${string}` diff --git a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts index c5ad18fea..24a2905f9 100644 --- a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts @@ -15,6 +15,7 @@ import TaxId from "../../../../models/taxId.model"; import { PhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; +import { ensurePresignedTransferFunded } from "./helpers"; function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -192,6 +193,16 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { logger.info(`BrlaPayoutOnBasePhaseHandler: Existing transaction ${brlaPayoutTxHash} succeeded.`); } } else { + // The presigned payout is single-use (fixed nonce, consumed even on revert); confirm the + // ephemeral can cover it before broadcasting. + try { + await ensurePresignedTransferFunded(brlaPayoutTx as `0x${string}`, Networks.Base, this.getPhaseName()); + } catch (error) { + throw this.createRecoverableError( + `BrlaPayoutOnBasePhaseHandler: ephemeral balance does not cover the presigned payout: ${getErrorMessage(error)}` + ); + } + txHash = (await evmClientManager.sendRawTransactionWithRetry( Networks.Base, brlaPayoutTx as `0x${string}` @@ -213,6 +224,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { }); } } catch (error) { + if (error instanceof PhaseError) throw error; logger.error("BrlaPayoutOnBasePhaseHandler: Failed to send BRLA payout transaction.", error); throw this.createRecoverableError("Failed to send BRLA payout transaction"); } diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.helpers.test.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.helpers.test.ts new file mode 100644 index 000000000..ff88870de --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.helpers.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import Big from "big.js"; +import { computeSubsidyRaw } from "./final-settlement-subsidy.helpers"; + +describe("computeSubsidyRaw", () => { + it("clamps to the on-chain shortfall when a same-chain synchronous swap already delivered the output", () => { + // delivered reads 0 because the post-swap snapshot captured the synchronously-swapped USDC, + // but the ephemeral already holds ~expected. Without the clamp this would subsidize the full output. + const expected = new Big("11463276"); + const delivered = new Big("0"); + const actualBalance = new Big("11463243"); + expect(computeSubsidyRaw(expected, delivered, actualBalance).toString()).toBe("33"); + }); + + it("subsidizes the genuine shortfall for an under-delivering cross-chain bridge", () => { + const expected = new Big("1000000"); + const delivered = new Big("950000"); + const actualBalance = new Big("950000"); + expect(computeSubsidyRaw(expected, delivered, actualBalance).toString()).toBe("50000"); + }); + + it("returns <= 0 (no subsidy) when the ephemeral already meets the expected amount", () => { + const expected = new Big("1000000"); + const delivered = new Big("1000000"); + const actualBalance = new Big("1000000"); + expect(computeSubsidyRaw(expected, delivered, actualBalance).lte(0)).toBe(true); + }); + + it("never exceeds the on-chain shortfall even when delivered is understated", () => { + const expected = new Big("1000000"); + const delivered = new Big("0"); + const actualBalance = new Big("800000"); + expect(computeSubsidyRaw(expected, delivered, actualBalance).toString()).toBe("200000"); + }); +}); diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.helpers.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.helpers.ts new file mode 100644 index 000000000..f0279d871 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.helpers.ts @@ -0,0 +1,20 @@ +import Big from "big.js"; + +/** + * How much output token must be subsidized into the ephemeral so it can settle `expectedAmountRaw`. + * + * Primary figure: `expected - delivered`, where `delivered` is measured against the pre-swap + * `preSettlementBalance` snapshot taken in the squidRouter phase. + * + * Clamp: never more than the true on-chain shortfall `expected - actualBalance`. The ephemeral + * already holds `actualBalance`, so it can never need more than that to reach `expected`. This + * guards against a mis-timed snapshot (e.g. a same-chain synchronous swap whose output was already + * captured in `preSettlementBalance`, making `delivered` read ~0) from funding a second full output. + * + * May return a value <= 0, meaning no subsidy is needed. + */ +export function computeSubsidyRaw(expectedAmountRaw: Big, delivered: Big, actualBalance: Big): Big { + const deliveredBased = expectedAmountRaw.minus(delivered); + const onChainShortfall = expectedAmountRaw.minus(actualBalance); + return deliveredBased.gt(onChainShortfall) ? onChainShortfall : deliveredBased; +} diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 8ebe2b380..a5f3572bc 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -15,6 +15,7 @@ import { multiplyByPowerOfTen, NATIVE_TOKEN_ADDRESS, Networks, + nativeToDecimal, RampCurrency, RampDirection, RampPhase, @@ -31,8 +32,13 @@ import { priceFeedService } from "../../priceFeed.service"; import { isFiatToOwnStablecoinBaseDirect } from "../../quote/utils"; import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; +import { computeSubsidyRaw } from "./final-settlement-subsidy.helpers"; const BALANCE_POLLING_TIME_MS = 5000; +// Backoff between failed subsidy-transfer attempts. Overridable so hermetic +// tests don't wait 20s per scripted failure (same pattern as +// PHASE_PROCESSOR_RETRY_DELAY_MS). +const SETTLEMENT_RETRY_BACKOFF_MS = parseInt(process.env.PHASE_SETTLEMENT_RETRY_BACKOFF_MS || "20000", 10); const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes // Wait for >=90% of expected bridge delivery to absorb slippage while still waiting for actual bridge arrival. const MIN_BRIDGE_DELIVERY_RATIO = 0.9; @@ -178,11 +184,22 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { }); logger.debug(`FinalSettlementSubsidyHandler: Funding account balance=${actualBalanceFundingAccount.toString()}`); - const subsidyAmountRaw = expectedAmountRaw.minus(delivered); + // Clamped to the true on-chain shortfall — see computeSubsidyRaw. This bounds any over-subsidy + // from a mis-timed preSettlementBalance snapshot (e.g. same-chain synchronous swaps). + const deliveredBasedSubsidy = expectedAmountRaw.minus(delivered); + const subsidyAmountRaw = computeSubsidyRaw(expectedAmountRaw, delivered, actualBalance); logger.debug( `FinalSettlementSubsidyHandler: subsidyAmountRaw=${subsidyAmountRaw.toString()} (expected=${expectedAmountRaw.toString()} - delivered=${delivered.toString()}, actualBalance=${actualBalance.toString()}, preSettlementBalance=${preBalance.toString()})` ); + if (subsidyAmountRaw.lt(deliveredBasedSubsidy)) { + logger.warn( + `FinalSettlementSubsidyHandler: Clamped subsidy ${deliveredBasedSubsidy.toString()} -> ${subsidyAmountRaw.toString()} ` + + `(actualBalance=${actualBalance.toString()}, expected=${expectedAmountRaw.toString()}, delivered=${delivered.toString()}). ` + + "delivered-calc disagrees with chain balance." + ); + } + if (subsidyAmountRaw.lte(0)) { logger.info( `FinalSettlementSubsidyHandler: Delivered amount (${delivered.toString()}) meets expected amount with actualBalance=${actualBalance.toString()} and preSettlementBalance=${preBalance.toString()}. No subsidy needed.` @@ -349,7 +366,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { if (!receipt || receipt.status !== "success") { logger.error(`FinalSettlementSubsidyHandler: Transaction ${txHash} failed or was not found. Retrying...`); attempt++; - await new Promise(resolve => setTimeout(resolve, 20000)); + await new Promise(resolve => setTimeout(resolve, SETTLEMENT_RETRY_BACKOFF_MS)); } } @@ -357,6 +374,15 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { throw new Error(`Failed to confirm subsidy transaction after ${attempt} attempts`); } + if (txHash) { + const subsidyToken = isNative ? NATIVE_TOKENS[destinationNetwork].symbol : outTokenDetails.assetSymbol; + const subsidyAmount = nativeToDecimal( + subsidyAmountRaw, + isNative ? NATIVE_TOKENS[destinationNetwork].decimals : outTokenDetails.decimals + ).toNumber(); + await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); + } + await state.update({ state: { ...state.state, diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index f745eed0d..6575e364b 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -1,6 +1,15 @@ -import { API, EvmClientManager, EvmNetworks, Networks as VortexNetworks } from "@vortexfi/shared"; +import { + API, + checkEvmBalancePeriodically, + checkEvmNativeBalancePeriodically, + EvmClientManager, + EvmNetworks, + Networks as VortexNetworks +} from "@vortexfi/shared"; import Big from "big.js"; +import { decodeFunctionData, erc20Abi, parseTransaction, recoverTransactionAddress, type TransactionSerialized } from "viem"; import { base, polygon } from "viem/chains"; +import logger from "../../../../config/logger"; import { BASE_EPHEMERAL_STARTING_BALANCE_UNITS, GLMR_FUNDING_AMOUNT_RAW, @@ -89,3 +98,66 @@ export async function isDestinationEvmEphemeralFunded( return Big(balance.toString()).gte(fundingAmountRaw); } + +const PRESIGNED_TRANSFER_BALANCE_POLL_MS = 5000; +const PRESIGNED_TRANSFER_BALANCE_TIMEOUT_MS = 3 * 60 * 1000; + +/** + * Guard for broadcasting a presigned single-use transfer: a revert still consumes the fixed + * nonce, after which the presigned payload can never be re-broadcast and the funds strand on + * the ephemeral. Decode sender, token and amount from the signed raw transaction and poll until + * the sender's balance covers the transfer, so a short-funded ephemeral surfaces as a phase + * error instead of a burned nonce. Decode failures are logged and skipped — this guard must + * never block a well-formed broadcast path. + * + * Rejects with a BalanceCheckError when the balance does not cover the transfer within the + * timeout (or the balance read fails); callers wrap that in a recoverable phase error. + */ +export async function ensurePresignedTransferFunded(rawTx: `0x${string}`, network: EvmNetworks, phase: string): Promise { + let sender: `0x${string}`; + let tokenAddress: `0x${string}` | undefined; + let amountRaw: bigint; + + try { + const decoded = parseTransaction(rawTx); + sender = (await recoverTransactionAddress({ serializedTransaction: rawTx as TransactionSerialized })) as `0x${string}`; + + if (!decoded.data || decoded.data === "0x") { + amountRaw = decoded.value ?? 0n; + } else { + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: decoded.data }); + if (functionName !== "transfer" || !decoded.to) { + // Not a plain transfer; there is no single balance requirement to assert here. + return; + } + tokenAddress = decoded.to as `0x${string}`; + amountRaw = args[1]; + } + } catch (error) { + logger.warn(`${phase}: could not decode presigned transfer for balance pre-check - ${(error as Error).message}`); + return; + } + + if (amountRaw <= 0n) { + return; + } + + if (tokenAddress) { + await checkEvmBalancePeriodically( + tokenAddress, + sender, + amountRaw.toString(), + PRESIGNED_TRANSFER_BALANCE_POLL_MS, + PRESIGNED_TRANSFER_BALANCE_TIMEOUT_MS, + network + ); + } else { + await checkEvmNativeBalancePeriodically( + sender, + amountRaw.toString(), + PRESIGNED_TRANSFER_BALANCE_POLL_MS, + PRESIGNED_TRANSFER_BALANCE_TIMEOUT_MS, + network + ); + } +} diff --git a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts index 9707e15ae..c752f76dd 100644 --- a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts +++ b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts @@ -21,6 +21,10 @@ import { RecoverablePhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; +// Backoff between failed transaction attempts. Overridable so hermetic tests +// don't wait 20s per scripted failure (same pattern as PHASE_PROCESSOR_RETRY_DELAY_MS). +const SETTLEMENT_RETRY_BACKOFF_MS = parseInt(process.env.PHASE_SETTLEMENT_RETRY_BACKOFF_MS || "20000", 10); + export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { return "moonbeamToPendulum"; @@ -121,8 +125,8 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { if (!receipt || receipt.status !== "success") { logger.error(`MoonbeamToPendulumPhaseHandler: Transaction ${obtainedHash} failed or was not found`); attempt++; - // Wait for 20 seconds to allow the network to settle the squidRouter transaction - await new Promise(resolve => setTimeout(resolve, 20000)); + // Allow the network to settle the squidRouter transaction + await new Promise(resolve => setTimeout(resolve, SETTLEMENT_RETRY_BACKOFF_MS)); } } diff --git a/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts b/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts index ad07596eb..a37710ec6 100644 --- a/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts +++ b/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts @@ -4,6 +4,7 @@ import RampState from "../../../../models/rampState.model"; import { PhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; +import { ensurePresignedTransferFunded } from "./helpers"; const POLL_INTERVAL_MS = 5_000; const POLL_TIMEOUT_MS = 10 * 60 * 1000; @@ -46,6 +47,16 @@ export class MykoboPayoutOnBasePhaseHandler extends BasePhaseHandler { logger.warn(`MykoboPayoutOnBasePhaseHandler: Existing tx ${mykoboPayoutTxHash} failed. Re-sending.`); } + // The presigned payout is single-use (fixed nonce, consumed even on revert); confirm the + // ephemeral can cover it before broadcasting. + try { + await ensurePresignedTransferFunded(payoutTx as `0x${string}`, Networks.Base, this.getPhaseName()); + } catch (error) { + throw this.createRecoverableError( + `MykoboPayoutOnBasePhaseHandler: ephemeral balance does not cover the presigned payout: ${error instanceof Error ? error.message : String(error)}` + ); + } + const txHash = (await evmClientManager.sendRawTransactionWithRetry( Networks.Base, payoutTx as `0x${string}` @@ -65,6 +76,7 @@ export class MykoboPayoutOnBasePhaseHandler extends BasePhaseHandler { }); logger.info(`MykoboPayoutOnBasePhaseHandler: Transaction ${txHash} confirmed.`); } catch (error) { + if (error instanceof PhaseError) throw error; logger.error("MykoboPayoutOnBasePhaseHandler: Failed to send Mykobo payout tx.", error); throw this.createRecoverableError("Failed to send Mykobo payout transaction"); } diff --git a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts index 8aca4d8bf..5bfad2852 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts @@ -1,7 +1,16 @@ // eslint-disable-next-line import/no-unresolved -import {beforeEach, describe, expect, it, mock} from "bun:test"; +import {afterAll, beforeEach, describe, expect, it, mock} from "bun:test"; import {privateKeyToAccount} from "viem/accounts"; import {parseTransaction} from "viem"; +// Captured before mock.module so afterAll can restore the real package — +// bun module mocks are process-wide and would poison later test files. +import * as sharedNamespace from "@vortexfi/shared"; +import * as rampServiceNamespace from "../../ramp/ramp.service"; + +// Value copies taken before mock.module runs — the namespaces themselves are +// live bindings that would reflect the mocks once installed. +const sharedReal = { ...sharedNamespace }; +const rampServiceReal = { ...rampServiceNamespace }; const Networks = { Base: "base" @@ -40,12 +49,11 @@ const checkEvmBalanceForToken = mock(async () => undefined); const appendErrorLog = mock(async (_rampId: string, _errorLog: { error: string; recoverable: boolean }) => undefined); mock.module("@vortexfi/shared", () => ({ + ...sharedReal, ApiManager: { getInstance: () => ({}) }, checkEvmBalanceForToken, - decodeSubmittableExtrinsic: mock(), - defaultReadLimits: {}, EvmClientManager: { getInstance: () => ({ getClient: () => ({ @@ -55,8 +63,6 @@ mock.module("@vortexfi/shared", () => ({ }) }) }, - EvmToken, - EvmTokenDetails: {}, evmTokenConfig: { [Networks.Base]: { [EvmToken.USDC]: { @@ -68,10 +74,7 @@ mock.module("@vortexfi/shared", () => ({ } } }, - NABLA_ROUTER: "0x4444444444444444444444444444444444444444", - Networks, - RampPhase: {}, - RampDirection + NABLA_ROUTER: "0x4444444444444444444444444444444444444444" })); mock.module("../../ramp/ramp.service", () => ({ @@ -85,6 +88,14 @@ const { NablaSwapPhaseHandler } = await import("./nabla-swap-handler"); type NablaSwapState = Parameters["execute"]>[0]; +const realQuoteTicketFindByPk = QuoteTicket.findByPk; + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../ramp/ramp.service", () => ({ ...rampServiceReal })); + QuoteTicket.findByPk = realQuoteTicketFindByPk; +}); + QuoteTicket.findByPk = mock(async () => ({ metadata: { nablaSwapEvm: { diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts index 386a2dc95..b38408e0a 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts @@ -1,6 +1,15 @@ // eslint-disable-next-line import/no-unresolved -import { beforeEach, describe, expect, it, mock } from "bun:test"; +import {afterAll, beforeEach, describe, expect, it, mock} from "bun:test"; import Big from "big.js"; +// Captured before mock.module so afterAll can restore the real package — +// bun module mocks are process-wide and would poison later test files. +import * as sharedNamespace from "@vortexfi/shared"; +import * as rampServiceNamespace from "../../ramp/ramp.service"; + +// Value copies taken before mock.module runs — the namespaces themselves are +// live bindings that would reflect the mocks once installed. +const sharedReal = { ...sharedNamespace }; +const rampServiceReal = { ...rampServiceNamespace }; const Networks = { Base: "base", @@ -8,6 +17,8 @@ const Networks = { Polygon: "polygon" } as const; +const EvmNetworks = Networks; + const EvmToken = { USDC: "USDC" } as const; @@ -23,6 +34,10 @@ const RampDirection = { SELL: "SELL" } as const; +const RampPhase = { + squidRouterSwap: "squidRouterSwap" +} as const; + const EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; const EURE_POLYGON_ADDRESS = "0x18ec0A6E18E5bc3784fDd3a3634b31245ab704F6"; const USDC_BASE_ADDRESS = "0x3333333333333333333333333333333333333333"; @@ -43,15 +58,18 @@ const sendRawTransaction = mock(async ({ serializedTransaction }: { serializedTr const waitForTransactionReceipt = mock(async () => ({ status: "success" })); const getTransactionCount = mock(async () => 0); const checkEvmBalanceForToken = mock(async () => Big(1000)); -const getEvmTokenDetailsByAddress = mock((network: string, tokenAddress: `0x${string}`) => ({ +const getEvmBalance = mock(async () => Big(0)); +const getOnChainTokenDetails = mock((network: string, token: string) => ({ assetSymbol: "Monerium EURe", decimals: 18, - erc20AddressSourceChain: tokenAddress, + erc20AddressSourceChain: token, isNative: false, network })); +const isEvmTokenDetails = mock(() => true); mock.module("@vortexfi/shared", () => ({ + ...sharedReal, checkEvmBalanceForToken, EvmClientManager: { getInstance: () => ({ @@ -62,9 +80,20 @@ mock.module("@vortexfi/shared", () => ({ }) }) }, + ALFREDPAY_EVM_TOKEN: "USDT", + EvmNetworks, EvmToken, + EvmTokenDetails: {}, + evmTokenConfig: { + [Networks.Polygon]: { + EURC: { + erc20AddressSourceChain: EURE_POLYGON_ADDRESS + } + } + }, FiatToken, - getEvmTokenDetailsByAddress, + getEvmBalance, + getOnChainTokenDetails, getNetworkFromDestination: (destination: string) => Object.values(Networks).includes(destination as (typeof Networks)[keyof typeof Networks]) ? destination : undefined, getNetworkId: (network: string) => { @@ -74,8 +103,10 @@ mock.module("@vortexfi/shared", () => ({ return undefined; }, isAlfredpayToken: () => false, + isEvmTokenDetails, Networks, - RampDirection + RampDirection, + RampPhase })); mock.module("../../ramp/ramp.service", () => ({ @@ -87,9 +118,18 @@ mock.module("../../ramp/ramp.service", () => ({ const { default: QuoteTicket } = await import("../../../../models/quoteTicket.model"); const { SquidRouterPhaseHandler } = await import("./squid-router-phase-handler"); +const realQuoteTicketFindByPk = QuoteTicket.findByPk; + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../ramp/ramp.service", () => ({ ...rampServiceReal })); + QuoteTicket.findByPk = realQuoteTicketFindByPk; +}); + let quote: { inputCurrency: string; metadata: Record; + network: string; outputCurrency: string; to: string; }; @@ -146,7 +186,9 @@ describe("SquidRouterPhaseHandler", () => { waitForTransactionReceipt.mockClear(); getTransactionCount.mockClear(); checkEvmBalanceForToken.mockClear(); - getEvmTokenDetailsByAddress.mockClear(); + getEvmBalance.mockClear(); + getOnChainTokenDetails.mockClear(); + isEvmTokenDetails.mockClear(); }); it("submits Squid approve and swap for Monerium EUR onramp to Base USDC", async () => { @@ -164,6 +206,10 @@ describe("SquidRouterPhaseHandler", () => { outputAmountRaw: "1000" } }, + // quote.network for a BUY ramp is by construction the destination network + // (quote.controller getNetworkFromDestination(to)); the pre-settlement snapshot + // must read the destination-chain balance, so this pins Base, not Polygon. + network: Networks.Base, outputCurrency: EvmToken.USDC, to: Networks.Base }; @@ -172,9 +218,15 @@ describe("SquidRouterPhaseHandler", () => { const updatedState = await handler.execute(makeState()); expect(sendRawTransaction).toHaveBeenCalledTimes(2); - expect(getEvmTokenDetailsByAddress).toHaveBeenCalledWith(Networks.Polygon, EURE_POLYGON_ADDRESS); + expect(getOnChainTokenDetails).toHaveBeenCalledWith(Networks.Base, EvmToken.USDC); + expect(getEvmBalance).toHaveBeenCalledTimes(1); expect(sendRawTransaction.mock.calls[0][0]).toEqual({ serializedTransaction: APPROVE_TX }); expect(sendRawTransaction.mock.calls[1][0]).toEqual({ serializedTransaction: SWAP_TX }); + expect(updatedState.state).toMatchObject({ + preSettlementBalance: "0", + squidRouterApproveHash: APPROVE_HASH, + squidRouterSwapHash: SWAP_HASH + }); expect(updatedState.currentPhase).toBe("squidRouterPay"); }); @@ -193,6 +245,7 @@ describe("SquidRouterPhaseHandler", () => { toToken: USDC_BASE_ADDRESS } }, + network: Networks.Base, outputCurrency: EvmToken.USDC, to: Networks.Base }; @@ -205,7 +258,7 @@ describe("SquidRouterPhaseHandler", () => { ); expect(sendRawTransaction).not.toHaveBeenCalled(); - expect(getEvmTokenDetailsByAddress).not.toHaveBeenCalled(); - expect(updatedState.currentPhase).toBe("destinationTransfer"); + expect(getOnChainTokenDetails).not.toHaveBeenCalled(); + expect(updatedState.currentPhase).toBe("finalSettlementSubsidy"); }); }); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index a36b98fd0..39c2635b5 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -29,6 +29,41 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { return EvmClientManager.getInstance().getClient(network); } + /** + * Snapshot the ephemeral's destination-token balance BEFORE the swap. finalSettlementSubsidy + * computes delivered = balanceNow - preSettlementBalance, so this must be the pre-delivery + * baseline. Same-chain swaps deliver the output synchronously within the swap tx, so a post-swap + * snapshot would already include the delivered funds and net `delivered` to ~0 (over-subsidy). + * Idempotent: only snapshots on first entry so a retry after the swap cannot overwrite it. + */ + private async snapshotPreSettlementBalance(state: RampState, quote: QuoteTicket, evmEphemeralAddress: string): Promise { + if (state.state.preSettlementBalance !== undefined) { + return; + } + + let preSettlementBalance = "0"; + try { + const destinationNetwork = quote.network as EvmNetworks; + const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency); + if (!outTokenDetails || !isEvmTokenDetails(outTokenDetails)) { + throw new Error(`Could not resolve destination token details for ${quote.outputCurrency} on ${destinationNetwork}`); + } + preSettlementBalance = ( + await getEvmBalance({ + chain: destinationNetwork, + ownerAddress: evmEphemeralAddress as `0x${string}`, + tokenDetails: outTokenDetails + }) + ).toString(); + } catch (error) { + logger.warn( + `SquidRouterPhaseHandler: Failed to snapshot pre-settlement balance for ramp ${state.id}; storing 0. Error: ${error}` + ); + } + state.state = { ...state.state, preSettlementBalance }; + await state.update({ state: state.state }); + } + /** * Get the phase name */ @@ -126,6 +161,9 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { ); } + // Snapshot the destination-token balance BEFORE the swap (see snapshotPreSettlementBalance). + await this.snapshotPreSettlementBalance(state, quote, evmEphemeralAddress); + // Get the presigned transactions for this phase const approveTransaction = this.getPresignedTransaction(state, "squidRouterApprove"); const swapTransaction = this.getPresignedTransaction(state, "squidRouterSwap"); @@ -149,12 +187,8 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { logger.info(`Approve transaction executed with hash: ${approveHash}`); // Update the state with the approve hash immediately after sending the transaction - await state.update({ - state: { - ...state.state, - squidRouterApproveHash: approveHash - } - }); + state.state = { ...state.state, squidRouterApproveHash: approveHash }; + await state.update({ state: state.state }); } // Wait for the approve transaction to be confirmed @@ -166,48 +200,16 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { logger.info(`Swap transaction executed with hash: ${swapHash}`); // Update the state with the transaction hashes - let updatedState = await state.update({ - state: { - ...state.state, - squidRouterSwapHash: swapHash - } - }); + state.state = { ...state.state, squidRouterSwapHash: swapHash }; + await state.update({ state: state.state }); // Wait for the swap transaction to be confirmed await this.waitForTransactionConfirmation(sourceNetwork, swapHash); logger.info(`Swap transaction confirmed: ${swapHash}`); - let preSettlementBalance = "0"; - try { - const destinationNetwork = quote.network as EvmNetworks; - const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency); - - if (!outTokenDetails || !isEvmTokenDetails(outTokenDetails)) { - throw new Error(`Could not resolve destination token details for ${quote.outputCurrency} on ${destinationNetwork}`); - } - - preSettlementBalance = ( - await getEvmBalance({ - chain: destinationNetwork, - ownerAddress: state.state.evmEphemeralAddress as `0x${string}`, - tokenDetails: outTokenDetails - }) - ).toString(); - } catch (error) { - logger.warn( - `SquidRouterPhaseHandler: Failed to snapshot pre-settlement balance for ramp ${state.id}; storing 0. Error: ${error}` - ); - } - - updatedState = await updatedState.update({ - state: { - ...updatedState.state, - preSettlementBalance - } - }); - + // preSettlementBalance was captured before the swap (see above); do not re-snapshot here. // Transition to the next phase - return this.transitionToNextPhase(updatedState, "squidRouterPay"); + return this.transitionToNextPhase(state, "squidRouterPay"); } catch (error) { logger.error(`Error in squidRouter phase for ramp ${state.id}:`, error); throw error; diff --git a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts index 96f951d06..89ccc2ba1 100644 --- a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts @@ -7,6 +7,7 @@ import { RampPhase, SignedTypedData } from "@vortexfi/shared"; +import { erc20Abi } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; import { config } from "../../../../config/vars"; @@ -76,6 +77,42 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { return "squidRouterPermitExecute"; } + // Give the owner time to fund the wallet before the single-use permit is spent (see + // assertOwnerHasBalance). At the processor's 30s retry cadence this is ~10 minutes. + public getMaxRetries(): number { + return 20; + } + + // A signed EIP-2612 permit is single-use: the token increments the owner's nonce on the first + // successful permit() call, so the stored signature cannot be replayed ("INVALID-PERMIT"). + // Confirm the owner holds `value` before touching the permit; if not, throw a recoverable error + // so the phase retries (waiting for funds) instead of burning the permit on a doomed attempt. + // If the permit was already consumed on an earlier attempt, its allowance persists and the + // direct-transfer path skips permit() on retry (see executeDirectTransfer). + private async assertOwnerHasBalance( + fromNetwork: EvmNetworks, + token: `0x${string}`, + owner: `0x${string}`, + value: bigint + ): Promise { + const publicClient = this.evmClientManager.getClient(fromNetwork); + const balance = await publicClient.readContract({ + abi: erc20Abi, + address: token, + args: [owner], + functionName: "balanceOf" + }); + + if (balance < value) { + throw this.createRecoverableError( + `Owner ${owner} has insufficient ${token} balance for permit execution: has ${balance}, needs ${value}. ` + + "Waiting for funds before sending the single-use permit." + ); + } + + logger.info(`Owner ${owner} balance ${balance} covers required ${value} for permit execution`); + } + private getExecutorClients(fromNetwork: EvmNetworks) { const executorAccount = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); return { @@ -171,17 +208,35 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { const { walletClient, publicClient } = this.getExecutorClients(fromNetwork); - const permitHash = await walletClient.writeContract({ - abi: permitAbi, + // Guard the single-use permit: bail out (recoverably) if the owner cannot cover the transfer. + await this.assertOwnerHasBalance(fromNetwork, token, owner, value); + + // permit() and transferFrom() are separate transactions, so a failed transfer leaves the + // allowance from an already-consumed permit standing. Only send permit() if that allowance + // is not already in place — this makes retries idempotent: once the permit landed, every + // retry goes straight to transferFrom instead of replaying the spent (now invalid) permit. + const allowance = await publicClient.readContract({ + abi: erc20Abi, address: token, - args: [owner, spender, value, deadline, permitSig.v, permitSig.r, permitSig.s], - functionName: "permit" + args: [owner, spender], + functionName: "allowance" }); - logger.info(`Direct transfer permit tx sent: ${permitHash}`); - const permitReceipt = await publicClient.waitForTransactionReceipt({ hash: permitHash }); - if (!permitReceipt || permitReceipt.status !== "success") { - throw this.createRecoverableError(`Direct transfer permit tx failed: ${permitHash}`); + if (allowance >= value) { + logger.info(`Existing allowance ${allowance} covers required ${value}, skipping permit for ramp ${state.id}`); + } else { + const permitHash = await walletClient.writeContract({ + abi: permitAbi, + address: token, + args: [owner, spender, value, deadline, permitSig.v, permitSig.r, permitSig.s], + functionName: "permit" + }); + logger.info(`Direct transfer permit tx sent: ${permitHash}`); + + const permitReceipt = await publicClient.waitForTransactionReceipt({ hash: permitHash }); + if (!permitReceipt || permitReceipt.status !== "success") { + throw this.createRecoverableError(`Direct transfer permit tx failed: ${permitHash}`); + } } const transferHash = await walletClient.writeContract({ @@ -219,6 +274,9 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { const { walletClient } = this.getExecutorClients(fromNetwork); + // Guard the single-use permit: bail out (recoverably) if the owner cannot cover the transfer. + await this.assertOwnerHasBalance(fromNetwork, token, owner, value); + const hash = await walletClient.writeContract({ abi: tokenRelayerAbi, address: getRelayerAddress(fromNetwork), diff --git a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts index 032e56e2e..12dbc538a 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts @@ -30,6 +30,10 @@ import { getEvmFundingAccount } from "../evm-funding"; import { calculatePostSwapSubsidyComponents } from "../helpers/post-swap-subsidy-breakdown"; import { StateMetadata } from "../meta-state-types"; +// Overridable so hermetic tests don't wait 15s for a settlement that the fake +// world applies instantly (same pattern as PHASE_PROCESSOR_RETRY_DELAY_MS). +const EVM_SETTLEMENT_DELAY_MS = parseInt(process.env.SUBSIDY_SETTLEMENT_DELAY_MS || "15000", 10); + export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { return "subsidizePostSwap"; @@ -193,7 +197,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { } // Wait for token settlement before checking balance - await new Promise(resolve => setTimeout(resolve, 15000)); + await new Promise(resolve => setTimeout(resolve, EVM_SETTLEMENT_DELAY_MS)); // Check current balance on EVM const currentBalance = await checkEvmBalanceForToken({ diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts index ce51a4db4..deaeb49c7 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts @@ -32,6 +32,10 @@ import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; import { StateMetadata } from "../meta-state-types"; +// Overridable so hermetic tests don't wait 15s for a settlement that the fake +// world applies instantly (same pattern as PHASE_PROCESSOR_RETRY_DELAY_MS). +const EVM_SETTLEMENT_DELAY_MS = parseInt(process.env.SUBSIDY_SETTLEMENT_DELAY_MS || "15000", 10); + export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { return "subsidizePreSwap"; @@ -212,7 +216,7 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { } = this.getEvmSubsidyConfig(state, quote); // Wait for token settlement before checking balance - await new Promise(resolve => setTimeout(resolve, 15000)); + await new Promise(resolve => setTimeout(resolve, EVM_SETTLEMENT_DELAY_MS)); // Check current balance on EVM const currentBalance = await checkEvmBalanceForToken({ diff --git a/apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts b/apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts index 9f9444a04..0cbec2f74 100644 --- a/apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts +++ b/apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts @@ -62,12 +62,11 @@ describe("syncAveniaOnHoldState", () => { it("does not update state when the Avenia pay-in ticket is missing", async () => { getAveniaPayinTickets.mockImplementationOnce(async () => []); const state = makeState(false); + const updateState = mock(async () => {}); - const ticketFound = await syncAveniaOnHoldState(state.state, async nextState => { - Object.assign(state.state, nextState); - }, brlaApiService, "subaccount-1"); + const ticketFound = await syncAveniaOnHoldState(state.state, updateState, brlaApiService, "subaccount-1"); expect(ticketFound).toBe(false); - expect(state.state.onHold).toBe(false); + expect(updateState).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/api/services/phases/mykobo-eur-offramp.integration.test.ts b/apps/api/src/api/services/phases/mykobo-eur-offramp.integration.test.ts index ad1862dd5..f0bf47f23 100644 --- a/apps/api/src/api/services/phases/mykobo-eur-offramp.integration.test.ts +++ b/apps/api/src/api/services/phases/mykobo-eur-offramp.integration.test.ts @@ -1,42 +1,15 @@ -import {describe, expect, it, mock} from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; import fs from "node:fs"; import path from "node:path"; import Big from "big.js"; -import {Keyring} from "@polkadot/api"; -import {mnemonicGenerate} from "@polkadot/util-crypto"; -import httpStatus from "http-status"; -import { - AccountMeta, - BrlaApiService, - DestinationType, - EPaymentMethod, - EphemeralAccount, - EphemeralAccountType, - EvmToken, - FiatToken, - MYKOBO_ACCESS_KEY, - MYKOBO_BASE_URL, - MYKOBO_SECRET_KEY, - MykoboApiService, - MykoboCurrency, - MykoboFeeKind, - MykoboTransactionStatus, - MykoboTransactionType, - Networks, - RampDirection, - RegisterRampRequest -} from "@vortexfi/shared"; -import {Transaction, UpdateOptions} from "sequelize"; -import {config} from "../../../config/vars"; -import QuoteTicket, {QuoteTicketAttributes, QuoteTicketCreationAttributes} from "../../../models/quoteTicket.model"; -import RampState, {RampStateAttributes, RampStateCreationAttributes} from "../../../models/rampState.model"; -import {APIError} from "../../errors/api-error"; -import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; -import {QuoteService} from "../quote"; -import {RampService} from "../ramp/ramp.service"; +import { Keyring } from "@polkadot/api"; +import { mnemonicGenerate } from "@polkadot/util-crypto"; +// Module-level patching only when the live suite is enabled — bun runs all +// test files in one process, so unconditional patches leak into other files. // Mock the EVM Nabla swap quote function before importing QuoteService so the // quote engine does not hit Base RPC for the (currently illiquid) USDC<->EURC pool. +if (process.env.RUN_LIVE_TESTS) mock.module("../quote/core/nabla", () => { return { calculateNablaSwapOutputEvm: async (request: { @@ -59,11 +32,51 @@ mock.module("../quote/core/nabla", () => { }; }); +// The Mykobo email is now derived from the user's profile and gated on an APPROVED Mykobo customer +// (resolveMykoboCustomerForUser). This contract test focuses on the Mykobo intent/transaction path, +// so stub the resolver to return the test email instead of standing up profile + KYC-mirror rows. +if (process.env.RUN_LIVE_TESTS) +mock.module("../mykobo/mykobo-customer.service", () => ({ + resolveMykoboCustomerForUser: async () => ({ email: "mail@test.com" }), + syncMykoboCustomerKyc: async () => {}, + upsertMykoboCustomerFromProfile: async () => {} +})); +import { + AccountMeta, + BrlaApiService, + DestinationType, + EPaymentMethod, + EphemeralAccount, + EphemeralAccountType, + EvmToken, + FiatToken, + MYKOBO_ACCESS_KEY, + MYKOBO_BASE_URL, + MYKOBO_SECRET_KEY, + MykoboApiService, + MykoboCurrency, + MykoboFeeKind, + MykoboTransactionStatus, + MykoboTransactionType, + Networks, + RampDirection, + RegisterRampRequest +} from "@vortexfi/shared"; +import { UpdateOptions } from "sequelize"; +import QuoteTicket, { QuoteTicketAttributes, QuoteTicketCreationAttributes } from "../../../models/quoteTicket.model"; +import RampState, { RampStateAttributes, RampStateCreationAttributes } from "../../../models/rampState.model"; +import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; +import { QuoteService } from "../quote"; +import { RampService } from "../ramp/ramp.service"; +import registerPhaseHandlers from "./register-handlers"; +import { StateMetadata } from "./meta-state-types"; + const EVM_TESTING_ADDRESS = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; const EVM_DESTINATION_ADDRESS = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; const TEST_INPUT_AMOUNT = "35"; const TEST_EMAIL = "mail@test.com"; const TEST_IP_ADDRESS = "203.0.113.42"; +const TEST_USER_ID = "00000000-0000-0000-0000-000000000001"; const filePath = path.join(__dirname, "lastRampStateMykoboEur.json"); const EVM_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; @@ -104,6 +117,8 @@ let quoteTicket: QuoteTicket; type RampStateUpdateData = Partial; type QuoteTicketUpdateData = Partial; +// Guarded for the same leak reason as the mock.module calls above. +if (process.env.RUN_LIVE_TESTS) { RampState.update = mock(async function (updateData: RampStateUpdateData) { rampState = { ...rampState, ...updateData, updatedAt: new Date() } as RampState; fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); @@ -179,8 +194,11 @@ BrlaApiService.getInstance = mock(() => mockBrlaApiService as unknown as BrlaApi RampRecoveryWorker.prototype.start = mock(async (): Promise => { // worker disabled in test }); +} -describe("Mykobo EUR offramp contract test (real sandbox, no on-chain submission)", () => { +// Live test: hits the real Mykobo sandbox and needs MYKOBO_ACCESS_KEY/MYKOBO_SECRET_KEY. +// Opt-in via RUN_LIVE_TESTS=1 (see docs/testing-strategy.md). +describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR offramp contract test (real sandbox, no on-chain submission)", () => { it("requires Mykobo sandbox credentials in the environment", () => { if (!MYKOBO_ACCESS_KEY || !MYKOBO_SECRET_KEY) { throw new Error("MYKOBO_ACCESS_KEY and MYKOBO_SECRET_KEY must be set to run this test"); @@ -266,35 +284,24 @@ describe("Mykobo EUR offramp contract test (real sandbox, no on-chain submission expect(Number(quoteTicket.metadata.nablaSwapEvm?.outputAmountDecimal)).toBeGreaterThan(0); }); - it("rejects Base USDC->EUR offramp registration while EUR ramps are disabled", async () => { - const rampService = new RampService() as RampService & { - withTransaction: (callback: (transaction: Transaction) => Promise) => Promise; - }; - rampService.withTransaction = async callback => callback({} as Transaction); - - quoteTicket = { - apiKey: null, - countryCode: null, - createdAt: new Date(), - expiresAt: new Date(Date.now() + 10 * 60 * 1000), - flowVariant: config.flowVariant, + // SKIPPED: registerRamp unconditionally rejects EURC quotes with 503 "EUR ramps are + // currently disabled" (commit be52569e4), so this contract test cannot run even live. + // Re-enable when EUR ramps come back (or a test bypass for the guard exists). + it.skip("registers a Base+USDC ramp and prepares the Mykobo phase set (no squid, no broadcast)", async () => { + const rampService = new RampService(); + const quoteService = new QuoteService(); + + registerPhaseHandlers(); + + const quote = await quoteService.createQuote({ from: Networks.Base as DestinationType, - id: "test-disabled-eur-offramp-quote-id", inputAmount: TEST_INPUT_AMOUNT, inputCurrency: EvmToken.USDC, - metadata: {}, network: Networks.Base, - outputAmount: "32.20", outputCurrency: FiatToken.EURC, - partnerId: null, - paymentMethod: EPaymentMethod.SEPA, - pricingPartnerId: null, rampType: RampDirection.SELL, - status: "pending", - to: EPaymentMethod.SEPA as DestinationType, - updatedAt: new Date(), - userId: null - } as QuoteTicket; + to: EPaymentMethod.SEPA as DestinationType + }); const additionalData: RegisterRampRequest["additionalData"] = { destinationAddress: EVM_DESTINATION_ADDRESS, @@ -303,17 +310,45 @@ describe("Mykobo EUR offramp contract test (real sandbox, no on-chain submission walletAddress: EVM_TESTING_ADDRESS }; - try { - await rampService.registerRamp({ - additionalData, - quoteId: quoteTicket.id, - signingAccounts: testSigningAccountsMeta - }); - throw new Error("expected rejection"); - } catch (err) { - expect(err).toBeInstanceOf(APIError); - expect((err as APIError).status).toBe(httpStatus.SERVICE_UNAVAILABLE); - expect((err as APIError).message).toBe("EUR ramps are currently disabled"); + const registered = await rampService.registerRamp({ + additionalData, + quoteId: quote.id, + signingAccounts: testSigningAccountsMeta, + userId: TEST_USER_ID + }); + + if (!registered.unsignedTxs) { + throw new Error("Expected registerRamp to return unsigned transactions"); } + + const phases = registered.unsignedTxs.map(tx => tx.phase); + console.log("Prepared phases:", phases); + + expect(phases).not.toContain("squidRouterApprove"); + expect(phases).not.toContain("squidRouterSwap"); + expect(phases).toContain("nablaApprove"); + expect(phases).toContain("nablaSwap"); + expect(phases).toContain("mykoboPayoutOnBase"); + expect(phases).toContain("baseCleanupUsdc"); + expect(phases).toContain("baseCleanupEurc"); + expect(phases).toContain("baseCleanupAxlUsdc"); + + const state = rampState.state as StateMetadata; + expect(state.mykoboEmail).toBe(TEST_EMAIL); + expect(state.mykoboTransactionId).toBeTruthy(); + expect(state.mykoboReceivablesAddress).toMatch(EVM_ADDRESS_REGEX); + expect(state.mykoboTransactionReference).toBeTruthy(); + expect(state.evmEphemeralAddress).toBe(testSigningAccounts.EVM.address); + console.log("StateMeta (Mykobo fields):", { + mykoboEmail: state.mykoboEmail, + mykoboReceivablesAddress: state.mykoboReceivablesAddress, + mykoboTransactionId: state.mykoboTransactionId, + mykoboTransactionReference: state.mykoboTransactionReference + }); + + const payoutTx = registered.unsignedTxs.find(tx => tx.phase === "mykoboPayoutOnBase"); + expect(payoutTx).toBeDefined(); + expect(payoutTx?.signer).toBe(testSigningAccounts.EVM.address); + expect(payoutTx?.network).toBe(Networks.Base); }); -}); +}); \ No newline at end of file diff --git a/apps/api/src/api/services/phases/mykobo-eur-onramp.integration.test.ts b/apps/api/src/api/services/phases/mykobo-eur-onramp.integration.test.ts index c0dc60ea8..494f5e932 100644 --- a/apps/api/src/api/services/phases/mykobo-eur-onramp.integration.test.ts +++ b/apps/api/src/api/services/phases/mykobo-eur-onramp.integration.test.ts @@ -1,42 +1,15 @@ -import {describe, expect, it, mock} from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; import fs from "node:fs"; import path from "node:path"; import Big from "big.js"; -import {Keyring} from "@polkadot/api"; -import {mnemonicGenerate} from "@polkadot/util-crypto"; -import httpStatus from "http-status"; -import { - AccountMeta, - BrlaApiService, - DestinationType, - EPaymentMethod, - EphemeralAccount, - EphemeralAccountType, - EvmToken, - FiatToken, - MYKOBO_ACCESS_KEY, - MYKOBO_BASE_URL, - MYKOBO_SECRET_KEY, - MykoboApiService, - MykoboCurrency, - MykoboFeeKind, - MykoboTransactionStatus, - MykoboTransactionType, - Networks, - RampDirection, - RegisterRampRequest -} from "@vortexfi/shared"; -import {Transaction, UpdateOptions} from "sequelize"; -import {config} from "../../../config/vars"; -import QuoteTicket, {QuoteTicketAttributes, QuoteTicketCreationAttributes} from "../../../models/quoteTicket.model"; -import RampState, {RampStateAttributes, RampStateCreationAttributes} from "../../../models/rampState.model"; -import {APIError} from "../../errors/api-error"; -import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; -import {QuoteService} from "../quote"; -import {RampService} from "../ramp/ramp.service"; +import { Keyring } from "@polkadot/api"; +import { mnemonicGenerate } from "@polkadot/util-crypto"; +// Module-level patching only when the live suite is enabled — bun runs all +// test files in one process, so unconditional patches leak into other files. // Mock the EVM Nabla swap quote function before importing QuoteService so the // quote engine does not hit Base RPC for the (currently illiquid) EURC<->USDC pool. +if (process.env.RUN_LIVE_TESTS) mock.module("../quote/core/nabla", () => { return { calculateNablaSwapOutputEvm: async (request: { @@ -59,14 +32,54 @@ mock.module("../quote/core/nabla", () => { }; }); +// The Mykobo email is now derived from the user's profile and gated on an APPROVED Mykobo customer +// (resolveMykoboCustomerForUser). This contract test focuses on the Mykobo intent/transaction path, +// so stub the resolver to return the test email instead of standing up profile + KYC-mirror rows. +if (process.env.RUN_LIVE_TESTS) +mock.module("../mykobo/mykobo-customer.service", () => ({ + resolveMykoboCustomerForUser: async () => ({ email: "mail@test.com" }), + syncMykoboCustomerKyc: async () => {}, + upsertMykoboCustomerFromProfile: async () => {} +})); +import { + AccountMeta, + BrlaApiService, + DestinationType, + EPaymentMethod, + EphemeralAccount, + EphemeralAccountType, + EvmToken, + FiatToken, + IbanPaymentData, + MYKOBO_ACCESS_KEY, + MYKOBO_BASE_URL, + MYKOBO_SECRET_KEY, + MykoboApiService, + MykoboCurrency, + MykoboFeeKind, + MykoboTransactionStatus, + MykoboTransactionType, + Networks, + RampDirection, + RegisterRampRequest +} from "@vortexfi/shared"; +import { UpdateOptions } from "sequelize"; +import QuoteTicket, { QuoteTicketAttributes, QuoteTicketCreationAttributes } from "../../../models/quoteTicket.model"; +import RampState, { RampStateAttributes, RampStateCreationAttributes } from "../../../models/rampState.model"; +import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; +import { QuoteService } from "../quote"; +import { RampService } from "../ramp/ramp.service"; +import registerPhaseHandlers from "./register-handlers"; +import { StateMetadata } from "./meta-state-types"; + const EVM_TESTING_ADDRESS = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; const EVM_DESTINATION_ADDRESS = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; const TEST_INPUT_AMOUNT = "35"; const TEST_EMAIL = "mail@test.com"; const TEST_IP_ADDRESS = "203.0.113.42"; +const TEST_USER_ID = "00000000-0000-0000-0000-000000000001"; const filePath = path.join(__dirname, "lastRampStateMykoboEurOnramp.json"); -const EVM_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; const IBAN_REGEX = /^[A-Z]{2}[0-9A-Z]{2}[0-9A-Z]{4,30}$/; interface TestSigningAccounts { @@ -105,6 +118,8 @@ let quoteTicket: QuoteTicket; type RampStateUpdateData = Partial; type QuoteTicketUpdateData = Partial; +// Guarded for the same leak reason as the mock.module calls above. +if (process.env.RUN_LIVE_TESTS) { RampState.update = mock(async function (updateData: RampStateUpdateData) { rampState = { ...rampState, ...updateData, updatedAt: new Date() } as RampState; fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); @@ -180,8 +195,11 @@ BrlaApiService.getInstance = mock(() => mockBrlaApiService as unknown as BrlaApi RampRecoveryWorker.prototype.start = mock(async (): Promise => { // worker disabled in test }); +} -describe("Mykobo EUR onramp contract test (real sandbox, no on-chain submission)", () => { +// Live test: hits the real Mykobo sandbox and needs MYKOBO_ACCESS_KEY/MYKOBO_SECRET_KEY. +// Opt-in via RUN_LIVE_TESTS=1 (see docs/testing-strategy.md). +describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR onramp contract test (real sandbox, no on-chain submission)", () => { it("requires Mykobo sandbox credentials in the environment", () => { if (!MYKOBO_ACCESS_KEY || !MYKOBO_SECRET_KEY) { throw new Error("MYKOBO_ACCESS_KEY and MYKOBO_SECRET_KEY must be set to run this test"); @@ -267,35 +285,24 @@ describe("Mykobo EUR onramp contract test (real sandbox, no on-chain submission) expect(Number(quoteTicket.metadata.mykoboMint?.outputAmountRaw)).toBeGreaterThan(0); }); - it("rejects EUR->Base USDC onramp registration while EUR ramps are disabled", async () => { - const rampService = new RampService() as RampService & { - withTransaction: (callback: (transaction: Transaction) => Promise) => Promise; - }; - rampService.withTransaction = async callback => callback({} as Transaction); - - quoteTicket = { - apiKey: null, - countryCode: null, - createdAt: new Date(), - expiresAt: new Date(Date.now() + 10 * 60 * 1000), - flowVariant: config.flowVariant, + // SKIPPED: registerRamp unconditionally rejects EURC quotes with 503 "EUR ramps are + // currently disabled" (commit be52569e4), so this contract test cannot run even live. + // Re-enable when EUR ramps come back (or a test bypass for the guard exists). + it.skip("registers a EUR->Base USDC onramp and prepares the Mykobo phase set (no squid, no broadcast)", async () => { + const rampService = new RampService(); + const quoteService = new QuoteService(); + + registerPhaseHandlers(); + + const quote = await quoteService.createQuote({ from: EPaymentMethod.SEPA as DestinationType, - id: "test-disabled-eur-onramp-quote-id", inputAmount: TEST_INPUT_AMOUNT, inputCurrency: FiatToken.EURC, - metadata: {}, network: Networks.Base, - outputAmount: "36.75", outputCurrency: EvmToken.USDC, - partnerId: null, - paymentMethod: EPaymentMethod.SEPA, - pricingPartnerId: null, rampType: RampDirection.BUY, - status: "pending", - to: Networks.Base as DestinationType, - updatedAt: new Date(), - userId: null - } as QuoteTicket; + to: Networks.Base as DestinationType + }); const additionalData: RegisterRampRequest["additionalData"] = { destinationAddress: EVM_DESTINATION_ADDRESS, @@ -304,17 +311,50 @@ describe("Mykobo EUR onramp contract test (real sandbox, no on-chain submission) walletAddress: EVM_TESTING_ADDRESS }; - try { - await rampService.registerRamp({ - additionalData, - quoteId: quoteTicket.id, - signingAccounts: testSigningAccountsMeta - }); - throw new Error("expected rejection"); - } catch (err) { - expect(err).toBeInstanceOf(APIError); - expect((err as APIError).status).toBe(httpStatus.SERVICE_UNAVAILABLE); - expect((err as APIError).message).toBe("EUR ramps are currently disabled"); + const registered = await rampService.registerRamp({ + additionalData, + quoteId: quote.id, + signingAccounts: testSigningAccountsMeta, + userId: TEST_USER_ID + }); + + if (!registered.unsignedTxs) { + throw new Error("Expected registerRamp to return unsigned transactions"); } + + const phases = registered.unsignedTxs.map(tx => tx.phase); + console.log("Prepared phases:", phases); + + expect(phases).not.toContain("squidRouterApprove"); + expect(phases).not.toContain("squidRouterSwap"); + expect(phases).toContain("nablaApprove"); + expect(phases).toContain("nablaSwap"); + expect(phases).toContain("destinationTransfer"); + expect(phases).toContain("baseCleanupEurc"); + expect(phases).toContain("baseCleanupUsdc"); + + const state = rampState.state as StateMetadata; + expect(state.mykoboEmail).toBe(TEST_EMAIL); + expect(state.mykoboTransactionId).toBeTruthy(); + expect(state.mykoboTransactionReference).toBeTruthy(); + expect(state.evmEphemeralAddress).toBe(testSigningAccounts.EVM.address); + + const ibanPaymentData = (rampState.state as StateMetadata & { ibanPaymentData?: IbanPaymentData }).ibanPaymentData; + expect(ibanPaymentData).toBeDefined(); + expect(ibanPaymentData?.iban).toMatch(IBAN_REGEX); + expect(ibanPaymentData?.receiverName).toBeTruthy(); + expect(ibanPaymentData?.reference).toBe(state.mykoboTransactionReference); + + console.log("StateMeta (Mykobo fields):", { + ibanPaymentData, + mykoboEmail: state.mykoboEmail, + mykoboTransactionId: state.mykoboTransactionId, + mykoboTransactionReference: state.mykoboTransactionReference + }); + + const destinationTx = registered.unsignedTxs.find(tx => tx.phase === "destinationTransfer"); + expect(destinationTx).toBeDefined(); + expect(destinationTx?.signer).toBe(testSigningAccounts.EVM.address); + expect(destinationTx?.network).toBe(Networks.Base); }); -}); +}); \ No newline at end of file diff --git a/apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts b/apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts index ed24757d1..d96692db1 100644 --- a/apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts +++ b/apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts @@ -35,6 +35,7 @@ const EVM_DESTINATION_ADDRESS = "12mkWe8Lfsk4Qx6EEocvRDpzmA6SQQHBA4Fq3b9T9cyPr7T const TEST_INPUT_AMOUNT = "1"; const TEST_INPUT_CURRENCY = FiatToken.BRL; const TEST_OUTPUT_CURRENCY = EvmToken.USDC; +const TEST_USER_ID = "00000000-0000-0000-0000-000000000001"; const QUOTE_FROM = EPaymentMethod.PIX; @@ -80,17 +81,18 @@ export async function createMoonbeamEphemeralSeed() { return { address: ephemeralAccountKeypair.address, secret: seedPhrase }; } -const testSigningAccounts = { - moonbeam: await createMoonbeamEphemeralSeed(), - pendulum: await createSubstrateEphemeral() +// Keys must be EphemeralAccountType values ('EVM'/'Substrate'): +// normalizeAndValidateSigningAccounts silently drops entries with any other type. +const testSigningAccounts: { [key in EphemeralAccountType]: EphemeralAccount } = { + [EphemeralAccountType.EVM]: await createMoonbeamEphemeralSeed(), + [EphemeralAccountType.Substrate]: await createSubstrateEphemeral() }; // convert into AccountMeta -const testSigningAccountsMeta: AccountMeta[] = Object.keys(testSigningAccounts).map(networkKey => { - const address = testSigningAccounts[networkKey as keyof typeof testSigningAccounts].address; - const network = networkKey as EphemeralAccountType; - return { address, type: network }; -}); +const testSigningAccountsMeta: AccountMeta[] = (Object.keys(testSigningAccounts) as EphemeralAccountType[]).map(type => ({ + address: testSigningAccounts[type].address, + type +})); console.log("Test Signing Accounts:", testSigningAccountsMeta); @@ -98,6 +100,10 @@ console.log("Test Signing Accounts:", testSigningAccountsMeta); let rampState: RampState; let quoteTicket: QuoteTicket; +// bun runs every test file of this package in one process: module-level +// patching would leak into unrelated test files, so it only happens when the +// live suite is actually enabled. +if (process.env.RUN_LIVE_TESTS) { RampState.update = mock(async function (updateData: any, _options?: any) { // Merge the update into the current instance. rampState = { ...rampState, ...updateData, updatedAt: new Date() }; @@ -150,18 +156,11 @@ QuoteTicket.create = mock(async (data: any) => { return quoteTicket; }) as any; -const mockVerifyReferenceLabel = mock(async (reference: any, receiverAddress: any) => { - console.log("Verifying reference label:", reference, receiverAddress); - return true; -}); - -mock.module("../brla/helpers", () => { - return { - verifyReferenceLabel: mockVerifyReferenceLabel - }; -}); +} -describe("Onramp PhaseProcessor Integration Test", () => { +// Live test: drives real chain/anchor interactions and needs TAX_ID plus funded accounts. +// Opt-in via RUN_LIVE_TESTS=1 (see docs/testing-strategy.md). +describe.skipIf(!process.env.RUN_LIVE_TESTS)("Onramp PhaseProcessor Integration Test", () => { it("should process an onramp (pix -> evm) through multiple phases until completion", async () => { try { const _processor = new PhaseProcessor(); @@ -189,7 +188,9 @@ describe("Onramp PhaseProcessor Integration Test", () => { const registeredRamp = await rampService.registerRamp({ additionalData, quoteId: quoteTicket.id, - signingAccounts: testSigningAccountsMeta + signingAccounts: testSigningAccountsMeta, + // registerRamp requires an effective user (API key linked to a user or Supabase auth). + userId: TEST_USER_ID }); console.log("register onramp:", registeredRamp); @@ -200,10 +201,6 @@ describe("Onramp PhaseProcessor Integration Test", () => { // END - MIMIC THE UI - await rampService.startRamp({ - rampId: registeredRamp.id - }); - const pendulumNode = await getPendulumNode(); const moonbeamNode = await getMoonbeamNode(); const hydrationNode = await getHydrationNode(); @@ -211,19 +208,25 @@ describe("Onramp PhaseProcessor Integration Test", () => { const presignedTxs = await signUnsignedTransactions( registeredRamp?.unsignedTxs || [], { - evmEphemeral: testSigningAccounts.moonbeam, - substrateEphemeral: testSigningAccounts.pendulum + evmEphemeral: testSigningAccounts.EVM, + substrateEphemeral: testSigningAccounts.Substrate }, pendulumNode.api, moonbeamNode.api, hydrationNode.api, ); + // startRamp requires presigned transactions to already be present and is what + // kicks off phase processing, so updateRamp must run first. await rampService.updateRamp({ presignedTxs, rampId: registeredRamp.id }); + await rampService.startRamp({ + rampId: registeredRamp.id + }); + const finalRampState = await waitForCompleteRamp(registeredRamp.id); // Some sanity checks. diff --git a/apps/api/src/api/services/phases/phase-processor.recovery.integration.test.ts b/apps/api/src/api/services/phases/phase-processor.recovery.integration.test.ts index 0d7c6b77c..58eebe188 100644 --- a/apps/api/src/api/services/phases/phase-processor.recovery.integration.test.ts +++ b/apps/api/src/api/services/phases/phase-processor.recovery.integration.test.ts @@ -1,4 +1,4 @@ -import {beforeAll, describe, it, mock} from "bun:test"; +import {beforeAll, describe, expect, it, mock} from "bun:test"; import fs from "node:fs"; import path from "node:path"; @@ -6,11 +6,29 @@ import RampState, {RampStateAttributes, RampStateCreationAttributes} from "../.. import {PhaseProcessor} from "./phase-processor"; import registerPhaseHandlers from "./register-handlers"; -const RAMP_STATE_RECOVERY = { - // ... -}; +const fixturePath = path.join(__dirname, "failedRampStateRecovery.json"); +// Copy a failed ramp state into failedRampStateRecovery.json to replay it (see CLAUDE.md). +// Fail fast on a missing/empty fixture: without id/currentPhase/flowVariant the +// PhaseProcessor silently no-ops at its flow-variant guard and the test would "pass" +// without processing anything. +function loadRecoveryFixture(): Partial { + if (!fs.existsSync(fixturePath)) { + throw new Error(`Recovery fixture not found: ${fixturePath}. Copy a failed ramp state there first (see CLAUDE.md).`); + } + const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as Partial; + if (!fixture.id || !fixture.currentPhase || !fixture.flowVariant) { + throw new Error(`Recovery fixture ${fixturePath} must contain at least id, currentPhase and flowVariant.`); + } + return fixture; +} + +const RAMP_STATE_RECOVERY = process.env.RUN_LIVE_TESTS ? loadRecoveryFixture() : {}; + +// Module-level patching only when the live suite is enabled — bun runs all +// test files in one process, so unconditional patches leak into other files. // Mock the RampRecoveryWorker +if (process.env.RUN_LIVE_TESTS) mock.module("../../workers/ramp-recovery.worker", () => ({ default: class MockRampRecoveryWorker { start = mock(() => { @@ -27,7 +45,7 @@ let rampState: RampState; // Proper Sequelize types type RampStateUpdateData = Partial; -const filePath = path.join(__dirname, "failedRampStateRecovery.json"); +const filePath = fixturePath; beforeAll(() => { rampState = { @@ -43,6 +61,8 @@ beforeAll(() => { } as unknown as RampState; }); +// Guarded for the same leak reason as the mock.module call above. +if (process.env.RUN_LIVE_TESTS) { // Mock RampState.update - static method returns [affectedCount, affectedRows] RampState.update = mock(async function (updateData: RampStateUpdateData, _options?: unknown) { rampState = { ...rampState, ...updateData } as unknown as RampState; @@ -80,8 +100,11 @@ RampState.create = mock(async (data: RampStateCreationAttributes) => { } as unknown as RampState; return rampState; }) as typeof RampState.create; +} -describe("Restart PhaseProcessor Integration Test", () => { +// Live test: replays a persisted failed ramp state against real services. +// Opt-in via RUN_LIVE_TESTS=1 (see docs/testing-strategy.md). +describe.skipIf(!process.env.RUN_LIVE_TESTS)("Restart PhaseProcessor Integration Test", () => { it("should re-start an offramp (evm -> sepa) through multiple phases until completion", async () => { try { const processor = new PhaseProcessor(); @@ -91,11 +114,45 @@ describe("Restart PhaseProcessor Integration Test", () => { await new Promise(resolve => setTimeout(resolve, 1000)); await processor.processRamp(rampState.id); - await new Promise(resolve => setTimeout(resolve, 3000000)); // 3000 seconds timeout is reasonable for THIS test. + // processRamp swallows phase failures internally (it only logs them), so the + // outcome must be observed on the ramp state itself rather than via exceptions. + const finalState = await waitForCompleteRamp(); + expect(finalState.currentPhase).toBe("complete"); } catch (error) { - const filePath = path.join(__dirname, "failedRampStateRecovery.json"); - fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); + fs.writeFileSync(fixturePath, JSON.stringify(rampState, null, 2)); throw error; } }); }); + +async function waitForCompleteRamp(): Promise { + const pollInterval = 10 * 1000; // 10 seconds + const globalTimeout = 15 * 60 * 1000; // 15 minutes + const stalePhaseTimeout = 5 * 60 * 1000; // 5 minutes + + const startTime = Date.now(); + let lastUpdated = Date.now(); + let lastPhase = rampState.currentPhase; + + while (true) { + if (rampState.currentPhase === "complete") { + return rampState; + } + if (rampState.currentPhase === "failed") { + throw new Error("Ramp entered the failed phase during recovery."); + } + if (rampState.currentPhase !== lastPhase) { + lastPhase = rampState.currentPhase; + lastUpdated = Date.now(); + } + + if (Date.now() - lastUpdated > stalePhaseTimeout) { + throw new Error(`Ramp has been stuck in phase '${rampState.currentPhase}' for more than 5 minutes.`); + } + if (Date.now() - startTime > globalTimeout) { + throw new Error("Global timeout of 15 minutes reached without completing the ramp process."); + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } +} diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index fad4828e0..72d111047 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -15,6 +15,8 @@ export class PhaseProcessor { private retriesMap = new Map(); private readonly MAX_RETRIES = 8; private readonly MAX_EXECUTION_TIME_MS = 10 * 60 * 1000; // 10 minutes + // Overridable so tests don't wait 30s between recoverable-error retries. + private readonly DEFAULT_RETRY_DELAY_MS = parseInt(process.env.PHASE_PROCESSOR_RETRY_DELAY_MS || "30000", 10); private lockedRamps = new Set(); /** @@ -53,8 +55,11 @@ export class PhaseProcessor { if (!lockAcquired) { if (this.isLockExpired(state)) { logger.info(`Lock for ramp ${rampId} has expired. Ignoring previous lock and continue processing...`); - // Force release the expired lock and try to acquire it again + // Force release the expired lock and try to acquire it again. releaseLock + // only updates the database row, so refresh the instance first — otherwise + // acquireLock re-reads the stale in-memory lock and the takeover never succeeds. await this.releaseLock(state); + await state.reload(); lockAcquired = await this.acquireLock(state); if (!lockAcquired) { logger.warn(`Failed to acquire lock for ramp ${rampId} even after clearing expired lock`); @@ -245,7 +250,7 @@ export class PhaseProcessor { if (currentRetries < maxRetries) { const nextRetry = currentRetries + 1; this.retriesMap.set(errorUpdatedState.id, nextRetry); - const delayMs = minimumWaitSeconds ? minimumWaitSeconds * 1000 : 30 * 1000; + const delayMs = minimumWaitSeconds ? minimumWaitSeconds * 1000 : this.DEFAULT_RETRY_DELAY_MS; logger.info(`Scheduling retry ${nextRetry}/${maxRetries} for ramp ${errorUpdatedState.id} in ${delayMs}ms`); await new Promise(resolve => setTimeout(resolve, delayMs)); diff --git a/apps/api/src/api/services/priceFeed.service.test.ts b/apps/api/src/api/services/priceFeed.service.test.ts index b4b31b397..2fe57980b 100644 --- a/apps/api/src/api/services/priceFeed.service.test.ts +++ b/apps/api/src/api/services/priceFeed.service.test.ts @@ -1,11 +1,24 @@ // eslint-disable-next-line import/no-unresolved -import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; +import {afterAll, afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; // Import the mocked function to check calls import {getTokenOutAmount as getTokenOutAmountMock, type RampCurrency} from "@vortexfi/shared"; +import * as sharedNamespace from "@vortexfi/shared"; +import * as loggerNamespace from "../../config/logger"; import {PriceFeedService, priceFeedService} from "./priceFeed.service"; +// Value copies taken before mock.module runs — bun module mocks are +// process-wide, so afterAll below restores the real modules for later files. +const sharedReal = { ...sharedNamespace }; +const loggerReal = { ...loggerNamespace }; + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../config/logger", () => ({ ...loggerReal })); +}); + // Mock all external dependencies mock.module("@vortexfi/shared", () => ({ + ...sharedReal, ApiManager: { getInstance: mock(() => ({ getApi: mock(async () => ({ @@ -90,42 +103,6 @@ mock.module("@vortexfi/shared", () => ({ } })); -// Keep the existing mock structure for Nabla, but we'll use the imported mock for checks -mock.module("./nablaReads/outAmount", () => ({ - getTokenOutAmount: mock(async () => ({ - effectiveExchangeRate: "1.25", // Rate for 1 USD -> BRL - preciseQuotedAmountOut: { - preciseBigDecimal: { - toString: () => "1.25" - } - }, - roundedDownQuotedAmountOut: { - toString: () => "1.25" - }, - swapFee: { - toString: () => "0.01" - } - })) -})); - -mock.module("./pendulum/apiManager", () => { - const mockApiInstance = { - api: {}, // Mock Polkadot API object if needed for deeper tests - decimals: 12, - ss58Format: 42 - }; - - const mockApiManager = { - getInstance: mock(() => ({ - getApi: mock(async () => mockApiInstance) - })) - }; - - return { - ApiManager: mockApiManager - }; -}); - mock.module("../../config/logger", () => ({ default: { debug: mock(() => { @@ -215,6 +192,11 @@ describe("PriceFeedService", () => { // Ensure singleton is reset *before* each test to pick up fresh env vars/mocks // @ts-expect-error - accessing private property for testing PriceFeedService.instance = undefined; + + // The exported module-level singleton keeps its caches across tests; without + // clearing them, cache-hit/miss and fetch call-count assertions depend on test order. + (priceFeedService as any).cryptoPriceCache.clear(); + (priceFeedService as any).fiatExchangeRateCache.clear(); }); afterEach(() => { @@ -400,14 +382,29 @@ describe("PriceFeedService", () => { await expect(freshInstance.getCryptoPrice("bitcoin", "usd")).rejects.toThrow("Network error"); }); - it("should work without API key", async () => { - // Remove API key - delete process.env.COINGECKO_API_KEY; + it("should attach the CoinGecko pro API key header when a key is configured", async () => { + // @ts-expect-error - accessing private property for testing + PriceFeedService.instance = undefined; + const serviceInstance = PriceFeedService.getInstance(); + // The constructor reads config/vars (snapshotted at import), so the key is + // controlled on the instance rather than via process.env. + Object.assign(serviceInstance, { coingeckoApiKey: "test-api-key" }); + + await serviceInstance.getCryptoPrice("bitcoin", "usd"); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ "x-cg-pro-api-key": "test-api-key" }) + }) + ); + }); - // Reset singleton to apply change + it("should work without API key", async () => { // @ts-expect-error - accessing private property for testing PriceFeedService.instance = undefined; - const serviceInstance = PriceFeedService.getInstance(); // Get new instance + const serviceInstance = PriceFeedService.getInstance(); + Object.assign(serviceInstance, { coingeckoApiKey: undefined }); await serviceInstance.getCryptoPrice("bitcoin", "usd"); @@ -415,7 +412,7 @@ describe("PriceFeedService", () => { expect.any(String), expect.objectContaining({ headers: expect.not.objectContaining({ - "x-cg-demo-api-key": expect.any(String) // Verify header is NOT present + "x-cg-pro-api-key": expect.any(String) // The header production actually sets }) }) ); @@ -593,29 +590,6 @@ describe("PriceFeedService", () => { }); describe("Configuration", () => { - it("should use default values when environment variables are not set", () => { - // Remove environment variables that have defaults - delete process.env.COINGECKO_API_URL; - delete process.env.CRYPTO_CACHE_TTL_MS; - delete process.env.FIAT_CACHE_TTL_MS; - // API key might be undefined, which is handled - - // Reset singleton to apply changes - // @ts-expect-error - accessing private property for testing - PriceFeedService.instance = undefined; - - // Create new instance - const instance = PriceFeedService.getInstance(); - - // Access private properties for testing (consider adding public getters if preferred) - // @ts-expect-error - accessing private properties for testing - expect(instance.coingeckoApiBaseUrl).toBe("https://pro-api.coingecko.com/api/v3"); - // @ts-expect-error - accessing private properties for testing - expect(instance.cryptoCacheTtlMs).toBe(300000); - // @ts-expect-error - accessing private properties for testing - expect(instance.fiatCacheTtlMs).toBe(300000); - }); - it("should keep loaded configuration values when environment variables change after import", () => { // Set specific values after the config module has already been imported process.env.COINGECKO_API_URL = "https://custom-api.example.com"; diff --git a/apps/api/src/api/services/quote/alfredpay-customer.ts b/apps/api/src/api/services/quote/alfredpay-customer.ts new file mode 100644 index 000000000..9767d3b3d --- /dev/null +++ b/apps/api/src/api/services/quote/alfredpay-customer.ts @@ -0,0 +1,93 @@ +import { AlfredPayCountry, AlfredPayStatus, FiatToken, isAlfredpayToken } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import AlfredPayCustomer from "../../../models/alfredPayCustomer.model"; +import { APIError } from "../../errors/api-error"; + +const fiatToCountry: Partial> = { + [FiatToken.USD]: AlfredPayCountry.US, + [FiatToken.MXN]: AlfredPayCountry.MX, + [FiatToken.COP]: AlfredPayCountry.CO, + [FiatToken.ARS]: AlfredPayCountry.AR +}; + +export const ALFREDPAY_EFFECTIVE_USER_REQUIRED_MESSAGE = + "This endpoint requires an API key linked to a user or Supabase user authentication."; + +/** + * Sentinel customer id used in the tracking-only `metadata.customerId` field of Alfredpay + * *quote* requests when no KYC-completed customer can be resolved (anonymous rate discovery). + * Alfredpay only validates the top-level `customerId` on order creation, which always goes + * through the strict `resolveAlfredpayCustomerId`. + */ +export const ALFREDPAY_ANONYMOUS_CUSTOMER_ID = "anonymous"; + +/** + * Best-effort customer id for Alfredpay *quote* metadata. Returns the user's KYC-completed + * customer id when resolvable, otherwise the anonymous sentinel. Never throws for a missing + * user or missing/incomplete KYC — quotes stay anonymous-eligible; registration enforces KYC. + */ +export async function resolveAlfredpayQuoteCustomerId(fiatCurrency: string, userId: string | undefined): Promise { + if (!userId) { + return ALFREDPAY_ANONYMOUS_CUSTOMER_ID; + } + + const country = fiatToCountry[fiatCurrency as FiatToken]; + if (!country) { + return ALFREDPAY_ANONYMOUS_CUSTOMER_ID; + } + + const customer = await AlfredPayCustomer.findOne({ + where: { country, userId } + }); + + if (!customer || customer.status !== AlfredPayStatus.Success) { + return ALFREDPAY_ANONYMOUS_CUSTOMER_ID; + } + + return customer.alfredPayId; +} + +/** + * Resolve the AlfredPay customer id for a given fiat currency + user. The + * fiat side of the request determines the country; the user must own a + * KYC-completed (`Success`) customer row. + * + * Used on the register/start paths, which enforce a non-empty `userId` for + * every corridor. Quote creation uses `resolveAlfredpayQuoteCustomerId` instead. + */ +export async function resolveAlfredpayCustomerId(fiatCurrency: string, userId: string): Promise { + if (!isAlfredpayToken(fiatCurrency as FiatToken)) { + throw new APIError({ + message: `Unsupported Alfredpay currency: ${fiatCurrency}`, + status: httpStatus.BAD_REQUEST + }); + } + + const country = fiatToCountry[fiatCurrency as FiatToken]; + if (!country) { + throw new APIError({ + message: `Unsupported Alfredpay currency: ${fiatCurrency}`, + status: httpStatus.BAD_REQUEST + }); + } + + const customer = await AlfredPayCustomer.findOne({ + where: { country, userId } + }); + + if (!customer) { + throw new APIError({ + message: `No completed Alfredpay KYC profile found for ${fiatCurrency}. Complete Alfredpay KYC before registering a ramp.`, + status: httpStatus.BAD_REQUEST + }); + } + + if (customer.status !== AlfredPayStatus.Success) { + throw new APIError({ + message: `Alfredpay KYC status is ${customer.status}. Complete Alfredpay KYC before registering a ramp.`, + status: httpStatus.BAD_REQUEST + }); + } + + return customer.alfredPayId; +} diff --git a/apps/api/src/api/services/quote/engines/alfredpay-auth.test.ts b/apps/api/src/api/services/quote/engines/alfredpay-auth.test.ts new file mode 100644 index 000000000..df309dfdd --- /dev/null +++ b/apps/api/src/api/services/quote/engines/alfredpay-auth.test.ts @@ -0,0 +1,122 @@ +import { + AlfredpayApiService, + CreateAlfredpayOfframpQuoteRequest, + CreateAlfredpayOnrampQuoteRequest, + EPaymentMethod, + EvmToken, + FiatToken, + Networks, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { ALFREDPAY_ANONYMOUS_CUSTOMER_ID } from "../alfredpay-customer"; +import { priceFeedService } from "../../priceFeed.service"; +import { createQuoteContext } from "../core/quote-context"; +import { OnRampInitializeAlfredpayEngine } from "./initialize/onramp-alfredpay"; +import { OfframpTransactionAlfredpayEngine } from "./partners/offramp-alfredpay"; + +function stubAlfredpayQuote() { + return { + expiration: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + fees: [], + fromAmount: "100", + quoteId: "alfredpay-quote-1", + toAmount: "99" + }; +} + +describe("Alfredpay quote auth", () => { + const originalGetInstance = AlfredpayApiService.getInstance; + const originalConvertCurrency = priceFeedService.convertCurrency; + + afterEach(() => { + AlfredpayApiService.getInstance = originalGetInstance; + priceFeedService.convertCurrency = originalConvertCurrency; + }); + + it("serves anonymous Alfredpay onramp quotes with the sentinel customer id in metadata", async () => { + let capturedRequest: CreateAlfredpayOnrampQuoteRequest | undefined; + AlfredpayApiService.getInstance = mock(() => ({ + createOnrampQuote: async (request: CreateAlfredpayOnrampQuoteRequest) => { + capturedRequest = request; + return stubAlfredpayQuote(); + } + })) as unknown as typeof AlfredpayApiService.getInstance; + + const ctx = createQuoteContext({ + partner: null, + request: { + from: EPaymentMethod.ACH, + inputAmount: "100", + inputCurrency: FiatToken.USD, + network: Networks.Polygon, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Polygon + }, + targetFeeFiatCurrency: FiatToken.USD + }); + + await new OnRampInitializeAlfredpayEngine().execute(ctx); + + expect(capturedRequest?.metadata.customerId).toBe(ALFREDPAY_ANONYMOUS_CUSTOMER_ID); + expect(ctx.alfredpayMint?.quoteId).toBe("alfredpay-quote-1"); + }); + + it("serves anonymous Alfredpay off-ramp quotes with the sentinel customer id in metadata", async () => { + priceFeedService.convertCurrency = mock(async () => "20") as typeof priceFeedService.convertCurrency; + let capturedRequest: CreateAlfredpayOfframpQuoteRequest | undefined; + AlfredpayApiService.getInstance = mock(() => ({ + createOfframpQuote: async (request: CreateAlfredpayOfframpQuoteRequest) => { + capturedRequest = request; + return stubAlfredpayQuote(); + } + })) as unknown as typeof AlfredpayApiService.getInstance; + + const ctx = createQuoteContext({ + partner: null, + request: { + from: Networks.Polygon, + inputAmount: "10", + inputCurrency: EvmToken.USDC, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: EPaymentMethod.SPEI + }, + targetFeeFiatCurrency: FiatToken.MXN + }); + ctx.evmToEvm = { + fromNetwork: Networks.Polygon, + fromToken: "0x0000000000000000000000000000000000000001", + inputAmountDecimal: new Big("10"), + inputAmountRaw: "10000000", + networkFeeUSD: "0", + outputAmountDecimal: new Big("10"), + outputAmountRaw: "10000000", + toNetwork: Networks.Polygon, + toToken: "0x0000000000000000000000000000000000000002" + }; + ctx.subsidy = { + actualOutputAmountDecimal: new Big("10"), + actualOutputAmountRaw: "10000000", + applied: false, + expectedOutputAmountDecimal: new Big("10"), + expectedOutputAmountRaw: "10000000", + idealSubsidyAmountInOutputTokenDecimal: new Big("0"), + idealSubsidyAmountInOutputTokenRaw: "0", + partnerId: null, + subsidyAmountInOutputTokenDecimal: new Big("0"), + subsidyAmountInOutputTokenRaw: "0", + subsidyRate: new Big("0"), + targetOutputAmountDecimal: new Big("10"), + targetOutputAmountRaw: "10000000" + }; + + await new OfframpTransactionAlfredpayEngine().execute(ctx); + + expect(capturedRequest?.metadata.customerId).toBe(ALFREDPAY_ANONYMOUS_CUSTOMER_ID); + expect(ctx.alfredpayOfframp?.quoteId).toBe("alfredpay-quote-1"); + }); +}); diff --git a/apps/api/src/api/services/quote/engines/discount/helpers.test.ts b/apps/api/src/api/services/quote/engines/discount/helpers.test.ts index 549ac5b9d..4ed4c4397 100644 --- a/apps/api/src/api/services/quote/engines/discount/helpers.test.ts +++ b/apps/api/src/api/services/quote/engines/discount/helpers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import Big from "big.js"; -import { calculateSubsidyAmount } from "./helpers"; +import { calculateExpectedOutput, calculateSubsidyAmount } from "./helpers"; describe("calculateSubsidyAmount", () => { it("returns 0 when actual output meets expected output", () => { @@ -30,26 +30,26 @@ describe("calculateSubsidyAmount", () => { expect(result.toString()).toBe("5"); }); - describe("negative targetDiscount scenarios (rate floor)", () => { - it("subsidizes when actual is below negative-target expected output", () => { - const result = calculateSubsidyAmount(new Big(99.9), new Big(99.5), 0); - expect(result.toString()).toBe("0.4"); - }); +}); - it("returns 0 when actual already meets negative-target expected output", () => { - const result = calculateSubsidyAmount(new Big(99.9), new Big(99.9), 0); - expect(result.toString()).toBe("0"); - }); +// The negative-discount / rate-floor logic lives in calculateExpectedOutput, not +// calculateSubsidyAmount (which only sees the resulting expectedOutput). +describe("calculateExpectedOutput with negative targetDiscount (rate floor)", () => { + it("lowers the expected output below the oracle rate for an onramp", () => { + const { expectedOutput, adjustedTargetDiscount } = calculateExpectedOutput("100", new Big(1), -0.001, false, null); + // rate = 1 * (1 - 0.001) = 0.999 + expect(expectedOutput.toString()).toBe("99.9"); + expect(adjustedTargetDiscount.toString()).toBe("-0.001"); + }); - it("returns 0 when actual exceeds negative-target expected output", () => { - const result = calculateSubsidyAmount(new Big(99.9), new Big(100.5), 0); - expect(result.toString()).toBe("0"); - }); + it("inverts the oracle price for offramps before applying the discount", () => { + const { expectedOutput } = calculateExpectedOutput("100", new Big(5), -0.01, true, null); + // USD-FIAT rate = 1/5 = 0.2; discounted = 0.2 * 0.99 = 0.198 + expect(expectedOutput.toString()).toBe("19.8"); + }); - it("caps subsidy at maxSubsidy for negative target", () => { - const result = calculateSubsidyAmount(new Big(99.9), new Big(98.0), 0.01); - // shortfall=1.9, maxAllowed=99.9*0.01=0.999 - expect(result.toString()).toBe("0.999"); - }); + it("applies a positive targetDiscount as a rate premium", () => { + const { expectedOutput } = calculateExpectedOutput("100", new Big(1), 0.02, false, null); + expect(expectedOutput.toString()).toBe("102"); }); }); diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts index 5f69075c4..3d643370b 100644 --- a/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts +++ b/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts @@ -12,6 +12,7 @@ import { } from "@vortexfi/shared"; import { calculateEvmBridgeAndNetworkFee, getTokenDetailsForEvmDestination } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; +import { isFiatToOwnStablecoinBaseDirect } from "../../utils"; import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; export class OnRampAveniaToEvmFeeEngine extends BaseFeeEngine { @@ -47,6 +48,17 @@ export class OnRampAveniaToEvmFeeEngine extends BaseFeeEngine { // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` const anchorFeeCurrency = ctx.aveniaMint!.currency as RampCurrency; + // Direct fiat -> own-stablecoin corridors (BRL→BRLA, EUR→EURC on Base): the anchor mints the + // requested token and it transfers straight to the destination — no swap or bridge leg exists, + // so pricing one here would quote a network fee that is never charged nor distributed. Mirrors + // the isFiatToOwnStablecoinBaseDirect passthrough in the squidrouter engines. + if (isFiatToOwnStablecoinBaseDirect(request.inputCurrency, request.outputCurrency, request.to)) { + return { + anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, + network: { amount: "0", currency: "USD" as RampCurrency } + }; + } + const toNetwork = getNetworkFromDestination(request.to); if (!toNetwork) { throw new Error(`OnRampAveniaToEvmFeeEngine: invalid network for destination: ${request.to}`); diff --git a/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts index d1b20d5be..3481c3348 100644 --- a/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts @@ -10,6 +10,7 @@ import { RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import { resolveAlfredpayQuoteCustomerId } from "../../alfredpay-customer"; import { QuoteContext } from "../../core/types"; import { BaseInitializeEngine } from "./index"; @@ -24,16 +25,19 @@ export class OnRampInitializeAlfredpayEngine extends BaseInitializeEngine { const usdTokenDecimals = ALFREDPAY_ERC20_DECIMALS; const inputAmountDecimal = new Big(req.inputAmount); - const alfredpayService = AlfredpayApiService.getInstance(); + // Quotes stay anonymous-eligible: metadata.customerId is tracking-only on Alfredpay quote + // requests. KYC is enforced at ramp registration via resolveAlfredpayCustomerId. + const customerId = await resolveAlfredpayQuoteCustomerId(req.inputCurrency, req.userId); + const quoteRequest: CreateAlfredpayOnrampQuoteRequest = { chain: AlfredpayChain.MATIC, fromAmount: inputAmountDecimal.toString(), fromCurrency: req.inputCurrency as unknown as AlfredpayFiatCurrency, metadata: { businessId: "vortex", - customerId: req.userId || "unknown" + customerId }, // Mints hardcoded to Polygon. paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency: ALFREDPAY_ONCHAIN_CURRENCY diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts index 58f816d48..b4ddea348 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts @@ -1,7 +1,27 @@ -import {describe, expect, it, mock} from "bun:test"; +import {afterAll, describe, expect, it, mock} from "bun:test"; import Big from "big.js"; +// Captured before mock.module so afterAll can restore the real modules — +// bun module mocks are process-wide and would poison later test files. +import * as sharedNamespace from "@vortexfi/shared"; +import * as nablaNamespace from "../../core/nabla"; +import * as priceFeedNamespace from "../../../priceFeed.service"; +import * as loggerNamespace from "../../../../../config/logger"; import type {QuoteContext} from "../../core/types"; +// Value copies taken before mock.module runs — the namespaces themselves are +// live bindings that would reflect the mocks once installed. +const sharedReal = { ...sharedNamespace }; +const nablaReal = { ...nablaNamespace }; +const priceFeedReal = { ...priceFeedNamespace }; +const loggerReal = { ...loggerNamespace }; + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../core/nabla", () => ({ ...nablaReal })); + mock.module("../../../priceFeed.service", () => ({ ...priceFeedReal })); + mock.module("../../../../../config/logger", () => ({ ...loggerReal })); +}); + const mockedEvmToken = { BRLA: "BRLA", USDC: "USDC" @@ -17,6 +37,7 @@ const mockedRampDirection = { } as const; mock.module("@vortexfi/shared", () => ({ + ...sharedReal, EvmToken: mockedEvmToken, getOnChainTokenDetails: (_network: string, token: string) => ({ assetSymbol: token, diff --git a/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts index c28de518d..07fa95034 100644 --- a/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts @@ -12,6 +12,7 @@ import { } from "@vortexfi/shared"; import Big from "big.js"; import { priceFeedService } from "../../../priceFeed.service"; +import { resolveAlfredpayQuoteCustomerId } from "../../alfredpay-customer"; import { QuoteContext } from "../../core/types"; import { BaseInitializeEngine } from "./../initialize/index"; @@ -45,6 +46,10 @@ export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { ? ctx.subsidy.targetOutputAmountDecimal.div(effectiveRate).round(ALFREDPAY_ERC20_DECIMALS, Big.roundDown) : ctx.evmToEvm.outputAmountDecimal.minus(deductibleFee).round(ALFREDPAY_ERC20_DECIMALS, Big.roundDown); + // Quotes stay anonymous-eligible: metadata.customerId is tracking-only on Alfredpay quote + // requests. KYC is enforced at ramp registration via resolveAlfredpayCustomerId. + const customerId = await resolveAlfredpayQuoteCustomerId(req.outputCurrency, req.userId); + const alfredpayService = AlfredpayApiService.getInstance(); const quoteRequest: CreateAlfredpayOfframpQuoteRequest = { chain: AlfredpayChain.MATIC, @@ -52,7 +57,7 @@ export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, metadata: { businessId: "vortex", - customerId: req.userId || "unknown" + customerId }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency: req.outputCurrency as unknown as AlfredpayFiatCurrency diff --git a/apps/api/src/api/services/quote/engines/squidrouter/index.test.ts b/apps/api/src/api/services/quote/engines/squidrouter/index.test.ts index 659fbf89d..11f658837 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/index.test.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/index.test.ts @@ -1,11 +1,22 @@ -import {describe, expect, it, mock} from "bun:test"; +import {afterAll, describe, expect, it, mock} from "bun:test"; import {EvmToken, Networks, RampDirection} from "@vortexfi/shared"; import Big from "big.js"; import {QuoteContext} from "../../core/types"; const BSC_USDT_OUTPUT_RAW = "4817805726163073314321"; +import * as coreSquidrouterNamespace from "../../core/squidrouter"; + +// Value copy taken before mock.module runs; restored in afterAll because bun +// module mocks are process-wide. +const coreSquidrouterReal = { ...coreSquidrouterNamespace }; + +afterAll(() => { + mock.module("../../core/squidrouter", () => ({ ...coreSquidrouterReal })); +}); + mock.module("../../core/squidrouter", () => ({ + ...coreSquidrouterReal, calculateEvmBridgeAndNetworkFee: mock(async () => ({ finalEffectiveExchangeRate: "1", finalGrossOutputAmountDecimal: new Big("4817.805726163073314321"), diff --git a/apps/api/src/api/services/ramp/base.service.test.ts b/apps/api/src/api/services/ramp/base.service.test.ts index 384024c87..2def3db66 100644 --- a/apps/api/src/api/services/ramp/base.service.test.ts +++ b/apps/api/src/api/services/ramp/base.service.test.ts @@ -1,4 +1,4 @@ -import {beforeEach, describe, expect, it, mock} from "bun:test"; +import {afterAll, beforeEach, describe, expect, it, mock} from "bun:test"; import {Op} from "sequelize"; import sequelize from "../../../config/database"; import QuoteTicket from "../../../models/quoteTicket.model"; @@ -31,12 +31,28 @@ const updateMock = mock(async (_values: unknown, _options: QuoteCleanupOptions) const findAllMock = mock(async (_options: QuoteCleanupOptions) => [{ id: "expired-quote-1" }, { id: "expired-quote-2" }]); const destroyMock = mock(async (_options: QuoteCleanupOptions) => 2); +// bun runs all test files in one process — restore the patched singletons in +// afterAll so later files don't run against these fakes. +const originalTransaction = sequelize.transaction; +const originalQuery = sequelize.query; +const originalUpdate = QuoteTicket.update; +const originalFindAll = QuoteTicket.findAll; +const originalDestroy = QuoteTicket.destroy; + sequelize.transaction = transactionMock as unknown as typeof sequelize.transaction; sequelize.query = queryMock as unknown as typeof sequelize.query; QuoteTicket.update = updateMock as unknown as typeof QuoteTicket.update; QuoteTicket.findAll = findAllMock as unknown as typeof QuoteTicket.findAll; QuoteTicket.destroy = destroyMock as unknown as typeof QuoteTicket.destroy; +afterAll(() => { + sequelize.transaction = originalTransaction; + sequelize.query = originalQuery; + QuoteTicket.update = originalUpdate; + QuoteTicket.findAll = originalFindAll; + QuoteTicket.destroy = originalDestroy; +}); + describe("BaseRampService.cleanupExpiredQuotes", () => { let service: BaseRampService; diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts index c630b3937..2c17c0f46 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts @@ -1,7 +1,16 @@ -import {beforeEach, describe, expect, it, mock} from "bun:test"; +import {afterAll, beforeEach, describe, expect, it, mock} from "bun:test"; import {EphemeralAccountType} from "@vortexfi/shared"; +import * as sharedNamespace from "@vortexfi/shared"; import {APIError} from "../../errors/api-error"; +// Value copy taken before mock.module runs; restored in afterAll because bun +// module mocks are process-wide and would poison later test files. +const sharedReal = { ...sharedNamespace }; + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); +}); + const SUBSTRATE_ADDR = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; const EVM_ADDR = "0x1111111111111111111111111111111111111111"; diff --git a/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts b/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts index 1d142a102..87d266bbd 100644 --- a/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts +++ b/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, mock } from "bun:test"; +import { afterAll, describe, expect, it, mock } from "bun:test"; import { EPaymentMethod, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState from "../../../models/rampState.model"; import { StateMetadata } from "../phases/meta-state-types"; @@ -8,6 +9,11 @@ import { RampService } from "./ramp.service"; const createdAt = new Date("2026-06-10T12:31:56.420Z"); const updatedAt = new Date("2026-06-10T12:32:25.548Z"); +const originalFindByPk = QuoteTicket.findByPk; +afterAll(() => { + QuoteTicket.findByPk = originalFindByPk; +}); + QuoteTicket.findByPk = mock(async () => ({ countryCode: "BR", inputAmount: "25003", @@ -51,7 +57,7 @@ function makeRampState(onHold: boolean, currentPhase: RampPhase = "brlaOnrampMin createdAt, currentPhase, errorLogs: [], - flowVariant: "monerium", + flowVariant: config.flowVariant, from: EPaymentMethod.PIX, id: "ramp-1", paymentMethod: EPaymentMethod.PIX, diff --git a/apps/api/src/api/services/ramp/ramp.service.register-auth.test.ts b/apps/api/src/api/services/ramp/ramp.service.register-auth.test.ts new file mode 100644 index 000000000..e19cb9a41 --- /dev/null +++ b/apps/api/src/api/services/ramp/ramp.service.register-auth.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import httpStatus from "http-status"; +import type { Transaction } from "sequelize"; +import { config } from "../../../config/vars"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import { APIError } from "../../errors/api-error"; +import { RampService } from "./ramp.service"; + +// Locks in the user-gating guards at the top of RampService.registerRamp. See +// docs/architecture/user-gated-ramp-registration.md. The guards run before any DB write or +// signing-account validation, so overriding withTransaction (to skip the real DB) and mocking +// QuoteTicket.findByPk is enough to drive them. +class TestRampService extends RampService { + protected async withTransaction(callback: (transaction: Transaction) => Promise): Promise { + return callback({} as Transaction); + } +} + +function stubQuote(overrides: { userId: string | null }): void { + QuoteTicket.findByPk = mock(async () => ({ + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + flowVariant: config.flowVariant, + id: "quote-1", + status: "pending", + userId: overrides.userId + })) as unknown as typeof QuoteTicket.findByPk; +} + +async function expectRegisterError(userId: string | undefined, expectedStatus: number): Promise { + const service = new TestRampService(); + try { + await service.registerRamp({ additionalData: {}, quoteId: "quote-1", signingAccounts: [], userId } as never); + throw new Error("registerRamp did not reject"); + } catch (error) { + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(expectedStatus); + return error as APIError; + } +} + +describe("RampService.registerRamp user gating", () => { + const originalFindByPk = QuoteTicket.findByPk; + + afterEach(() => { + QuoteTicket.findByPk = originalFindByPk; + }); + + it("lets an authenticated caller claim an anonymous quote (passes the user-gating guards)", async () => { + stubQuote({ userId: null }); + const service = new TestRampService(); + try { + await service.registerRamp({ additionalData: {}, quoteId: "quote-1", signingAccounts: [], userId: "user-a" } as never); + throw new Error("registerRamp did not reject"); + } catch (error) { + // The stubbed quote has no currencies/signing accounts, so registration fails later + // (missing destinationAddress) — but it must get past the gating guards. + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).not.toBe(httpStatus.FORBIDDEN); + expect((error as APIError).message).not.toContain("Invalid quote"); + } + }); + + it("rejects a user registering a quote owned by a different user with 403", async () => { + stubQuote({ userId: "user-b" }); + await expectRegisterError("user-a", httpStatus.FORBIDDEN); + }); + + it("rejects registration with no effective user (e.g. unlinked partner key) with 400", async () => { + stubQuote({ userId: null }); + const error = await expectRegisterError(undefined, httpStatus.BAD_REQUEST); + // Pin the guard's own message: without it, registration still fails later with a + // different 400 (missing destinationAddress), which must not satisfy this test. + expect(error.message).toContain("requires an API key linked to a user"); + }); +}); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index f87e61edd..1401323a9 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -52,10 +52,12 @@ import RampState, { RampStateAttributes } from "../../../models/rampState.model" import TaxId from "../../../models/taxId.model"; import { APIError } from "../../errors/api-error"; import { ActivePartner, handleQuoteConsumptionForDiscountState } from "../../services/quote/engines/discount/helpers"; -import { syncMykoboCustomerKyc } from "../mykobo/mykobo-customer.service"; +import { resolveAveniaAccountForRamp } from "../avenia-account"; +import { resolveMykoboCustomerForUser } from "../mykobo/mykobo-customer.service"; import { StateMetadata } from "../phases/meta-state-types"; import phaseProcessor from "../phases/phase-processor"; import { PriceFeedService } from "../priceFeed.service"; +import { resolveAlfredpayCustomerId } from "../quote/alfredpay-customer"; import { prepareOfframpTransactions } from "../transactions/offramp"; import { prepareOnrampTransactions } from "../transactions/onramp"; import { AveniaOnrampTransactionParams } from "../transactions/onramp/common/types"; @@ -199,6 +201,28 @@ export class RampService extends BaseRampService { }); } + if (request.userId && quote.userId && request.userId !== quote.userId) { + throw new APIError({ + message: "Authenticated user does not own this provider-bound quote.", + status: httpStatus.FORBIDDEN + }); + } + + // An anonymous quote (userId == null) carries no owner, so an authenticated caller + // claiming it is not an escalation — this is the normal web-app funnel (quote before + // login, register after). Provider identity is still derived from the effective user. + const effectiveUserId = request.userId || quote.userId || undefined; + + if (!effectiveUserId) { + throw new APIError({ + message: "Invalid quote: this route requires an API key linked to a user or Supabase user authentication.", + status: httpStatus.BAD_REQUEST + }); + } + + // Before removing this kill-switch, add a hermetic EUR corridor scenario in + // apps/api/src/tests/corridors/ (the Mykobo corridors are currently covered by + // RUN_LIVE_TESTS-gated tests only — see docs/testing-strategy.md). if (quote.inputCurrency === FiatToken.EURC || quote.outputCurrency === FiatToken.EURC) { throw new APIError({ message: "EUR ramps are currently disabled", @@ -216,7 +240,7 @@ export class RampService extends BaseRampService { additionalData, signingAccounts, transaction, - request.userId // will be undefined if not logged in. registerRamp is optional. + effectiveUserId ); const [affectedRows] = await this.consumeQuote(quote.id, transaction); @@ -260,7 +284,7 @@ export class RampService extends BaseRampService { to: quote.to, type: quote.rampType, unsignedTxs, - userId: request.userId || quote.userId + userId: effectiveUserId }, transaction ); @@ -1011,16 +1035,24 @@ export class RampService extends BaseRampService { private async prepareOfframpBrlTransactions( quote: QuoteTicket, normalizedSigningAccounts: AccountMeta[], - additionalData: RegisterRampRequest["additionalData"] + additionalData: RegisterRampRequest["additionalData"], + userId: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; depositQrCode?: string }> { - if (!additionalData || !additionalData.pixDestination || !additionalData.taxId || !additionalData.receiverTaxId) { - throw new Error("receiverTaxId, pixDestination and taxId parameters must be provided for offramp to BRL"); + if (!additionalData || !additionalData.pixDestination) { + throw new APIError({ + message: "pixDestination is required for offramp to BRL", + status: httpStatus.BAD_REQUEST + }); } + const aveniaAccount = await resolveAveniaAccountForRamp(userId, additionalData.taxId); + const derivedTaxId = aveniaAccount.taxId; + const derivedReceiverTaxId = normalizeTaxId(additionalData.receiverTaxId || derivedTaxId); + const subaccount = await this.validateBrlaOfframpRequest( - additionalData.taxId, + derivedTaxId, additionalData.pixDestination, - additionalData.receiverTaxId, + derivedReceiverTaxId, quote.outputAmount ); @@ -1028,10 +1060,11 @@ export class RampService extends BaseRampService { brlaEvmAddress: subaccount.wallets.evm, pixDestination: additionalData.pixDestination, quote, - receiverTaxId: additionalData.receiverTaxId, + receiverTaxId: derivedReceiverTaxId, signingAccounts: normalizedSigningAccounts, - taxId: additionalData.taxId, - userAddress: additionalData.walletAddress + taxId: derivedTaxId, + userAddress: additionalData.walletAddress, + userId }); return { depositQrCode: subaccount.brCode, stateMeta, unsignedTxs }; @@ -1042,12 +1075,18 @@ export class RampService extends BaseRampService { normalizedSigningAccounts: AccountMeta[], additionalData: RegisterRampRequest["additionalData"], transaction: Transaction, - userId?: string + userId: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial }> { // We refresh the quote. It will be used in the transaction creation process, right after this. if (isAlfredpayToken(quote.outputCurrency as FiatToken) && quote.metadata.alfredpayOfframp) { const toCurrency = quote.outputCurrency as unknown as AlfredpayFiatCurrency; - await this.refreshAlfredpayOfframpQuoteIfMatching(quote, quote.metadata.alfredpayOfframp, toCurrency, transaction); + await this.refreshAlfredpayOfframpQuoteIfMatching( + quote, + quote.metadata.alfredpayOfframp, + toCurrency, + userId, + transaction + ); } const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({ @@ -1068,11 +1107,12 @@ export class RampService extends BaseRampService { quote: QuoteTicket, normalizedSigningAccounts: AccountMeta[], additionalData: RegisterRampRequest["additionalData"], - signingAccounts: AccountMeta[] + signingAccounts: AccountMeta[], + userId: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; depositQrCode: string; aveniaTicketId: string }> { - if (!additionalData || additionalData.destinationAddress === undefined || additionalData.taxId === undefined) { + if (!additionalData || !additionalData.destinationAddress) { throw new APIError({ - message: "Parameters destinationAddress and taxId are required for onramp", + message: "Parameter destinationAddress is required for onramp", status: httpStatus.BAD_REQUEST }); } @@ -1085,13 +1125,16 @@ export class RampService extends BaseRampService { }); } - const { brCode, aveniaTicketId } = await this.validateBrlaOnrampRequest(additionalData.taxId, quote, quote.inputAmount); + const aveniaAccount = await resolveAveniaAccountForRamp(userId, additionalData.taxId); + const derivedTaxId = aveniaAccount.taxId; + + const { brCode, aveniaTicketId } = await this.validateBrlaOnrampRequest(derivedTaxId, quote, quote.inputAmount); const params: AveniaOnrampTransactionParams = { destinationAddress: additionalData.destinationAddress, quote, signingAccounts: normalizedSigningAccounts, - taxId: additionalData.taxId + taxId: derivedTaxId }; const { unsignedTxs, stateMeta } = await prepareOnrampTransactions(params); @@ -1103,7 +1146,7 @@ export class RampService extends BaseRampService { quote: QuoteTicket, normalizedSigningAccounts: AccountMeta[], additionalData: RegisterRampRequest["additionalData"], - userId?: string + userId: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; @@ -1115,11 +1158,13 @@ export class RampService extends BaseRampService { }); } + await resolveAlfredpayCustomerId(quote.inputCurrency, userId); + const { unsignedTxs, stateMeta } = await prepareOnrampTransactions({ destinationAddress: additionalData.destinationAddress, quote, signingAccounts: normalizedSigningAccounts, - userId: userId as string + userId }); return { stateMeta: stateMeta as Partial, unsignedTxs }; @@ -1129,19 +1174,23 @@ export class RampService extends BaseRampService { quote: QuoteTicket, normalizedSigningAccounts: AccountMeta[], additionalData: RegisterRampRequest["additionalData"], - userId?: string + userId: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; ibanPaymentData?: IbanPaymentData; }> { - if (!additionalData?.destinationAddress || !additionalData?.email || !additionalData?.ipAddress) { + if (!additionalData?.destinationAddress || !additionalData?.ipAddress) { throw new APIError({ - message: "Parameters destinationAddress, email and ipAddress are required for Mykobo EUR onramp", + message: "Parameters destinationAddress and ipAddress are required for Mykobo EUR onramp", status: httpStatus.BAD_REQUEST }); } + // The Mykobo email is derived from the effective user's profile (and KYC must be approved); + // a client-supplied email is accepted only if it matches. See resolveMykoboCustomerForUser. + const { email } = await resolveMykoboCustomerForUser(userId, additionalData.email); + const evmEphemeralEntry = normalizedSigningAccounts.find(account => account.type === "EVM"); if (!evmEphemeralEntry) { throw new APIError({ @@ -1153,7 +1202,7 @@ export class RampService extends BaseRampService { const mykobo = MykoboApiService.getInstance(); const intent = await mykobo.createTransactionIntent({ currency: MykoboCurrency.EURC, - email_address: additionalData.email, + email_address: email, ip_address: additionalData.ipAddress, transaction_type: MykoboTransactionType.DEPOSIT, value: new Big(quote.inputAmount).toFixed(2, 0), @@ -1171,7 +1220,7 @@ export class RampService extends BaseRampService { const { unsignedTxs, stateMeta } = await prepareMykoboToEvmOnrampTransactions({ destinationAddress: additionalData.destinationAddress, ipAddress: additionalData.ipAddress, - mykoboEmail: additionalData.email, + mykoboEmail: email, mykoboTransactionId: intent.transaction.id, mykoboTransactionReference: intent.transaction.reference, quote, @@ -1185,10 +1234,6 @@ export class RampService extends BaseRampService { reference: intent.transaction.reference }; - if (userId) { - await syncMykoboCustomerKyc(userId, additionalData.email); - } - return { ibanPaymentData, stateMeta: stateMeta as Partial, unsignedTxs }; } @@ -1198,7 +1243,7 @@ export class RampService extends BaseRampService { additionalData: RegisterRampRequest["additionalData"], signingAccounts: AccountMeta[], transaction: Transaction, - userId?: string + userId: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; @@ -1208,7 +1253,7 @@ export class RampService extends BaseRampService { }> { switch (selectRampTransactionPreparationKind(quote, additionalData)) { case RampTransactionPreparationKind.OfframpBrl: - return this.prepareOfframpBrlTransactions(quote, normalizedSigningAccounts, additionalData); + return this.prepareOfframpBrlTransactions(quote, normalizedSigningAccounts, additionalData, userId); case RampTransactionPreparationKind.OfframpNonBrl: return this.prepareOfframpNonBrlTransactions(quote, normalizedSigningAccounts, additionalData, transaction, userId); @@ -1220,7 +1265,7 @@ export class RampService extends BaseRampService { return this.prepareAlfredpayOnrampTransactions(quote, normalizedSigningAccounts, additionalData, userId); case RampTransactionPreparationKind.OnrampAvenia: - return this.prepareAveniaOnrampTransactions(quote, normalizedSigningAccounts, additionalData, signingAccounts); + return this.prepareAveniaOnrampTransactions(quote, normalizedSigningAccounts, additionalData, signingAccounts, userId); } } @@ -1384,6 +1429,7 @@ export class RampService extends BaseRampService { quote, originalAlfredpayMint, fromCurrency, + rampState.userId, transaction ); @@ -1418,11 +1464,14 @@ export class RampService extends BaseRampService { quote: QuoteTicket, originalAlfredpayMint: NonNullable, fromCurrency: AlfredpayFiatCurrency, + userId: string, transaction: Transaction ): Promise { const alfredpayService = AlfredpayApiService.getInstance(); const originalQuoteId = originalAlfredpayMint.quoteId; + const customerId = await resolveAlfredpayCustomerId(fromCurrency, userId); + try { const freshQuote = await alfredpayService.createOnrampQuote({ chain: AlfredpayChain.MATIC, @@ -1430,7 +1479,7 @@ export class RampService extends BaseRampService { fromCurrency, metadata: { businessId: "vortex", - customerId: quote.userId || "unknown" + customerId }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency: ALFREDPAY_ONCHAIN_CURRENCY @@ -1485,16 +1534,19 @@ export class RampService extends BaseRampService { quote: QuoteTicket, originalAlfredpayOfframp: NonNullable, toCurrency: AlfredpayFiatCurrency, + userId: string, transaction: Transaction ): Promise { const alfredpayService = AlfredpayApiService.getInstance(); const originalQuoteId = originalAlfredpayOfframp.quoteId; + const customerId = await resolveAlfredpayCustomerId(toCurrency, userId); + const freshQuote = await alfredpayService.createOfframpQuote({ chain: AlfredpayChain.MATIC, fromAmount: originalAlfredpayOfframp.inputAmountDecimal.toString(), fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - metadata: { businessId: "vortex", customerId: quote.userId || "unknown" }, + metadata: { businessId: "vortex", customerId }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency } satisfies CreateAlfredpayOfframpQuoteRequest); diff --git a/apps/api/src/api/services/transactions/offramp/common/types.ts b/apps/api/src/api/services/transactions/offramp/common/types.ts index 6b51bfdbe..03405fb8c 100644 --- a/apps/api/src/api/services/transactions/offramp/common/types.ts +++ b/apps/api/src/api/services/transactions/offramp/common/types.ts @@ -9,7 +9,7 @@ export interface OfframpTransactionParams { taxId?: string; receiverTaxId?: string; brlaEvmAddress?: string; - userId?: string; + userId: string; fiatAccountId?: string; email?: string; destinationAddress?: string; diff --git a/apps/api/src/api/services/transactions/offramp/common/validation.ts b/apps/api/src/api/services/transactions/offramp/common/validation.ts index ebc9aeb8d..c6a11b7dd 100644 --- a/apps/api/src/api/services/transactions/offramp/common/validation.ts +++ b/apps/api/src/api/services/transactions/offramp/common/validation.ts @@ -57,7 +57,7 @@ export function validateOfframpQuote( } /** - * Validates BRL offramp requirements + * Validates BRL offramp requirements. * @param quote The quote ticket * @param params Offramp parameters * @returns Validated parameters @@ -79,13 +79,13 @@ export function validateBRLOfframp( const { brlaEvmAddress, pixDestination, taxId, receiverTaxId } = params; if (!brlaEvmAddress || !pixDestination || !taxId || !receiverTaxId) { - throw new Error("brlaEvmAddress, pixDestination, receiverTaxId and taxId parameters must be provided for offramp to BRL"); + throw new Error("brlaEvmAddress, pixDestination, receiverTaxId and taxId must be derived for offramp to BRL"); } return { brlaEvmAddress, pixDestination, - receiverTaxId, + receiverTaxId: normalizeTaxId(receiverTaxId), taxId: normalizeTaxId(taxId) }; } diff --git a/apps/api/src/api/services/transactions/offramp/index.ts b/apps/api/src/api/services/transactions/offramp/index.ts index 1e48a6878..1c84dd087 100644 --- a/apps/api/src/api/services/transactions/offramp/index.ts +++ b/apps/api/src/api/services/transactions/offramp/index.ts @@ -36,7 +36,7 @@ export async function prepareOfframpTransactions(params: OfframpTransactionParam } return prepareEvmToMykoboOfframpTransactions(params); } else if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { - // Alfredpay offramp (USD, MXN, COP) + // Alfredpay offramp (USD, MXN, COP, ARS) return prepareEvmToAlfredpayOfframpTransactions(params); } diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts index 42be242b2..1824aadbb 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts @@ -1,8 +1,6 @@ import { ALFREDPAY_ERC20_TOKEN, ALFREDPAY_ONCHAIN_CURRENCY, - AlfredPayCountry, - AlfredPayStatus, AlfredpayApiService, AlfredpayChain, AlfredpayFiatCurrency, @@ -13,7 +11,6 @@ import { EvmTokenDetails, EvmTransactionData, evmTokenConfig, - FiatToken, getNetworkFromDestination, getNetworkId, getOnChainTokenDetails, @@ -25,6 +22,7 @@ import { UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; +import httpStatus from "http-status"; import { ContractFunctionExecutionError, encodeAbiParameters, @@ -38,9 +36,10 @@ import { import { privateKeyToAccount } from "viem/accounts"; import { config } from "../../../../../config/vars"; import erc20ABI from "../../../../../contracts/ERC20"; -import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; +import { APIError } from "../../../../errors/api-error"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { resolveAlfredpayCustomerId } from "../../../quote/alfredpay-customer"; import { encodeEvmTransactionData } from "../../index"; import { addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; @@ -198,43 +197,35 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ throw new Error(`Unsupported source network ${fromNetwork} for EVM to Alfredpay type offramp`); } - const fiatToCountry: Partial> = { - [FiatToken.USD]: AlfredPayCountry.US, - [FiatToken.MXN]: AlfredPayCountry.MX, - [FiatToken.COP]: AlfredPayCountry.CO, - [FiatToken.ARS]: AlfredPayCountry.AR - }; - const customerCountry = fiatToCountry[quote.outputCurrency as FiatToken]; - if (!customerCountry) { - throw new Error(`Unsupported Alfredpay output currency: ${quote.outputCurrency}`); - } - - const customer = await AlfredPayCustomer.findOne({ - where: { country: customerCountry, userId } - }); - - if (!customer) { - throw new Error(`Alfredpay customer not found for userId ${userId}`); - } - - if (customer.status !== AlfredPayStatus.Success) { - throw new Error(`Alfredpay customer status is ${customer.status}, expected Success. Proceed first with KYC.`); + if (!userId) { + throw new APIError({ + message: "Alfredpay offramp requires an API key linked to a user or Supabase user authentication.", + status: httpStatus.BAD_REQUEST + }); } if (!fiatAccountId) { - throw new Error("fiatAccountId is required for Alfredpay offramp"); + throw new APIError({ + message: "fiatAccountId is required for Alfredpay offramp", + status: httpStatus.BAD_REQUEST + }); } + const alfredPayId = await resolveAlfredpayCustomerId(quote.outputCurrency, userId); + const alfredpayQuoteId = quote.metadata.alfredpayOfframp?.quoteId; if (!alfredpayQuoteId) { - throw new Error("Missing alfredpayOfframp.quoteId in quote metadata"); + throw new APIError({ + message: "Missing alfredpayOfframp.quoteId in quote metadata", + status: httpStatus.BAD_REQUEST + }); } const alfredpayService = AlfredpayApiService.getInstance(); const offrampOrder = await alfredpayService.createOfframp({ amount: quote.metadata.alfredpayOfframp.inputAmountDecimal.toString(), chain: AlfredpayChain.MATIC, - customerId: customer.alfredPayId, + customerId: alfredPayId, fiatAccountId, fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, originAddress: evmEphemeralEntry.address, @@ -321,7 +312,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ stateMeta = { ...stateMeta, alfredpayTransactionId: offrampOrder.transactionId, - alfredpayUserId: customer.alfredPayId, + alfredpayUserId: alfredPayId, evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, isDirectTransfer: true, @@ -412,7 +403,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ stateMeta = { ...stateMeta, alfredpayTransactionId: offrampOrder.transactionId, - alfredpayUserId: customer.alfredPayId, + alfredpayUserId: alfredPayId, evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, squidRouterPermitExecutionValue: bridgeResult.swapData.value, @@ -445,7 +436,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ stateMeta = { ...stateMeta, alfredpayTransactionId: offrampOrder.transactionId, - alfredpayUserId: customer.alfredPayId, + alfredpayUserId: alfredPayId, evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, isDirectTransfer: true, @@ -487,7 +478,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ stateMeta = { ...stateMeta, alfredpayTransactionId: offrampOrder.transactionId, - alfredpayUserId: customer.alfredPayId, + alfredpayUserId: alfredPayId, evmEphemeralAddress: evmEphemeralEntry.address, fiatAccountId, isNoPermitFallback: true, diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts index f47e2c3a8..cbb766b93 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts @@ -17,7 +17,7 @@ import httpStatus from "http-status"; import { encodeFunctionData } from "viem"; import erc20ABI from "../../../../../contracts/ERC20"; import { APIError } from "../../../../errors/api-error"; -import { syncMykoboCustomerKyc } from "../../../mykobo/mykobo-customer.service"; +import { resolveMykoboCustomerForUser } from "../../../mykobo/mykobo-customer.service"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; import { encodeEvmTransactionData } from "../.."; @@ -46,13 +46,9 @@ export async function prepareEvmToMykoboOfframpTransactions({ throw new Error("EVM ephemeral account not found for EVM to Mykobo offramp"); } - if (!email) { - throw new APIError({ - isPublic: true, - message: "email must be provided for Mykobo (EUR) offramp", - status: httpStatus.BAD_REQUEST - }); - } + // The Mykobo email is derived from the effective user's profile (and KYC must be approved); + // a client-supplied email is accepted only if it matches. See resolveMykoboCustomerForUser. + const { email: mykoboEmail } = await resolveMykoboCustomerForUser(userId, email); if (!ipAddress) { throw new APIError({ @@ -116,7 +112,7 @@ export async function prepareEvmToMykoboOfframpTransactions({ const mykobo = MykoboApiService.getInstance(); const intent = await mykobo.createTransactionIntent({ currency: MykoboCurrency.EURC, - email_address: email, + email_address: mykoboEmail, ip_address: ipAddress, transaction_type: MykoboTransactionType.WITHDRAW, value: mykoboFlooredValue, @@ -239,16 +235,12 @@ export async function prepareEvmToMykoboOfframpTransactions({ ...stateMeta, destinationAddress, evmEphemeralAddress: evmEphemeralEntry.address, - mykoboEmail: email, + mykoboEmail, mykoboReceivablesAddress, mykoboTransactionId, mykoboTransactionReference, walletAddress: userAddress }; - if (userId) { - await syncMykoboCustomerKyc(userId, email); - } - return { stateMeta, unsignedTxs }; } diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts index 762e8e729..df5ccf7d0 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts @@ -1,7 +1,25 @@ -import {beforeEach, describe, expect, it, mock} from "bun:test"; +import {afterAll, beforeEach, describe, expect, it, mock} from "bun:test"; import type {AccountMeta, UnsignedTx} from "@vortexfi/shared"; +import * as sharedNamespace from "@vortexfi/shared"; +import * as varsNamespace from "../../../../../config/vars"; +import * as moonbeamCleanupNamespace from "../../moonbeam/cleanup"; +import * as pendulumCleanupNamespace from "../../pendulum/cleanup"; import type {QuoteTicketAttributes} from "../../../../../models/quoteTicket.model"; +// Value copies taken before mock.module runs; restored in afterAll because +// bun module mocks are process-wide and would poison later test files. +const sharedReal = { ...sharedNamespace }; +const varsReal = { ...varsNamespace }; +const moonbeamCleanupReal = { ...moonbeamCleanupNamespace }; +const pendulumCleanupReal = { ...pendulumCleanupNamespace }; + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../../../../config/vars", () => ({ ...varsReal })); + mock.module("../../moonbeam/cleanup", () => ({ ...moonbeamCleanupReal })); + mock.module("../../pendulum/cleanup", () => ({ ...pendulumCleanupReal })); +}); + const Networks = { Base: "base", Moonbeam: "moonbeam" @@ -39,6 +57,7 @@ const createNablaTransactionsForOnrampOnEVM = mock( ); mock.module("@vortexfi/shared", () => ({ + ...sharedReal, AMM_MINIMUM_OUTPUT_HARD_MARGIN: 0.02, AMM_MINIMUM_OUTPUT_SOFT_MARGIN: 0.01, createMoonbeamToPendulumXCM: mock(async () => "0xmoonbeam"), @@ -58,7 +77,9 @@ mock.module("@vortexfi/shared", () => ({ })); mock.module("../../../../../config/vars", () => ({ + ...varsReal, config: { + ...varsReal.config, swap: { deadlineMinutes: 20 } diff --git a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts index 5ea5e071b..1709fa0e8 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts @@ -1,7 +1,5 @@ import { ALFREDPAY_ERC20_TOKEN, - AlfredPayCountry, - AlfredPayStatus, createOnrampSquidrouterTransactionsFromPolygonToEvm, createOnrampSquidrouterTransactionsOnDestinationChain, ERC20_USDC_POLYGON, @@ -10,7 +8,6 @@ import { EvmTokenDetails, EvmTransactionData, evmTokenConfig, - FiatToken, getNetworkFromDestination, getOnChainTokenDetails, getOnChainTokenDetailsOrDefault, @@ -21,9 +18,9 @@ import { UnsignedTx } from "@vortexfi/shared"; import { isAddress } from "viem"; -import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { resolveAlfredpayCustomerId } from "../../../quote/alfredpay-customer"; import { encodeEvmTransactionData } from "../../index"; import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; import { addDestinationChainApprovalTransaction, addOnrampDestinationChainTransactions } from "../common/transactions"; @@ -83,32 +80,11 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ throw new Error(`Output token details not found for ${quote.outputCurrency} on network ${toNetwork}`); } - const fiatToCountry: Partial> = { - [FiatToken.USD]: AlfredPayCountry.US, - [FiatToken.MXN]: AlfredPayCountry.MX, - [FiatToken.COP]: AlfredPayCountry.CO, - [FiatToken.ARS]: AlfredPayCountry.AR - }; - const customerCountry = fiatToCountry[quote.inputCurrency as FiatToken]; - if (!customerCountry) { - throw new Error(`Unsupported Alfredpay input currency: ${quote.inputCurrency}`); - } - - const customer = await AlfredPayCustomer.findOne({ - where: { country: customerCountry, userId } - }); - - if (!customer) { - throw new Error(`Alfredpay customer not found for userId ${userId}`); - } - - if (customer.status !== AlfredPayStatus.Success) { - throw new Error(`Alfredpay customer status is ${customer.status}, expected Success. Proceed first with KYC.`); - } + const alfredPayId = await resolveAlfredpayCustomerId(quote.inputCurrency, userId); // Setup state metadata stateMeta = { - alfredpayUserId: customer.alfredPayId, + alfredpayUserId: alfredPayId, destinationAddress, evmEphemeralAddress: evmEphemeralEntry.address }; diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts index 337a89134..6ee471483 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts @@ -1,4 +1,4 @@ -import {beforeEach, describe, expect, it, mock} from "bun:test"; +import {afterAll, beforeEach, describe, expect, it, mock} from "bun:test"; import Big from "big.js"; const EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; @@ -43,7 +43,37 @@ const addOnrampDestinationChainTransactions = mock(async (params: { amountRaw: s }; }); + +// Value copies taken before the mock.module calls below; restored in afterAll +// because bun module mocks are process-wide and would poison later test files. +import * as sharedNamespace2 from "@vortexfi/shared"; +import * as commonValidationNamespace from "../common/validation"; +import * as commonTransactionsNamespace from "../common/transactions"; +import * as feeDistributionNamespace from "../../common/feeDistribution"; +import * as baseCleanupNamespace from "../../base/cleanup"; +import * as onrampIndexNamespace from "../../index"; +import * as evmFundingNamespace from "../../../phases/evm-funding"; +import * as loggerNamespace2 from "../../../../../config/logger"; + +const restorableModules: Array<[string, Record]> = [ + ["@vortexfi/shared", { ...sharedNamespace2 }], + ["../common/validation", { ...commonValidationNamespace }], + ["../common/transactions", { ...commonTransactionsNamespace }], + ["../../common/feeDistribution", { ...feeDistributionNamespace }], + ["../../base/cleanup", { ...baseCleanupNamespace }], + ["../../index", { ...onrampIndexNamespace }], + ["../../../phases/evm-funding", { ...evmFundingNamespace }], + ["../../../../../config/logger", { ...loggerNamespace2 }] +]; + +afterAll(() => { + for (const [path, real] of restorableModules) { + mock.module(path, () => real); + } +}); + mock.module("@vortexfi/shared", () => ({ + ...sharedNamespace2, createOnrampSquidrouterTransactionsFromBaseToEvm: mock(async () => ({ approveData: { data: "0xapprove", gas: "100000", to: USDC_BASE, value: "0" }, squidRouterQuoteId: "quote", diff --git a/apps/api/src/api/services/transactions/validation.test.ts b/apps/api/src/api/services/transactions/validation.test.ts index b61a8d8ed..80998e934 100644 --- a/apps/api/src/api/services/transactions/validation.test.ts +++ b/apps/api/src/api/services/transactions/validation.test.ts @@ -135,16 +135,20 @@ function withBackups(tx: PresignedTx): PresignedTx { return { ...tx, meta: { additionalTxs } }; } -const VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP: PresignedTx[] = await Promise.all([ - makeSignedEvmTxWithBackups({ nonce: 0, phase: "nablaApprove", network: Networks.Polygon }), - makeSignedEvmTxWithBackups({ nonce: 1, phase: "squidRouterApprove", network: Networks.Polygon }), - makeSignedEvmTxWithBackups({ nonce: 2, phase: "squidRouterSwap", network: Networks.Polygon }), +// All-EVM presigned set on the live BRL onramp Base corridor (see the backend +// onramp_brl phase chain): nabla phases classify as EVM on Base per +// getTransactionTypeForPhase, and destinationTransfer/squidRouterSwap are EVM +// everywhere. Signed with chainId 8453 to match Networks.Base. +const VALID_EXAMPLE_PRESIGNED_TX_BASE_ONRAMP: PresignedTx[] = await Promise.all([ + makeSignedEvmTxWithBackups({ chainId: 8453, network: Networks.Base, nonce: 0, phase: "nablaApprove" }), + makeSignedEvmTxWithBackups({ chainId: 8453, network: Networks.Base, nonce: 1, phase: "squidRouterSwap" }), + makeSignedEvmTxWithBackups({ chainId: 8453, network: Networks.Base, nonce: 2, phase: "destinationTransfer" }), ]); -const VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP: PresignedTx[] = [ - { meta: {}, network: Networks.Polygon, nonce: 0, phase: "nablaApprove", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, - { meta: {}, network: Networks.Polygon, nonce: 1, phase: "squidRouterApprove", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, - { meta: {}, network: Networks.Polygon, nonce: 2, phase: "squidRouterSwap", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, +const VALID_EXAMPLE_UNSIGNED_TX_BASE_ONRAMP: PresignedTx[] = [ + { meta: {}, network: Networks.Base, nonce: 0, phase: "nablaApprove", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, + { meta: {}, network: Networks.Base, nonce: 1, phase: "squidRouterSwap", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, + { meta: {}, network: Networks.Base, nonce: 2, phase: "destinationTransfer", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, ]; const VALID_EXAMPLE_PRESIGNED_TX_BRL_ONRAMP: PresignedTx[] = [ @@ -208,44 +212,6 @@ const VALID_EXAMPLE_UNSIGNED_TX_BRL_ONRAMP: PresignedTx[] = [ describe("Presigned Transaction validation", () => { - it("matches a signed EVM transaction to the unsigned server-built transaction", async () => { - const unsignedTxData: EvmTransactionData = { - data: "0x12345678", - gas: "21000", - maxFeePerGas: "1000000000", - maxPriorityFeePerGas: "1000000000", - to: "0x000000000000000000000000000000000000dEaD", - value: "1" - }; - const signedRawTx = await EVM_WALLET.signTransaction({ - chainId: 137, - data: unsignedTxData.data, - gasLimit: BigInt(unsignedTxData.gas), - maxFeePerGas: BigInt(unsignedTxData.maxFeePerGas!), - maxPriorityFeePerGas: BigInt(unsignedTxData.maxPriorityFeePerGas!), - nonce: 4, - to: unsignedTxData.to, - type: 2, - value: BigInt(unsignedTxData.value) - }); - - const unsignedTx: PresignedTx = { - meta: {}, - network: Networks.Polygon, - nonce: 4, - phase: "fundEphemeral", - signer: EVM_WALLET.address, - txData: unsignedTxData - }; - const signedTx: PresignedTx = { - ...unsignedTx, - txData: signedRawTx - }; - - // change to use universal "validator" - expect(areAllTxsIncluded([signedTx], [unsignedTx])).toBe(true); - }); - it("includes a signed EVM transaction regardless of txData calldata differences (correctness is validated elsewhere)", async () => { const unsignedTxData: EvmTransactionData = { data: "0x12345678", @@ -387,12 +353,12 @@ describe("Presigned Transaction validation", () => { it("should pass validation for valid presigned EVM transactions", async () => { const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; - await expect(validatePresignedTxs(RampDirection.BUY, VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP)).resolves.toBeUndefined(); + await expect(validatePresignedTxs(RampDirection.BUY, VALID_EXAMPLE_PRESIGNED_TX_BASE_ONRAMP, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_BASE_ONRAMP)).resolves.toBeUndefined(); }); it("should pass validation for single valid presigned transaction", async () => { - const singleTx: PresignedTx[] = [VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP[0]]; - const singleUnsigned: PresignedTx[] = [VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP[0]]; + const singleTx: PresignedTx[] = [VALID_EXAMPLE_PRESIGNED_TX_BASE_ONRAMP[0]]; + const singleUnsigned: PresignedTx[] = [VALID_EXAMPLE_UNSIGNED_TX_BASE_ONRAMP[0]]; const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; @@ -440,24 +406,24 @@ describe("Presigned Transaction validation", () => { }); it("should throw error for too many transactions", async () => { - const invalidTxs: PresignedTx[] = new Array(101).fill(VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP[0]); + const invalidTxs: PresignedTx[] = new Array(101).fill(VALID_EXAMPLE_PRESIGNED_TX_BASE_ONRAMP[0]); const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", EVM: EVM_SIGNER_2 }; await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, [])).rejects.toThrow("presignedTxs must be an array with 1-100 elements"); }); it("should throw when an ephemeral transaction is missing backup transactions", async () => { - const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP)); + const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_BASE_ONRAMP)); invalidTxs[2].meta = {}; const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; - await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP)).rejects.toThrow( - "Transaction for phase squidRouterSwap must include at least 4 backup transactions in meta.additionalTxs" + await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_BASE_ONRAMP)).rejects.toThrow( + "Transaction for phase destinationTransfer must include at least 4 backup transactions in meta.additionalTxs" ); }); it("should throw when backup transaction nonces are not sequential", async () => { - const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP)); + const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_BASE_ONRAMP)); const backupTx = invalidTxs[2]?.meta?.additionalTxs?.backup2; if (!backupTx) { throw new Error("Missing backup transaction for test setup"); @@ -466,8 +432,8 @@ describe("Presigned Transaction validation", () => { const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; - await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP)).rejects.toThrow( - "Transaction for phase squidRouterSwap has invalid backup nonce sequence. Expected 4, got 5" + await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_BASE_ONRAMP)).rejects.toThrow( + "Transaction for phase destinationTransfer has invalid backup nonce sequence. Expected 4, got 5" ); }); @@ -1059,17 +1025,17 @@ describe("Presigned Transaction validation", () => { it("accepts a subset of presigned txs when requireComplete is false (updateRamp partial submission)", async () => { const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; - const subset = VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP.slice(0, 1); + const subset = VALID_EXAMPLE_PRESIGNED_TX_BASE_ONRAMP.slice(0, 1); await expect( - validatePresignedTxs(RampDirection.BUY, subset, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP, { requireComplete: false }) + validatePresignedTxs(RampDirection.BUY, subset, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_BASE_ONRAMP, { requireComplete: false }) ).resolves.toBeUndefined(); }); it("still rejects subset submissions by default (requireComplete defaults to true)", async () => { const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; - const subset = VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP.slice(0, 1); + const subset = VALID_EXAMPLE_PRESIGNED_TX_BASE_ONRAMP.slice(0, 1); await expect( - validatePresignedTxs(RampDirection.BUY, subset, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP) + validatePresignedTxs(RampDirection.BUY, subset, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_BASE_ONRAMP) ).rejects.toThrow("Not all unsigned transactions have a corresponding presigned transaction"); }); @@ -1077,7 +1043,7 @@ describe("Presigned Transaction validation", () => { const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; const extra = await makeSignedEvmTxWithBackups({ nonce: 99, phase: "fundEphemeral", network: Networks.Polygon }); await expect( - validatePresignedTxs(RampDirection.BUY, [extra], ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP, { requireComplete: false }) + validatePresignedTxs(RampDirection.BUY, [extra], ephemerals, VALID_EXAMPLE_UNSIGNED_TX_BASE_ONRAMP, { requireComplete: false }) ).rejects.toThrow("Some presigned transactions do not match any unsigned transaction"); }); diff --git a/apps/api/src/api/services/webhook/__tests__/webhook-delivery.service.test.ts b/apps/api/src/api/services/webhook/__tests__/webhook-delivery.service.test.ts index 3bc0f0ed3..88c4fc5fb 100644 --- a/apps/api/src/api/services/webhook/__tests__/webhook-delivery.service.test.ts +++ b/apps/api/src/api/services/webhook/__tests__/webhook-delivery.service.test.ts @@ -1,449 +1,232 @@ -import {afterEach, beforeEach, describe, expect, it, mock} from 'bun:test'; -import {WebhookDeliveryService} from '../webhook-delivery.service'; -import {RampDirection, WebhookEventType} from '@vortexfi/shared'; - -// Mock factory functions -const createMockWebhook = (overrides: Partial = {}) => ({ - id: 'webhook-1', - url: 'https://example.com/webhook1', - secret: 'secret1', +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { RampDirection, TransactionStatus, WebhookEventType } from "@vortexfi/shared"; +import cryptoService from "../../../../config/crypto"; +import { WebhookDeliveryService } from "../webhook-delivery.service"; +import webhookService from "../webhook.service"; + +// The service signs payloads with the real cryptoService singleton (RSA-PSS, +// raw base64 in X-Vortex-Signature) and uses the webhookService singleton for +// lookup/deactivation. No mock.module here — bun module mocks are process-wide +// and leak into other test files. Instead: real keys initialized once, the two +// webhookService methods patched on the instance (originals captured below and +// restored in afterAll), and globalThis.fetch stubbed per test. + +const originalFetch = globalThis.fetch; +const originalFindWebhooksForEvent = webhookService.findWebhooksForEvent; +const originalDeactivateWebhook = webhookService.deactivateWebhook; + +const findWebhooksForEventMock = mock(async (): Promise => []); +const deactivateWebhookMock = mock(async (): Promise => true); + +const fakeWebhook = (overrides: Record = {}) => ({ + id: "webhook-1", + url: "https://example.com/hook", ...overrides }); -const createMockWebhookArray = (webhooks: Partial[] = []) => - webhooks.length > 0 ? webhooks.map(webhook => createMockWebhook(webhook)) : [ - createMockWebhook({ id: 'webhook-1', url: 'https://example.com/webhook1', secret: 'secret1' }) - ]; - -const createMockResponse = (overrides: Partial = {}) => ({ - ok: true, - status: 200, - ...overrides -} as Response); +// Real timers, but backoff shrunk from 1s..16s to 1ms per attempt so the +// retry tests finish instantly. timeoutMs is shrunk so the per-attempt abort +// timer left dangling on rejected fetches fires (harmlessly) right away. +const createService = () => { + const service = new WebhookDeliveryService(); + (service as unknown as { retryDelays: number[] }).retryDelays = [1, 1, 1, 1, 1]; + (service as unknown as { timeoutMs: number }).timeoutMs = 50; + return service; +}; -// Create mock functions first -const findWebhooksForEventMock = mock(async (): Promise => []); -const getWebhookByIdMock = mock(async (): Promise => ({})); -const deactivateWebhookMock = mock(async (): Promise => true); +let fetchMock: ReturnType; +const stubFetch = (impl: () => Promise) => { + fetchMock = mock(impl); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +}; -// Mock fetch globally -const originalFetch = global.fetch; -const fetchMock = mock(async (url: string, options?: RequestInit): Promise => ({ - ok: true, - status: 200 -} as Response)); - -// Mock AbortController -const abortMock = mock(() => {}); -const originalAbortController = global.AbortController; -const mockAbortController = class MockAbortController { - signal = { aborted: false }; - abort = abortMock; +const fetchCall = (index: number) => { + const [url, init] = fetchMock.mock.calls[index] as unknown as [string, RequestInit]; + return { body: init.body as string, headers: init.headers as Record, init, url }; }; -// Mock setTimeout and clearTimeout -const originalSetTimeout = global.setTimeout; -const originalClearTimeout = global.clearTimeout; -const setTimeoutMock = mock((callback: Function, ms: number) => { - // For testing, we can call the callback immediately or return a dummy timeout ID - return 123 as any; -}); -const clearTimeoutMock = mock(() => {}); - -// Mock crypto -const createHmacMock = mock(() => ({ - update: mock(() => ({ - digest: mock(() => 'test-signature-hash') - })) -})); - -mock.module('crypto', () => ({ - default: { - createHmac: createHmacMock - }, - createHmac: createHmacMock -})); - -// Mock dependencies -mock.module('../webhook.service', () => ({ - default: { - findWebhooksForEvent: findWebhooksForEventMock, - getWebhookById: getWebhookByIdMock, - deactivateWebhook: deactivateWebhookMock - } -})); - -// Mock logger -mock.module('../../../../config/logger', () => ({ - default: { - info: mock(() => {}), - error: mock(() => {}), - debug: mock(() => {}), - warn: mock(() => {}) - } -})); - -describe('WebhookDeliveryService', () => { - let webhookDeliveryService: WebhookDeliveryService; +describe("WebhookDeliveryService", () => { + let service: WebhookDeliveryService; - beforeEach(() => { - webhookDeliveryService = new WebhookDeliveryService(); + beforeAll(() => { + cryptoService.initializeKeys(); + (webhookService as { findWebhooksForEvent: unknown }).findWebhooksForEvent = findWebhooksForEventMock; + (webhookService as { deactivateWebhook: unknown }).deactivateWebhook = deactivateWebhookMock; + }); - // Setup global mocks - global.fetch = fetchMock as any; - global.AbortController = mockAbortController as any; - global.setTimeout = setTimeoutMock as any; - global.clearTimeout = clearTimeoutMock as any; + afterAll(() => { + webhookService.findWebhooksForEvent = originalFindWebhooksForEvent; + webhookService.deactivateWebhook = originalDeactivateWebhook; + globalThis.fetch = originalFetch; + }); - // Reset all mocks + beforeEach(() => { + service = createService(); findWebhooksForEventMock.mockReset(); - getWebhookByIdMock.mockReset(); + findWebhooksForEventMock.mockResolvedValue([]); deactivateWebhookMock.mockReset(); - fetchMock.mockReset(); - abortMock.mockReset(); - setTimeoutMock.mockReset(); - clearTimeoutMock.mockReset(); - createHmacMock.mockReset(); - - // Setup default mock return values - createHmacMock.mockReturnValue({ - update: mock(() => ({ - digest: mock(() => 'test-signature-hash') - })) - }); + deactivateWebhookMock.mockResolvedValue(true); }); afterEach(() => { - // Restore globals - global.fetch = originalFetch; - global.AbortController = originalAbortController; - global.setTimeout = originalSetTimeout; - global.clearTimeout = originalClearTimeout; + globalThis.fetch = originalFetch; }); - describe('triggerTransactionCreated', () => { - it('should trigger webhooks for transaction created event', async () => { - // Use mock factory - const mockWebhooks = createMockWebhookArray([ - { id: 'webhook-1', url: 'https://example.com/webhook1', secret: 'secret1' }, - { id: 'webhook-2', url: 'https://example.com/webhook2', secret: 'secret2' } + describe("triggerTransactionCreated", () => { + it("delivers the signed payload to every matching webhook", async () => { + findWebhooksForEventMock.mockResolvedValue([ + fakeWebhook({ id: "webhook-1", url: "https://example.com/hook1" }), + fakeWebhook({ id: "webhook-2", url: "https://example.com/hook2" }) ]); + stubFetch(async () => new Response(null, { status: 200 })); - // Setup mocks - findWebhooksForEventMock.mockResolvedValue(mockWebhooks); - fetchMock.mockResolvedValue(createMockResponse()); - - // Execute - await webhookDeliveryService.triggerTransactionCreated( - 'tx-123', - 'session-456', - 'tx-id', - RampDirection.BUY - ); - - // Verify - expect(findWebhooksForEventMock).toHaveBeenCalledWith( - WebhookEventType.TRANSACTION_CREATED, - 'tx-123', - 'session-456' - ); + await service.triggerTransactionCreated("quote-123", "session-456", "tx-789", RampDirection.BUY); + expect(findWebhooksForEventMock).toHaveBeenCalledWith(WebhookEventType.TRANSACTION_CREATED, "quote-123", "session-456"); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[0][0]).toBe('https://example.com/webhook1'); - expect(fetchMock.mock.calls[1][0]).toBe('https://example.com/webhook2'); + expect(fetchCall(0).url).toBe("https://example.com/hook1"); + expect(fetchCall(1).url).toBe("https://example.com/hook2"); - // Check payload structure - const firstCallPayload = JSON.parse(fetchMock.mock.calls[0][1]!.body as string); - expect(firstCallPayload).toEqual({ + const payload = JSON.parse(fetchCall(0).body); + expect(payload).toEqual({ eventType: WebhookEventType.TRANSACTION_CREATED, - timestamp: expect.any(String), payload: { - sessionId: 'session-456', - transactionId: 'tx-123', - transactionStatus: 'PENDING', + quoteId: "quote-123", + sessionId: "session-456", + transactionId: "tx-789", + transactionStatus: TransactionStatus.PENDING, transactionType: RampDirection.BUY - } + }, + timestamp: expect.any(String) }); + expect(Number.isNaN(Date.parse(payload.timestamp))).toBe(false); + // Both webhooks receive the identical payload + expect(fetchCall(1).body).toBe(fetchCall(0).body); }); - it('should do nothing when no webhooks are found', async () => { - // Setup mocks - findWebhooksForEventMock.mockResolvedValue([]); - - // Execute - await webhookDeliveryService.triggerTransactionCreated( - 'tx-123', - 'session-456', - 'tx-id', - RampDirection.BUY - ); - - // Verify - expect(findWebhooksForEventMock).toHaveBeenCalledWith( - WebhookEventType.TRANSACTION_CREATED, - 'tx-123', - 'session-456' - ); + it("does nothing when no webhooks match", async () => { + stubFetch(async () => new Response(null, { status: 200 })); + + await service.triggerTransactionCreated("quote-123", "session-456", "tx-789", RampDirection.BUY); + + expect(findWebhooksForEventMock).toHaveBeenCalledWith(WebhookEventType.TRANSACTION_CREATED, "quote-123", "session-456"); expect(fetchMock).not.toHaveBeenCalled(); }); - it('should handle webhook delivery failures', async () => { - // Use mock factory - const mockWebhooks = createMockWebhookArray([ - { id: 'webhook-1', url: 'https://example.com/webhook1', secret: 'secret1' } - ]); + it("resolves without throwing when the webhook lookup fails", async () => { + findWebhooksForEventMock.mockRejectedValue(new Error("db down")); + stubFetch(async () => new Response(null, { status: 200 })); - // Setup mocks - webhook delivery fails - findWebhooksForEventMock.mockResolvedValue(mockWebhooks); - fetchMock.mockResolvedValue(createMockResponse({ ok: false, status: 500 })); - - // Execute - await webhookDeliveryService.triggerTransactionCreated( - 'tx-123', - 'session-456', - 'tx-id', - RampDirection.BUY - ); - - // Verify that fetch was called multiple times (retries) - expect(fetchMock).toHaveBeenCalled(); - // Should eventually deactivate webhook after max retries - expect(deactivateWebhookMock).toHaveBeenCalledWith('webhook-1'); + await expect( + service.triggerTransactionCreated("quote-123", "session-456", "tx-789", RampDirection.BUY) + ).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); }); }); - describe('triggerStatusChange', () => { - it('should trigger webhooks for status change event with complete status', async () => { - // Mock data - const mockWebhooks = [ - { - id: 'webhook-1', - url: 'https://example.com/webhook1', - secret: 'secret1' - } - ]; + describe("retry and deactivation", () => { + it("retries up to maxRetries on HTTP failures, then deactivates the webhook", async () => { + findWebhooksForEventMock.mockResolvedValue([fakeWebhook()]); + stubFetch(async () => new Response(null, { status: 500 })); + + await service.triggerTransactionCreated("quote-123", "session-456", "tx-789", RampDirection.BUY); - // Setup mocks - findWebhooksForEventMock.mockResolvedValue(mockWebhooks); - fetchMock.mockResolvedValue({ - ok: true, - status: 200 - } as Response); - - // Execute - await webhookDeliveryService.triggerStatusChange( - 'tx-123', - 'session-456', - 'tx-id', - 'complete', - RampDirection.SELL - ); - - // Verify - expect(findWebhooksForEventMock).toHaveBeenCalledWith( - WebhookEventType.STATUS_CHANGE, - 'tx-123', - 'session-456' - ); - - expect(fetchMock).toHaveBeenCalled(); - - // Check payload contains correct status mapping - const fetchCall = fetchMock.mock.calls[0]; - const payload = JSON.parse(fetchCall[1]!.body as string); - expect(payload.eventType).toBe(WebhookEventType.STATUS_CHANGE); - expect(payload.payload.transactionStatus).toBe('COMPLETE'); - expect(payload.payload.transactionType).toBe(RampDirection.SELL); - expect(payload.payload.transactionId).toBe('tx-123'); - expect(payload.payload.sessionId).toBe('session-456'); + expect(fetchMock).toHaveBeenCalledTimes(5); + expect(deactivateWebhookMock).toHaveBeenCalledWith("webhook-1"); }); - it('should trigger webhooks for status change event with failed status', async () => { - // Mock data - const mockWebhooks = [ - { - id: 'webhook-1', - url: 'https://example.com/webhook1', - secret: 'secret1' - } - ]; + it("stops retrying once a delivery succeeds", async () => { + findWebhooksForEventMock.mockResolvedValue([fakeWebhook()]); + let attempts = 0; + stubFetch(async () => { + attempts++; + return new Response(null, { status: attempts < 3 ? 502 : 200 }); + }); - // Setup mocks - findWebhooksForEventMock.mockResolvedValue(mockWebhooks); - fetchMock.mockResolvedValue({ - ok: true, - status: 200 - } as Response); - - // Execute - await webhookDeliveryService.triggerStatusChange( - 'tx-123', - 'session-456', - 'tx-id', - 'failed', - RampDirection.BUY - ); - - // Verify - const fetchCall = fetchMock.mock.calls[0]; - const payload = JSON.parse(fetchCall[1]!.body as string); - expect(payload.payload.transactionStatus).toBe('FAILED'); + await service.triggerTransactionCreated("quote-123", "session-456", "tx-789", RampDirection.BUY); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(deactivateWebhookMock).not.toHaveBeenCalled(); }); - it('should trigger webhooks for status change event with timedOut status', async () => { - // Mock data - const mockWebhooks = [ - { - id: 'webhook-1', - url: 'https://example.com/webhook1', - secret: 'secret1' - } - ]; + it("treats network errors like failures and deactivates after maxRetries without throwing", async () => { + findWebhooksForEventMock.mockResolvedValue([fakeWebhook()]); + stubFetch(async () => { + throw new Error("connection refused"); + }); + + await expect( + service.triggerTransactionCreated("quote-123", "session-456", "tx-789", RampDirection.BUY) + ).resolves.toBeUndefined(); - // Setup mocks - findWebhooksForEventMock.mockResolvedValue(mockWebhooks); - fetchMock.mockResolvedValue({ - ok: true, - status: 200 - } as Response); - - // Execute - await webhookDeliveryService.triggerStatusChange( - 'tx-123', - 'session-456', - 'tx-id', - 'timedOut', - RampDirection.BUY - ); - - // Verify - const fetchCall = fetchMock.mock.calls[0]; - const payload = JSON.parse(fetchCall[1]!.body as string); - expect(payload.payload.transactionStatus).toBe('FAILED'); + expect(fetchMock).toHaveBeenCalledTimes(5); + expect(deactivateWebhookMock).toHaveBeenCalledWith("webhook-1"); }); + }); + + describe("triggerStatusChange", () => { + it("maps ramp phases to transaction statuses in the payload", async () => { + findWebhooksForEventMock.mockResolvedValue([fakeWebhook()]); + stubFetch(async () => new Response(null, { status: 200 })); - it('should trigger webhooks for status change event with pending status', async () => { - // Mock data - const mockWebhooks = [ - { - id: 'webhook-1', - url: 'https://example.com/webhook1', - secret: 'secret1' - } + const cases: [string, TransactionStatus][] = [ + ["complete", TransactionStatus.COMPLETE], + ["failed", TransactionStatus.FAILED], + ["timedOut", TransactionStatus.FAILED], + ["pendulumCleanup", TransactionStatus.PENDING] ]; - // Setup mocks - findWebhooksForEventMock.mockResolvedValue(mockWebhooks); - fetchMock.mockResolvedValue({ - ok: true, - status: 200 - } as Response); - - // Execute - await webhookDeliveryService.triggerStatusChange( - 'tx-123', - 'session-456', - 'tx-id', - 'someOtherPhase', - RampDirection.BUY - ); - - // Verify - const fetchCall = fetchMock.mock.calls[0]; - const payload = JSON.parse(fetchCall[1]!.body as string); - expect(payload.payload.transactionStatus).toBe('PENDING'); + for (const [index, [phase, expectedStatus]] of cases.entries()) { + await service.triggerStatusChange("quote-123", "session-456", "tx-789", phase, RampDirection.SELL); + + const payload = JSON.parse(fetchCall(index).body); + expect(payload.eventType).toBe(WebhookEventType.STATUS_CHANGE); + expect(payload.payload).toEqual({ + quoteId: "quote-123", + sessionId: "session-456", + transactionId: "tx-789", + transactionStatus: expectedStatus, + transactionType: RampDirection.SELL + }); + } + + expect(findWebhooksForEventMock).toHaveBeenCalledWith(WebhookEventType.STATUS_CHANGE, "quote-123", "session-456"); + expect(fetchMock).toHaveBeenCalledTimes(cases.length); }); - it('should do nothing when no webhooks are found', async () => { - // Setup mocks - findWebhooksForEventMock.mockResolvedValue([]); - - // Execute - await webhookDeliveryService.triggerStatusChange( - 'tx-123', - 'session-456', - 'tx-id', - 'complete', - RampDirection.SELL - ); - - // Verify - expect(findWebhooksForEventMock).toHaveBeenCalledWith( - WebhookEventType.STATUS_CHANGE, - 'tx-123', - 'session-456' - ); + it("does nothing when no webhooks match", async () => { + stubFetch(async () => new Response(null, { status: 200 })); + + await service.triggerStatusChange("quote-123", "session-456", "tx-789", "complete", RampDirection.SELL); + + expect(findWebhooksForEventMock).toHaveBeenCalledWith(WebhookEventType.STATUS_CHANGE, "quote-123", "session-456"); expect(fetchMock).not.toHaveBeenCalled(); }); }); - describe('webhook delivery', () => { - it('should include correct headers in webhook requests', async () => { - // Mock data - const mockWebhooks = [ - { - id: 'webhook-1', - url: 'https://example.com/webhook1', - secret: 'webhook-secret' - } - ]; - - // Setup mocks - findWebhooksForEventMock.mockResolvedValue(mockWebhooks); - fetchMock.mockResolvedValue({ - ok: true, - status: 200 - } as Response); - - // Execute - await webhookDeliveryService.triggerTransactionCreated( - 'tx-123', - 'session-456', - 'tx-id', - RampDirection.BUY - ); - - // Verify headers - expect(fetchMock).toHaveBeenCalledWith( - 'https://example.com/webhook1', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - 'User-Agent': 'Vortex-Webhooks/1.0', - 'X-Vortex-Signature': expect.stringMatching(/^sha256=/), - 'X-Vortex-Timestamp': expect.any(String) - }), - body: expect.any(String), - signal: expect.any(Object) - }) - ); - }); + describe("request format", () => { + it("sends a POST with JSON headers, a unix timestamp, and a verifiable RSA-PSS signature", async () => { + findWebhooksForEventMock.mockResolvedValue([fakeWebhook()]); + stubFetch(async () => new Response(null, { status: 200 })); - it('should handle network errors gracefully', async () => { - // Mock data - const mockWebhooks = [ - { - id: 'webhook-1', - url: 'https://example.com/webhook1', - secret: 'secret1' - } - ]; + await service.triggerTransactionCreated("quote-123", "session-456", "tx-789", RampDirection.BUY); - // Setup mocks - network error - findWebhooksForEventMock.mockResolvedValue(mockWebhooks); - fetchMock.mockRejectedValue(new Error('Network error')); + const { body, headers, init } = fetchCall(0); + expect(init.method).toBe("POST"); + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers["User-Agent"]).toBe("Vortex-Webhooks/1.0"); - // Execute - should not throw - await expect(webhookDeliveryService.triggerTransactionCreated( - 'tx-123', - 'session-456', - 'tx-id', - RampDirection.BUY - )).resolves.toBeUndefined(); + // Freshness timestamp: unix seconds, close to now + expect(headers["X-Vortex-Timestamp"]).toMatch(/^\d+$/); + expect(Math.abs(Number(headers["X-Vortex-Timestamp"]) - Date.now() / 1000)).toBeLessThan(60); - // Should eventually deactivate webhook after max retries - expect(deactivateWebhookMock).toHaveBeenCalledWith('webhook-1'); + // Raw base64 RSA-PSS signature over the exact body — no "sha256=" prefix + // (that belonged to the removed HMAC scheme) + const signature = headers["X-Vortex-Signature"]; + expect(signature.startsWith("sha256=")).toBe(false); + expect(cryptoService.verifySignature(body, signature)).toBe(true); + expect(cryptoService.verifySignature(`${body} `, signature)).toBe(false); }); }); }); diff --git a/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts b/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts index 37a41b24e..ba9d62b48 100644 --- a/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts +++ b/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts @@ -1,6 +1,23 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; +import { describe, it, expect, afterAll, beforeEach } from 'bun:test'; import { mock } from 'bun:test'; +import * as webhookModelNamespace from '../../../../models/webhook.model'; +import * as quoteTicketModelNamespace from '../../../../models/quoteTicket.model'; +import * as loggerNamespace from '../../../../config/logger'; import { WebhookService } from '../webhook.service'; + +// Value copies taken before the mock.module calls below; restored in afterAll +// because bun module mocks are process-wide and would poison later test files. +const restorableModules: Array<[string, Record]> = [ + ['../../../../models/webhook.model', { ...webhookModelNamespace }], + ['../../../../models/quoteTicket.model', { ...quoteTicketModelNamespace }], + ['../../../../config/logger', { ...loggerNamespace }] +]; + +afterAll(() => { + for (const [path, real] of restorableModules) { + mock.module(path, () => real); + } +}); import { APIError } from '../../../errors/api-error'; import { WebhookEventType, RegisterWebhookRequest, RegisterWebhookResponse } from '@vortexfi/shared'; import Webhook, { WebhookAttributes } from '../../../../models/webhook.model'; @@ -36,18 +53,7 @@ const findAllMock = mock(async (): Promise => ([])); const destroyMock = mock(async (): Promise => true); const updateMock = mock(async (): Promise => ({})); -// Mock RampState -const rampStateFindByPkMock = mock(async (): Promise => ({})); - -// Mock crypto -const randomBytesMock = mock(() => Buffer.from('1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'hex')); - -mock.module('crypto', () => ({ - default: { - randomBytes: randomBytesMock - }, - randomBytes: randomBytesMock -})); +const quoteTicketFindByPkMock = mock(async (): Promise => ({})); // Mock modules mock.module('../../../../models/webhook.model', () => ({ @@ -58,9 +64,10 @@ mock.module('../../../../models/webhook.model', () => ({ } })); -mock.module('../../../../models/rampState.model', () => ({ +// Production validates quoteId via QuoteTicket (webhook.service.ts), not RampState. +mock.module('../../../../models/quoteTicket.model', () => ({ default: { - findByPk: rampStateFindByPkMock + findByPk: quoteTicketFindByPkMock } })); @@ -83,11 +90,7 @@ describe('WebhookService', () => { findAllMock.mockReset(); destroyMock.mockReset(); updateMock.mockReset(); - rampStateFindByPkMock.mockReset(); - randomBytesMock.mockReset(); - - // Setup default crypto mock - randomBytesMock.mockReturnValue(Buffer.from('1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'hex')); + quoteTicketFindByPkMock.mockReset(); }); describe('registerWebhook', () => { @@ -95,7 +98,7 @@ describe('WebhookService', () => { const mockWebhook = createMockWebhook(); // Setup mocks - rampStateFindByPkMock.mockResolvedValue(createMockRampState()); // Quote exists + quoteTicketFindByPkMock.mockResolvedValue(createMockRampState()); // Quote exists createMock.mockResolvedValue(mockWebhook); // Execute @@ -105,7 +108,7 @@ describe('WebhookService', () => { }); // Verify - expect(rampStateFindByPkMock).toHaveBeenCalledWith('quote-123'); + expect(quoteTicketFindByPkMock).toHaveBeenCalledWith('quote-123'); expect(createMock).toHaveBeenCalledWith({ events: [WebhookEventType.TRANSACTION_CREATED, WebhookEventType.STATUS_CHANGE], isActive: true, @@ -192,14 +195,22 @@ describe('WebhookService', () => { }); it('should handle registration errors', async () => { - // Setup mocks + // Setup mocks — the quote lookup must succeed so the rejection genuinely + // comes from Webhook.create, not from an earlier validation step. + quoteTicketFindByPkMock.mockResolvedValue({ id: 'quote-123' }); createMock.mockRejectedValue(new Error('Database error')); // Execute and verify - await expect(webhookService.registerWebhook({ + const error = await webhookService.registerWebhook({ url: 'https://example.com/webhook', quoteId: 'quote-123' - })).rejects.toBeInstanceOf(APIError); + }).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(500); + expect(createMock).toHaveBeenCalled(); }); it('should reject non-HTTPS URLs', async () => { @@ -244,13 +255,19 @@ describe('WebhookService', () => { it('should reject when quoteId does not exist', async () => { // Setup mocks - quote not found - rampStateFindByPkMock.mockResolvedValue(null); + quoteTicketFindByPkMock.mockResolvedValue(null); - // Execute and verify - await expect(webhookService.registerWebhook({ + // Execute and verify — pin the 404 so a generic wrapped error can't satisfy this + const error = await webhookService.registerWebhook({ url: 'https://example.com/webhook', quoteId: 'non-existent-quote' - })).rejects.toBeInstanceOf(APIError); + }).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(404); + expect((error as APIError).message).toContain('not found'); }); }); diff --git a/apps/api/src/api/workers/cleanup.worker.test.ts b/apps/api/src/api/workers/cleanup.worker.test.ts index 4e9b8a810..06b06a8df 100644 --- a/apps/api/src/api/workers/cleanup.worker.test.ts +++ b/apps/api/src/api/workers/cleanup.worker.test.ts @@ -1,8 +1,30 @@ -import {beforeEach, describe, expect, it, mock} from "bun:test"; +import {afterAll, beforeEach, describe, expect, it, mock} from "bun:test"; import {CleanupPhase} from "@vortexfi/shared"; +import * as loggerNamespace from "../../config/logger"; +import * as postProcessNamespace from "../services/phases/post-process"; import RampState, {RampStateAttributes} from "../../models/rampState.model"; import CleanupWorker from "./cleanup.worker"; +// Value copies taken before the mock.module calls below; restored in afterAll +// because bun module mocks are process-wide and would poison later test files. +const loggerReal = { ...loggerNamespace }; +const postProcessReal = { ...postProcessNamespace }; +const realRampStateUpdate = RampState.update; + +// CleanupWorker's CronJob is created with runOnInit=true, so merely +// constructing the worker fires a real cleanup cycle (live DB queries) in the +// background. Neutralize the tick target for the duration of this file. +const workerPrototype = CleanupWorker.prototype as unknown as { cleanup: () => Promise }; +const realCleanupCycle = workerPrototype.cleanup; +workerPrototype.cleanup = async () => {}; + +afterAll(() => { + mock.module("../../config/logger", () => ({ ...loggerReal })); + mock.module("../services/phases/post-process", () => ({ ...postProcessReal })); + RampState.update = realRampStateUpdate; + workerPrototype.cleanup = realCleanupCycle; +}); + class TestCleanupWorker extends CleanupWorker { public async testProcessCleanup(state: RampState): Promise { return this.processCleanup(state); diff --git a/apps/api/src/config/crypto.test.ts b/apps/api/src/config/crypto.test.ts index 9ce939965..3a3c8261a 100644 --- a/apps/api/src/config/crypto.test.ts +++ b/apps/api/src/config/crypto.test.ts @@ -1,16 +1,20 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import crypto from "crypto"; import { CryptoService } from "./crypto"; +import { config } from "./vars"; describe("CryptoService - Public Key Derivation", () => { - let originalEnv: NodeJS.ProcessEnv; + // initializeKeys() reads config.secrets.webhookPrivateKey — the snapshot of + // WEBHOOK_PRIVATE_KEY taken when vars.ts was imported. Mutating process.env + // here is inert, so the tests inject through the config object instead. + let originalWebhookPrivateKey: string | undefined; beforeEach(() => { - originalEnv = { ...process.env }; + originalWebhookPrivateKey = config.secrets.webhookPrivateKey; }); afterEach(() => { - process.env = originalEnv; + config.secrets.webhookPrivateKey = originalWebhookPrivateKey; }); it("should derive public key from private key when only WEBHOOK_PRIVATE_KEY is provided", () => { @@ -27,9 +31,8 @@ describe("CryptoService - Public Key Derivation", () => { } }); - // Set only the private key in environment - process.env.WEBHOOK_PRIVATE_KEY = privateKey; - delete process.env.WEBHOOK_PUBLIC_KEY; + // Inject only the private key through the config snapshot + config.secrets.webhookPrivateKey = privateKey; // Create a new instance and initialize const cryptoService = new (CryptoService as any)(); @@ -56,9 +59,8 @@ describe("CryptoService - Public Key Derivation", () => { } }); - // Set only the private key in environment - process.env.WEBHOOK_PRIVATE_KEY = privateKey; - delete process.env.WEBHOOK_PUBLIC_KEY; + // Inject only the private key through the config snapshot + config.secrets.webhookPrivateKey = privateKey; // Create a new instance and initialize const cryptoService = new (CryptoService as any)(); @@ -73,8 +75,7 @@ describe("CryptoService - Public Key Derivation", () => { }); it("should generate new key pair when WEBHOOK_PRIVATE_KEY is not provided", () => { - delete process.env.WEBHOOK_PRIVATE_KEY; - delete process.env.WEBHOOK_PUBLIC_KEY; + config.secrets.webhookPrivateKey = undefined; const cryptoService = new (CryptoService as any)(); cryptoService.initializeKeys(); diff --git a/apps/api/src/config/payment-methods.config.ts b/apps/api/src/config/payment-methods.config.ts index 224bb551a..e2abefdac 100644 --- a/apps/api/src/config/payment-methods.config.ts +++ b/apps/api/src/config/payment-methods.config.ts @@ -27,6 +27,35 @@ const ARS = { name: "Argentine Peso" }; +// USD/MXN/COP limits mirror the hardcoded AlfredPay individual-customer bounds +// (see packages/shared/src/tokens/freeTokens/config.ts), converted from raw to fiat units. +const USD = { + id: FiatToken.USD, + limits: { + max: 100000, + min: 1 + }, + name: "US Dollar" +}; + +const MXN = { + id: FiatToken.MXN, + limits: { + max: 86952173, + min: 150 + }, + name: "Mexican Peso" +}; + +const COP = { + id: FiatToken.COP, + limits: { + max: 368655999, + min: 33000 + }, + name: "Colombian Peso" +}; + const SEPA_PAYMENT_METHOD: PaymentMethodConfig = { id: EPaymentMethod.SEPA, name: PaymentMethodName.SEPA, @@ -45,7 +74,19 @@ const CBU_PAYMENT_METHOD: PaymentMethodConfig = { supportedFiats: [ARS] }; +const SPEI_PAYMENT_METHOD: PaymentMethodConfig = { + id: EPaymentMethod.SPEI, + name: PaymentMethodName.SPEI, + supportedFiats: [MXN] +}; + +const ACH_PAYMENT_METHOD: PaymentMethodConfig = { + id: EPaymentMethod.ACH, + name: PaymentMethodName.ACH, + supportedFiats: [USD, COP] +}; + export const PAYMENT_METHODS_CONFIG: Record = { - buy: [PIX_PAYMENT_METHOD], - sell: [SEPA_PAYMENT_METHOD, PIX_PAYMENT_METHOD, CBU_PAYMENT_METHOD] + buy: [PIX_PAYMENT_METHOD, ACH_PAYMENT_METHOD, SPEI_PAYMENT_METHOD], + sell: [SEPA_PAYMENT_METHOD, PIX_PAYMENT_METHOD, CBU_PAYMENT_METHOD, ACH_PAYMENT_METHOD, SPEI_PAYMENT_METHOD] }; diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 0a3254e6d..2e83fc47c 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -1,10 +1,12 @@ import {describe, expect, it} from "bun:test"; +import os from "node:os"; const varsModuleUrl = new URL("./vars.ts", import.meta.url).href; const bunExecutable = Bun.argv[0]; const requiredProductionEnv = { ADMIN_SECRET: "test-admin-secret", + FLOW_VARIANT: "monerium", METRICS_DASHBOARD_SECRET: "test-metrics-dashboard-secret", SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_KEY: "test-service-key", @@ -19,6 +21,9 @@ async function importVarsWithEnv(env: Record) { "-e", `import(${JSON.stringify(varsModuleUrl)}).then(() => console.log("ok")).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); })` ], + // A cwd without .env files: bun auto-loads .env from the cwd, which would + // silently backfill variables these scenarios deliberately leave unset. + cwd: os.tmpdir(), env: { PATH: process.env.PATH ?? "", ...requiredProductionEnv, diff --git a/apps/api/src/database/migrations/034-add-user-id-to-api-keys.ts b/apps/api/src/database/migrations/034-add-user-id-to-api-keys.ts new file mode 100644 index 000000000..bf3799740 --- /dev/null +++ b/apps/api/src/database/migrations/034-add-user-id-to-api-keys.ts @@ -0,0 +1,29 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("api_keys", "user_id", { + allowNull: true, + field: "user_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }); + + await queryInterface.addIndex("api_keys", ["user_id"], { + name: "idx_api_keys_user_id" + }); + + await queryInterface.addIndex("api_keys", ["user_id", "is_active"], { + name: "idx_api_keys_active_user_lookup" + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeIndex("api_keys", "idx_api_keys_active_user_lookup"); + await queryInterface.removeIndex("api_keys", "idx_api_keys_user_id"); + await queryInterface.removeColumn("api_keys", "user_id"); +} diff --git a/apps/api/src/database/migrations/035-make-api-key-partner-name-nullable.ts b/apps/api/src/database/migrations/035-make-api-key-partner-name-nullable.ts new file mode 100644 index 000000000..b070498f7 --- /dev/null +++ b/apps/api/src/database/migrations/035-make-api-key-partner-name-nullable.ts @@ -0,0 +1,17 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.changeColumn("api_keys", "partner_name", { + allowNull: true, + field: "partner_name", + type: DataTypes.STRING(100) + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.changeColumn("api_keys", "partner_name", { + allowNull: false, + field: "partner_name", + type: DataTypes.STRING(100) + }); +} diff --git a/apps/api/src/database/migrations/036-add-eth-usdt-to-subsidy-token-enum.ts b/apps/api/src/database/migrations/036-add-eth-usdt-to-subsidy-token-enum.ts new file mode 100644 index 000000000..a44573acd --- /dev/null +++ b/apps/api/src/database/migrations/036-add-eth-usdt-to-subsidy-token-enum.ts @@ -0,0 +1,52 @@ +import { QueryInterface } from "sequelize"; + +// Migration 022 left the DB enum without ETH even though SubsidyToken.ETH existed +// in the model (squid-router-pay ETH gas subsidies failed silently in createSubsidy). +// USDT is new: finalSettlementSubsidy now records rows for USDT settlement top-ups. +const OLD_ENUM_VALUES = ["GLMR", "PEN", "XLM", "USDC.axl", "BRLA", "EURC", "USDC", "MATIC", "BRL"]; +const NEW_ENUM_VALUES = [...OLD_ENUM_VALUES, "ETH", "USDT"]; + +export async function up(queryInterface: QueryInterface): Promise { + // Phase 1: Convert enum to VARCHAR to allow value updates + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE VARCHAR(32); + `); + + // Phase 2: Replace enum type with updated values + await queryInterface.sequelize.query(` + DROP TYPE IF EXISTS enum_subsidies_token; + `); + + await queryInterface.sequelize.query(` + CREATE TYPE enum_subsidies_token AS ENUM (${NEW_ENUM_VALUES.map(value => `'${value}'`).join(", ")}); + `); + + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE enum_subsidies_token USING token::enum_subsidies_token; + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + // Phase 1: Convert enum to VARCHAR to allow value updates + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE VARCHAR(32); + `); + + // Phase 2: Map values unsupported by the old enum to USDC + await queryInterface.sequelize.query(` + UPDATE subsidies SET token = 'USDC' WHERE token IN ('ETH', 'USDT'); + `); + + // Phase 3: Restore old enum type + await queryInterface.sequelize.query(` + DROP TYPE IF EXISTS enum_subsidies_token; + `); + + await queryInterface.sequelize.query(` + CREATE TYPE enum_subsidies_token AS ENUM (${OLD_ENUM_VALUES.map(value => `'${value}'`).join(", ")}); + `); + + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE enum_subsidies_token USING token::enum_subsidies_token; + `); +} diff --git a/apps/api/src/database/migrations/037-subsidy-token-enum-to-varchar.ts b/apps/api/src/database/migrations/037-subsidy-token-enum-to-varchar.ts new file mode 100644 index 000000000..d995815df --- /dev/null +++ b/apps/api/src/database/migrations/037-subsidy-token-enum-to-varchar.ts @@ -0,0 +1,38 @@ +import { QueryInterface } from "sequelize"; + +// The subsidy token symbol comes from the dynamic SquidRouter token registry +// (outTokenDetails.assetSymbol in final-settlement-subsidy) and per-network +// native symbols (BNB, AVAX), so the set of reachable values is open-ended — +// an enum can never be complete. Migration 036 already patched this failure +// mode once (ETH missing → createSubsidy failed silently). Widen to VARCHAR +// permanently so on-chain subsidies can't go unrecorded over bookkeeping. +const ENUM_VALUES_036 = ["GLMR", "PEN", "XLM", "USDC.axl", "BRLA", "EURC", "USDC", "MATIC", "BRL", "ETH", "USDT"]; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE VARCHAR(32); + `); + + await queryInterface.sequelize.query(` + DROP TYPE IF EXISTS enum_subsidies_token; + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + // Map values the 036 enum can't hold to USDC before casting back. + await queryInterface.sequelize.query(` + UPDATE subsidies SET token = 'USDC' WHERE token NOT IN (${ENUM_VALUES_036.map(value => `'${value}'`).join(", ")}); + `); + + await queryInterface.sequelize.query(` + DROP TYPE IF EXISTS enum_subsidies_token; + `); + + await queryInterface.sequelize.query(` + CREATE TYPE enum_subsidies_token AS ENUM (${ENUM_VALUES_036.map(value => `'${value}'`).join(", ")}); + `); + + await queryInterface.sequelize.query(` + ALTER TABLE subsidies ALTER COLUMN token TYPE enum_subsidies_token USING token::enum_subsidies_token; + `); +} diff --git a/apps/api/src/models/apiKey.model.ts b/apps/api/src/models/apiKey.model.ts index f4404ad0e..b59ac1a3e 100644 --- a/apps/api/src/models/apiKey.model.ts +++ b/apps/api/src/models/apiKey.model.ts @@ -5,7 +5,7 @@ import type Partner from "./partner.model"; // Define the attributes of the ApiKey model export interface ApiKeyAttributes { id: string; - partnerName: string; + partnerName: string | null; keyType: "public" | "secret"; keyHash: string | null; keyValue: string | null; @@ -14,6 +14,7 @@ export interface ApiKeyAttributes { lastUsedAt: Date | null; expiresAt: Date | null; isActive: boolean; + userId: string | null; createdAt: Date; updatedAt: Date; } @@ -21,14 +22,14 @@ export interface ApiKeyAttributes { // Define the attributes that can be set during creation type ApiKeyCreationAttributes = Optional< ApiKeyAttributes, - "id" | "keyType" | "name" | "lastUsedAt" | "expiresAt" | "createdAt" | "updatedAt" + "id" | "keyType" | "name" | "lastUsedAt" | "expiresAt" | "partnerName" | "userId" | "createdAt" | "updatedAt" >; // Define the ApiKey model class ApiKey extends Model implements ApiKeyAttributes { declare id: string; - declare partnerName: string; + declare partnerName: string | null; declare keyType: "public" | "secret"; @@ -46,6 +47,8 @@ class ApiKey extends Model implement declare isActive: boolean; + declare userId: string | null; + declare createdAt: Date; declare updatedAt: Date; @@ -112,7 +115,7 @@ ApiKey.init( type: DataTypes.STRING(100) }, partnerName: { - allowNull: false, + allowNull: true, field: "partner_name", type: DataTypes.STRING(100) }, @@ -121,6 +124,17 @@ ApiKey.init( defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE + }, + userId: { + allowNull: true, + field: "user_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID } }, { @@ -145,6 +159,14 @@ ApiKey.init( fields: ["is_active"], name: "idx_api_keys_active" }, + { + fields: ["user_id"], + name: "idx_api_keys_user_id" + }, + { + fields: ["user_id", "is_active"], + name: "idx_api_keys_active_user_lookup" + }, { fields: ["is_active", "key_prefix", "key_type"], name: "idx_api_keys_active_prefix_type", diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 847719dc8..f869815ae 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -51,6 +51,10 @@ ProfilePartnerAssignment.belongsTo(Partner, { as: "sellPartner", foreignKey: "se Partner.hasMany(ProfilePartnerAssignment, { as: "buyProfileAssignments", foreignKey: "buyPartnerId" }); Partner.hasMany(ProfilePartnerAssignment, { as: "sellProfileAssignments", foreignKey: "sellPartnerId" }); +// API key ↔ user binding +User.hasMany(ApiKey, { as: "apiKeys", foreignKey: "userId" }); +ApiKey.belongsTo(User, { as: "user", foreignKey: "userId" }); + // Initialize models const models = { AlfredPayCustomer, diff --git a/apps/api/src/models/subsidy.model.ts b/apps/api/src/models/subsidy.model.ts index e4bd896e8..58a84aca8 100644 --- a/apps/api/src/models/subsidy.model.ts +++ b/apps/api/src/models/subsidy.model.ts @@ -2,7 +2,10 @@ import { RampPhase } from "@vortexfi/shared"; import { DataTypes, Model, Optional } from "sequelize"; import sequelize from "../config/database"; -// TODO how to take this from other types? +// Known subsidy tokens for callers that reference fixed symbols. The DB column +// is a plain VARCHAR (migration 037): final-settlement subsidies record the +// assetSymbol from the dynamic SquidRouter registry, so the reachable set of +// symbols is open-ended and must not be constrained by an enum. export enum SubsidyToken { GLMR = "GLMR", PEN = "PEN", @@ -13,7 +16,8 @@ export enum SubsidyToken { USDC = "USDC", MATIC = "MATIC", BRL = "BRL", - ETH = "ETH" + ETH = "ETH", + USDT = "USDT" } export interface SubsidyAttributes { @@ -21,7 +25,7 @@ export interface SubsidyAttributes { rampId: string; phase: RampPhase; amount: number; - token: SubsidyToken; + token: string; paymentDate: Date; payerAccount: string; transactionHash: string; @@ -40,7 +44,7 @@ class Subsidy extends Model implem declare amount: number; - declare token: SubsidyToken; + declare token: string; declare paymentDate: Date; @@ -104,7 +108,7 @@ Subsidy.init( token: { allowNull: false, comment: "Token used for the subsidy payment", - type: DataTypes.ENUM(...Object.values(SubsidyToken)) + type: DataTypes.STRING(32) }, transactionHash: { allowNull: false, diff --git a/apps/api/src/test-utils/db.ts b/apps/api/src/test-utils/db.ts new file mode 100644 index 000000000..e74cdf278 --- /dev/null +++ b/apps/api/src/test-utils/db.ts @@ -0,0 +1,61 @@ +import sequelize from "../config/database"; +import { runMigrations } from "../database/migrator"; +// Importing the models index registers every model and association on the sequelize instance. +import "../models"; + +let initialized = false; + +/** + * Connects to the dedicated test database and brings it to the latest migration. + * Idempotent; call from beforeAll in any integration test. + */ +export async function setupTestDatabase(): Promise { + if (initialized) { + return; + } + + const dbName = sequelize.getDatabaseName(); + if (!dbName.includes("test")) { + throw new Error( + `Refusing to run integration tests against database '${dbName}'. ` + + "The test preload should have pointed DB_NAME at 'vortex_test' — check src/test-utils/preload.ts." + ); + } + + try { + await sequelize.authenticate(); + } catch (error) { + throw new Error( + `Could not connect to the test database at ${sequelize.config.host}:${sequelize.config.port}. ` + + "Start it with `bun test:db:start` (from apps/api). " + + `Original error: ${error instanceof Error ? error.message : error}` + ); + } + + await runMigrations(); + initialized = true; +} + +/** + * Standard between-test reset: empty all tables, then re-seed the baseline + * configuration rows (vortex fee partners) the application expects. + */ +export async function resetTestDatabase(): Promise { + await truncateAllTables(); + const { seedVortexPartners } = await import("./factories"); + await seedVortexPartners(); +} + +/** + * Empties all application tables between tests while keeping the schema and + * migration bookkeeping intact. + */ +export async function truncateAllTables(): Promise { + const tables = Object.values(sequelize.models) + // Umzug's SequelizeStorage registers SequelizeMeta as a model; wiping it + // would make every migration re-run on the next setup. + .filter(model => model.name !== "SequelizeMeta") + .map(model => `"${model.getTableName()}"`) + .join(", "); + await sequelize.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE`); +} diff --git a/apps/api/src/test-utils/factories.ts b/apps/api/src/test-utils/factories.ts new file mode 100644 index 000000000..9508c5dca --- /dev/null +++ b/apps/api/src/test-utils/factories.ts @@ -0,0 +1,200 @@ +import { + AlfredPayCountry, + AlfredPayStatus, + AlfredPayType, + AveniaAccountType, + type DestinationType, + EPaymentMethod, + EvmToken, + FiatToken, + Networks, + RampDirection, + type UnsignedTx +} from "@vortexfi/shared"; +import { generateApiKey, getKeyPrefix, hashApiKey } from "../api/middlewares/apiKeyAuth.helpers"; +import type { StateMetadata } from "../api/services/phases/meta-state-types"; +import type { QuoteTicketMetadata } from "../api/services/quote/core/types"; +import { config } from "../config/vars"; +import AlfredPayCustomer from "../models/alfredPayCustomer.model"; +import ApiKey from "../models/apiKey.model"; +import Partner from "../models/partner.model"; +import QuoteTicket, { type QuoteTicketAttributes } from "../models/quoteTicket.model"; +import RampState, { type RampStateAttributes } from "../models/rampState.model"; +import TaxId, { TaxIdInternalStatus } from "../models/taxId.model"; +import User from "../models/user.model"; + +let sequence = 0; +function nextSeq(): number { + return ++sequence; +} + +export async function createTestUser(overrides: Partial<{ id: string; email: string }> = {}): Promise { + const seq = nextSeq(); + return User.create({ + email: overrides.email ?? `test-user-${seq}@example.com`, + id: overrides.id ?? crypto.randomUUID() + }); +} + +export async function createTestPartner(overrides: Partial[0]> = {}): Promise { + const seq = nextSeq(); + return Partner.create({ + displayName: `Test Partner ${seq}`, + isActive: true, + logoUrl: null, + markupCurrency: FiatToken.EURC, + markupType: "none", + markupValue: 0, + maxDynamicDifference: 0, + maxSubsidy: 0, + minDynamicDifference: 0, + name: `test-partner-${seq}`, + payoutAddressEvm: null, + payoutAddressSubstrate: null, + rampType: RampDirection.BUY, + targetDiscount: 0, + vortexFeeType: "none", + vortexFeeValue: 0, + ...overrides + }); +} + +/** + * Creates a secret API key for a partner (or a user-scoped key when userId is given) + * and returns both the DB record and the plaintext key for use in request headers. + */ +export async function createTestApiKey( + options: { partnerName?: string; userId?: string } = {} +): Promise<{ record: ApiKey; plaintextKey: string }> { + const plaintextKey = generateApiKey("secret", "test"); + const record = await ApiKey.create({ + expiresAt: null, + isActive: true, + keyHash: await hashApiKey(plaintextKey), + keyPrefix: getKeyPrefix(plaintextKey), + keyType: "secret", + keyValue: null, + lastUsedAt: null, + name: "test key", + partnerName: options.partnerName ?? null, + userId: options.userId ?? null + }); + return { plaintextKey, record }; +} + +/** Minimal complete fee structure so status/fee readers work; override per test. */ +export function defaultQuoteFees(currency: FiatToken = FiatToken.EURC): NonNullable { + return { + displayFiat: { anchor: "1", currency, network: "0", partnerMarkup: "0", total: "1", vortex: "0" }, + usd: { anchor: "1", network: "0", partnerMarkup: "0", total: "1", vortex: "0" } + }; +} + +/** + * A pending EUR→USDC-on-Base onramp quote by default; override anything. + * Metadata carries a minimal fee structure — pass a realistic `metadata` + * override for tests that exercise ramp registration. + */ +export async function createTestQuote(overrides: Partial = {}): Promise { + return QuoteTicket.create({ + apiKey: null, + countryCode: null, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + flowVariant: config.flowVariant, + from: EPaymentMethod.SEPA as DestinationType, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + metadata: { fees: defaultQuoteFees(), ...(overrides.metadata ?? {}) } as QuoteTicketMetadata, + network: Networks.Base, + outputAmount: "105", + outputCurrency: EvmToken.USDC, + partnerId: null, + paymentMethod: EPaymentMethod.SEPA, + pricingPartnerId: null, + rampType: RampDirection.BUY, + status: "pending", + to: Networks.Base as DestinationType, + userId: null, + ...overrides + }); +} + +/** + * Baseline configuration the quote pipeline expects in every environment: + * the "vortex" partner rows carrying the default platform fee (zero here; + * tests that assert fee math override via createTestPartner). + */ +export async function seedVortexPartners(): Promise { + for (const rampType of [RampDirection.BUY, RampDirection.SELL]) { + await createTestPartner({ displayName: "Vortex", name: "vortex", rampType }); + } +} + +/** An Alfredpay-KYC'd customer linked to a user, as required by MXN/COP/USD/ARS ramp registration. */ +export async function createTestAlfredpayCustomer( + userId: string, + overrides: Partial<{ alfredPayId: string; country: AlfredPayCountry }> = {} +): Promise { + const seq = nextSeq(); + return AlfredPayCustomer.create({ + alfredPayId: overrides.alfredPayId ?? `test-alfredpay-customer-${seq}`, + country: overrides.country ?? AlfredPayCountry.MX, + lastFailureReasons: null, + status: AlfredPayStatus.Success, + statusExternal: null, + type: AlfredPayType.INDIVIDUAL, + userId + }); +} + +/** An Avenia-KYC'd tax id linked to a user, as required by BRL ramp registration. */ +export async function createTestTaxId(userId: string, overrides: Partial<{ taxId: string; subAccountId: string }> = {}) { + const seq = nextSeq(); + return TaxId.create({ + accountType: AveniaAccountType.INDIVIDUAL, + finalQuoteId: null, + finalSessionId: null, + finalTimestamp: null, + initialQuoteId: null, + initialSessionId: null, + internalStatus: TaxIdInternalStatus.Accepted, + kycAttempt: null, + requestedDate: new Date(), + subAccountId: overrides.subAccountId ?? "test-subaccount-id", + taxId: overrides.taxId ?? `1234567890${seq}`, + userId + }); +} + +const DEFAULT_UNSIGNED_TX: UnsignedTx = { + network: Networks.Base, + nonce: 0, + phase: "destinationTransfer", + signer: "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3", + txData: "0x" +} as UnsignedTx; + +/** + * A ramp state in its initial phase, linked to a fresh quote unless quoteId is given. + */ +export async function createTestRampState(overrides: Partial = {}): Promise { + const quoteId = overrides.quoteId ?? (await createTestQuote()).id; + return RampState.create({ + currentPhase: "initial", + errorLogs: [], + flowVariant: config.flowVariant, + from: EPaymentMethod.SEPA as DestinationType, + paymentMethod: EPaymentMethod.SEPA, + phaseHistory: [], + postCompleteState: { cleanup: { cleanupAt: null, cleanupCompleted: false, errors: null } }, + presignedTxs: null, + processingLock: { locked: false, lockedAt: null }, + state: (overrides.state ?? {}) as StateMetadata, + to: Networks.Base, + type: RampDirection.BUY, + unsignedTxs: [DEFAULT_UNSIGNED_TX], + userId: null, + ...overrides, + quoteId + }); +} diff --git a/apps/api/src/test-utils/fake-world/fake-anchors.ts b/apps/api/src/test-utils/fake-world/fake-anchors.ts new file mode 100644 index 000000000..3e6dc9c56 --- /dev/null +++ b/apps/api/src/test-utils/fake-world/fake-anchors.ts @@ -0,0 +1,477 @@ +import { + AlfredpayApiService, + type AlfredpayFee, + type AlfredpayFiatAccount, + type AlfredpayFiatPaymentInstructions, + type AlfredpayOfframpQuote, + AlfredpayOfframpStatus, + type AlfredpayOfframpTransaction, + type AlfredpayOnrampQuote, + AlfredpayOnrampStatus, + type AlfredpayOnrampStatusMetadata, + type AlfredpayOnrampTransaction, + AlfredpayPaymentMethodType, + AveniaTicketStatus, + BrlaApiService, + type CreateAlfredpayOfframpQuoteRequest, + type CreateAlfredpayOfframpRequest, + type CreateAlfredpayOfframpResponse, + type CreateAlfredpayOnrampQuoteRequest, + type CreateAlfredpayOnrampRequest, + type CreateAlfredpayOnrampResponse, + type GetAlfredpayOnrampTransactionResponse, + MykoboApiError, + MykoboApiService, + type MykoboCreateIntentRequest, + type MykoboCreateIntentResponse, + type MykoboFeeResponse, + type MykoboGetProfileResponse, + type MykoboGetTransactionResponse, + type MykoboTransaction, + MykoboTransactionStatus, + MykoboTransactionType +} from "@vortexfi/shared"; + +function unimplementedProxy(impl: object, label: string): T { + return new Proxy(impl, { + get: (obj, prop) => { + if (prop in obj) { + return (obj as Record)[prop]; + } + if (prop === "then") { + return undefined; + } + throw new Error(`${label}.${String(prop)} is not implemented — extend src/test-utils/fake-world/fake-anchors.ts.`); + } + }) as T; +} + +/** + * Fake Mykobo anchor. Creates deterministic transactions/intents in memory; + * fees and per-call failures are scripted through the public fields. + */ +export class FakeMykobo { + depositFeeTotal = "1.00"; + withdrawFeeTotal = "1.00"; + /** When set, the next createTransactionIntent call rejects with this error. */ + failNextIntent: Error | null = null; + /** + * KYC review status served by getProfileByEmail ("approved" | "pending" | + * "rejected"); null means Mykobo knows no such profile (404). + */ + profileKycReviewStatus: string | null = "approved"; + /** On-chain receivables address handed out in WITHDRAW intent instructions. */ + withdrawReceivablesAddress = "0x5afe0000000000000000000000000000005e9a00"; + + readonly intents: MykoboCreateIntentRequest[] = []; + readonly transactions = new Map(); + private counter = 0; + + setTransactionStatus(id: string, status: MykoboTransactionStatus): void { + const transaction = this.transactions.get(id); + if (!transaction) { + throw new Error(`FakeMykobo: unknown transaction ${id}`); + } + transaction.status = status; + } + + private readonly impl = { + createTransactionIntent: async (request: MykoboCreateIntentRequest): Promise => { + if (this.failNextIntent) { + const error = this.failNextIntent; + this.failNextIntent = null; + throw error; + } + this.intents.push(request); + this.counter += 1; + const transaction: MykoboTransaction = { + created_at: new Date().toISOString(), + fee: this.depositFeeTotal, + id: `mykobo-tx-${this.counter}`, + incoming_currency: "EUR", + network: "BASE", + outgoing_currency: "EURC", + reference: `TESTREF${this.counter}`, + status: MykoboTransactionStatus.PENDING_PAYER, + transaction_type: request.transaction_type, + tx_hash: null, + updated_at: new Date().toISOString(), + value: request.value, + wallet_address: request.wallet_address + }; + this.transactions.set(transaction.id, transaction); + return { + instructions: + request.transaction_type === MykoboTransactionType.WITHDRAW + ? { address: this.withdrawReceivablesAddress } + : { bank_account_name: "Vortex Test Account", iban: "DE89370400440532013000" }, + transaction + }; + }, + defaultDepositFee: async (): Promise => ({ total: this.depositFeeTotal }), + defaultWithdrawFee: async (): Promise => ({ total: this.withdrawFeeTotal }), + getProfileByEmail: async (email: string): Promise => { + if (this.profileKycReviewStatus === null) { + throw new MykoboApiError(404, { error: "profile not found" }, "profile not found"); + } + return { + profile: { + bank_account_number: "DE89370400440532013000", + created_at: new Date().toISOString(), + email_address: email, + first_name: "Test", + kyc_status: { received_at: new Date().toISOString(), review_status: this.profileKycReviewStatus }, + last_name: "User" + } + }; + }, + getTransaction: async (transactionId: string): Promise => { + const transaction = this.transactions.get(transactionId); + if (!transaction) { + throw new Error(`FakeMykobo: unknown transaction ${transactionId}`); + } + return { transaction }; + }, + lookupFees: async (): Promise => ({ total: this.depositFeeTotal }) + }; + + asService(): MykoboApiService { + return unimplementedProxy(this.impl, "FakeMykobo"); + } +} + +/** + * Fake BRLA/Avenia anchor with an in-memory subaccount and generous limits. + * Quote responses apply a flat, scriptable BRL/USD-style rate. + */ +export class FakeBrla { + /** outputAmount = inputAmount * payInRate for pay-in quotes. */ + payInRate = 1; + payOutRate = 1; + subaccountId = "test-subaccount-id"; + subaccountEvmWallet = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; + /** Internal Avenia subaccount balances served by getAccountBalance; script per test. */ + accountBalances = { BRLA: 0, USDC: 0, USDM: 0, USDT: 0 }; + /** Status reported for every pay-in ticket by getAveniaPayinTickets. */ + payinTicketStatus: AveniaTicketStatus = AveniaTicketStatus.PAID; + /** Status reported for every payout ticket by getAveniaPayoutTicket. */ + payoutTicketStatus: AveniaTicketStatus = AveniaTicketStatus.PAID; + /** Called after createPixOutputTicket succeeds; use it to apply the on-chain mint effect. */ + onPixOutputTicket?: (ticket: { id: string; walletAddress?: string }) => void; + readonly pixInputTickets: Array<{ id: string; brCode: string }> = []; + readonly pixOutputTickets: Array<{ id: string }> = []; + private counter = 0; + + private readonly impl = { + createPayInQuote: async (quoteParams: { inputAmount: string }) => ({ + appliedFees: [], + basePrice: "1", + inputAmount: quoteParams.inputAmount, + inputCurrency: "BRL", + inputPaymentMethod: "PIX", + outputAmount: (Number(quoteParams.inputAmount) * this.payInRate).toString(), + quoteToken: `payin-quote-token-${++this.counter}` + }), + createPayOutQuote: async (quoteParams: { outputAmount: string }) => ({ + appliedFees: [], + basePrice: "1", + inputAmount: (Number(quoteParams.outputAmount) / this.payOutRate).toString(), + inputCurrency: "BRLA", + inputPaymentMethod: "INTERNAL", + outputAmount: quoteParams.outputAmount, + quoteToken: `payout-quote-token-${++this.counter}` + }), + createPixInputTicket: async () => { + const ticket = { + brCode: `brcode-${++this.counter}`, + expiration: new Date(Date.now() + 3600_000), + id: `pix-in-${this.counter}` + }; + this.pixInputTickets.push(ticket); + return ticket; + }, + createPixOutputTicket: async (payload?: { ticketBlockchainOutput?: { walletAddress?: string } }) => { + const ticket = { id: `pix-out-${++this.counter}` }; + this.pixOutputTickets.push(ticket); + this.onPixOutputTicket?.({ id: ticket.id, walletAddress: payload?.ticketBlockchainOutput?.walletAddress }); + return ticket; + }, + getAccountBalance: async () => ({ balances: { ...this.accountBalances } }), + getAveniaPayinTickets: async () => this.pixInputTickets.map(ticket => ({ id: ticket.id, status: this.payinTicketStatus })), + getAveniaPayoutTicket: async (ticketId: string) => ({ + id: ticketId, + status: this.payoutTicketStatus + }), + getSubaccountUsedLimit: async () => ({ + limitInfo: { + blocked: false, + createdAt: new Date().toISOString(), + limits: [ + { + currency: "BRL", + maxChainIn: "10000000", + maxChainOut: "10000000", + maxFiatIn: "10000000", + maxFiatOut: "10000000", + usedLimit: { usedChainIn: "0", usedChainOut: "0", usedFiatIn: "0", usedFiatOut: "0" } + } + ] + } + }), + subaccountInfo: async () => ({ + accountInfo: {}, + brCode: "test-brcode", + createdAt: new Date().toISOString(), + id: this.subaccountId, + pixKey: "test-pix-key", + wallets: [{ chain: "EVM", id: "wallet-1", walletAddress: this.subaccountEvmWallet }] + }), + validatePixKey: async () => ({ bankName: "Test Bank", name: "Test Receiver", taxId: "12345678900" }) + }; + + asService(): BrlaApiService { + return unimplementedProxy(this.impl, "FakeBrla"); + } +} + +/** + * Fake Alfredpay anchor. Onramp quotes apply a flat, scriptable rate; orders + * and transaction polling run against in-memory state. The status served by + * getOnrampTransaction is scripted through `onrampStatus`; the on-chain mint + * effect belongs in the test via `onCreateOnramp` (mirroring FakeBrla's + * onPixOutputTicket). Extend as Alfredpay corridors gain test coverage. + */ +export class FakeAlfredpay { + /** toAmount = fromAmount * onrampRate for onramp quotes. */ + onrampRate = 1; + /** Fees attached to every quote; the fee engine sums them per currency. */ + quoteFees: AlfredpayFee[] = []; + /** Status reported for every order by getOnrampTransaction. */ + onrampStatus: AlfredpayOnrampStatus = AlfredpayOnrampStatus.TRADE_COMPLETED; + onrampStatusMetadata: AlfredpayOnrampStatusMetadata | null = null; + /** Called after createOnramp succeeds; use it to apply the on-chain mint effect. */ + onCreateOnramp?: (order: { transactionId: string; depositAddress: string }) => void; + readonly onrampOrders: CreateAlfredpayOnrampRequest[] = []; + readonly transactions = new Map(); + /** toAmount = fromAmount * offrampRate for offramp quotes. */ + offrampRate = 1; + /** Status reported for every order by getOfframpTransaction. */ + offrampStatus: AlfredpayOfframpStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + /** Deposit address handed out for every offramp order. */ + offrampDepositAddress = "0x5afe00000000000000000000000000000000d0e5"; + readonly offrampOrders: CreateAlfredpayOfframpRequest[] = []; + readonly offrampTransactions = new Map(); + /** Accounts served by listFiatAccounts, keyed by Alfredpay customer id. */ + readonly fiatAccountsByCustomer = new Map(); + private counter = 0; + + /** + * Rail-realistic fiat payment instructions handed out per BUY currency + * (MXN: SPEI/CLABE, USD & COP: ACH bank fields, ARS: CBU). Tests can + * override entries to script other shapes. + */ + fiatPaymentInstructionsByCurrency: Record = { + ARS: { + accountHolderName: "Vortex Test Account", + bankAccountNumber: "2850590940090418135201", + bankName: "Banco de Prueba", + paymentType: "CBU", + reference: "VORTEX-TEST" + }, + COP: { + accountHolderName: "Vortex Test Account", + bankAccountNumber: "01234567890", + bankName: "Bancolombia de Prueba", + bankRoutingNumber: "007", + paymentType: "ACH", + reference: "VORTEX-TEST" + }, + MXN: { + clabe: "646180157000000004", + paymentType: "SPEI", + reference: "VORTEX-TEST" + }, + USD: { + accountHolderName: "Vortex Test Account", + bankAccountNumber: "000123456789", + bankName: "Test Bank USA", + bankRoutingNumber: "021000021", + paymentType: "ACH", + reference: "VORTEX-TEST" + } + }; + + private instructionsFor(currency: string): AlfredpayFiatPaymentInstructions { + const instructions = this.fiatPaymentInstructionsByCurrency[currency]; + if (!instructions) { + throw new Error( + `FakeAlfredpay: no fiatPaymentInstructions configured for ${currency} — extend fiatPaymentInstructionsByCurrency.` + ); + } + return { ...instructions }; + } + + private onrampQuote(request: CreateAlfredpayOnrampQuoteRequest): AlfredpayOnrampQuote { + const fromAmount = request.fromAmount ?? "0"; + return { + chain: request.chain, + expiration: new Date(Date.now() + 5 * 60_000).toISOString(), + fees: [...this.quoteFees], + fromAmount, + fromCurrency: request.fromCurrency, + metadata: {}, + paymentMethodType: request.paymentMethodType, + quoteId: `alfredpay-quote-${++this.counter}`, + rate: this.onrampRate.toString(), + toAmount: (Number(fromAmount) * this.onrampRate).toString(), + toCurrency: request.toCurrency + }; + } + + private offrampQuote(request: CreateAlfredpayOfframpQuoteRequest): AlfredpayOfframpQuote { + const fromAmount = request.fromAmount ?? "0"; + return { + chain: request.chain, + expiration: new Date(Date.now() + 5 * 60_000).toISOString(), + fees: [...this.quoteFees], + fromAmount, + fromCurrency: request.fromCurrency, + metadata: {}, + paymentMethodType: request.paymentMethodType, + quoteId: `alfredpay-offramp-quote-${++this.counter}`, + rate: this.offrampRate.toString(), + toAmount: (Number(fromAmount) * this.offrampRate).toString(), + toCurrency: request.toCurrency + }; + } + + private readonly impl = { + createOfframp: async (request: CreateAlfredpayOfframpRequest): Promise => { + this.offrampOrders.push(request); + const transactionId = `alfredpay-offramp-${++this.counter}`; + const now = new Date().toISOString(); + const transaction: AlfredpayOfframpTransaction = { + chain: request.chain, + createdAt: now, + customerId: request.customerId, + depositAddress: this.offrampDepositAddress, + expiration: new Date(Date.now() + 30 * 60_000).toISOString(), + fiatAccountId: request.fiatAccountId, + fromAmount: request.amount, + fromCurrency: request.fromCurrency, + memo: request.memo, + quote: this.offrampQuote({ + fromAmount: request.amount, + fromCurrency: request.fromCurrency, + metadata: { businessId: "vortex", customerId: request.customerId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: request.toCurrency + }), + quoteId: request.quoteId, + status: AlfredpayOfframpStatus.ON_CHAIN_DEPOSIT_RECEIVED, + toAmount: (Number(request.amount) * this.offrampRate).toString(), + toCurrency: request.toCurrency, + transactionId, + updatedAt: now + }; + this.offrampTransactions.set(transactionId, transaction); + return transaction; + }, + createOfframpQuote: async (request: CreateAlfredpayOfframpQuoteRequest): Promise => + this.offrampQuote(request), + createOnramp: async (request: CreateAlfredpayOnrampRequest): Promise => { + this.onrampOrders.push(request); + const transactionId = `alfredpay-onramp-${++this.counter}`; + const now = new Date().toISOString(); + const transaction: AlfredpayOnrampTransaction = { + chain: request.chain, + createdAt: now, + customerId: request.customerId, + depositAddress: request.depositAddress, + email: "test@example.com", + externalId: `external-${transactionId}`, + fromAmount: request.amount, + fromCurrency: request.fromCurrency, + memo: "", + metadata: null, + paymentMethodType: request.paymentMethodType, + quote: this.onrampQuote({ + fromAmount: request.amount, + fromCurrency: request.fromCurrency, + metadata: { businessId: "vortex", customerId: request.customerId }, + paymentMethodType: request.paymentMethodType, + toCurrency: request.toCurrency + }), + quoteId: request.quoteId, + status: AlfredpayOnrampStatus.CREATED, + toAmount: (Number(request.amount) * this.onrampRate).toString(), + toCurrency: request.toCurrency, + transactionId, + txHash: null, + updatedAt: now + }; + this.transactions.set(transactionId, transaction); + this.onCreateOnramp?.({ depositAddress: request.depositAddress, transactionId }); + return { fiatPaymentInstructions: this.instructionsFor(request.fromCurrency), transaction }; + }, + createOnrampQuote: async (request: CreateAlfredpayOnrampQuoteRequest): Promise => + this.onrampQuote(request), + getOfframpTransaction: async (transactionId: string): Promise => { + const transaction = this.offrampTransactions.get(transactionId); + if (!transaction) { + throw new Error(`FakeAlfredpay: unknown offramp transaction ${transactionId}`); + } + return { ...transaction, status: this.offrampStatus }; + }, + getOnrampTransaction: async (transactionId: string): Promise => { + const transaction = this.transactions.get(transactionId); + if (!transaction) { + throw new Error(`FakeAlfredpay: unknown onramp transaction ${transactionId}`); + } + return { + ...transaction, + fiatPaymentInstructions: this.instructionsFor(transaction.fromCurrency), + metadata: this.onrampStatusMetadata, + status: this.onrampStatus + }; + }, + listFiatAccounts: async (customerId: string): Promise => + this.fiatAccountsByCustomer.get(customerId) ?? [] + }; + + asService(): AlfredpayApiService { + return unimplementedProxy(this.impl, "FakeAlfredpay"); + } +} + +export function installFakeAnchors(): { + fakeMykobo: FakeMykobo; + fakeBrla: FakeBrla; + fakeAlfredpay: FakeAlfredpay; + restore: () => void; +} { + const originals = { + alfredpay: AlfredpayApiService.getInstance, + brla: BrlaApiService.getInstance, + mykobo: MykoboApiService.getInstance + }; + + const fakeMykobo = new FakeMykobo(); + const fakeBrla = new FakeBrla(); + const fakeAlfredpay = new FakeAlfredpay(); + + MykoboApiService.getInstance = () => fakeMykobo.asService(); + BrlaApiService.getInstance = () => fakeBrla.asService(); + AlfredpayApiService.getInstance = () => fakeAlfredpay.asService(); + + return { + fakeAlfredpay, + fakeBrla, + fakeMykobo, + restore: () => { + MykoboApiService.getInstance = originals.mykobo; + BrlaApiService.getInstance = originals.brla; + AlfredpayApiService.getInstance = originals.alfredpay; + } + }; +} diff --git a/apps/api/src/test-utils/fake-world/fake-auth.ts b/apps/api/src/test-utils/fake-world/fake-auth.ts new file mode 100644 index 000000000..77a09079b --- /dev/null +++ b/apps/api/src/test-utils/fake-world/fake-auth.ts @@ -0,0 +1,101 @@ +import { RefreshTokenError, SupabaseAuthService } from "../../api/services/auth"; + +const TOKEN_PREFIX = "test-user:"; +const REFRESH_PREFIX = "test-refresh:"; +/** The one-time code the fake OTP flow accepts after sendOTP was called for the email. */ +export const TEST_OTP_CODE = "123456"; + +/** Returns a Bearer token the fake verifier accepts for the given user id. */ +export function testUserToken(userId: string, email = "user@example.com"): string { + return `${TOKEN_PREFIX}${userId}:${email}`; +} + +export interface FakeSupabaseAuth { + /** Emails checkUserExists reports as existing accounts. */ + readonly existingEmails: Set; + /** Emails an OTP was sent to (in order). */ + readonly otpRequests: string[]; + restore: () => void; +} + +/** + * Replaces the Supabase auth surface with a local in-memory flow — no Supabase + * calls. Tokens minted by testUserToken() are valid; the email/OTP login + * accepts TEST_OTP_CODE for any email that requested one and mints tokens for + * the deterministic user id `otp-user-`. + */ +export function installFakeSupabaseAuth(): FakeSupabaseAuth { + const originals = { + checkUserExists: SupabaseAuthService.checkUserExists, + refreshToken: SupabaseAuthService.refreshToken, + sendOTP: SupabaseAuthService.sendOTP, + verifyOTP: SupabaseAuthService.verifyOTP, + verifyToken: SupabaseAuthService.verifyToken + }; + + const existingEmails = new Set(); + const otpRequests: string[] = []; + const pendingOtps = new Set(); + // User ids are UUID columns; keep them stable per email across logins. + const userIdsByEmail = new Map(); + const userIdFor = (email: string) => { + let id = userIdsByEmail.get(email); + if (!id) { + id = crypto.randomUUID(); + userIdsByEmail.set(email, id); + } + return id; + }; + + SupabaseAuthService.verifyToken = async (accessToken: string) => { + if (!accessToken.startsWith(TOKEN_PREFIX)) { + return { valid: false }; + } + const [userId, email] = accessToken.slice(TOKEN_PREFIX.length).split(":"); + return { email, user_id: userId, valid: true }; + }; + + SupabaseAuthService.checkUserExists = async (email: string) => existingEmails.has(email); + + SupabaseAuthService.sendOTP = async (email: string) => { + otpRequests.push(email); + pendingOtps.add(email); + }; + + SupabaseAuthService.verifyOTP = async (email: string, token: string) => { + if (!pendingOtps.has(email) || token !== TEST_OTP_CODE) { + throw new Error("FakeSupabaseAuth: invalid OTP"); + } + pendingOtps.delete(email); + existingEmails.add(email); + const userId = userIdFor(email); + return { + access_token: testUserToken(userId, email), + refresh_token: `${REFRESH_PREFIX}${userId}:${email}`, + user_id: userId + }; + }; + + SupabaseAuthService.refreshToken = async (refreshToken: string) => { + if (!refreshToken.startsWith(REFRESH_PREFIX)) { + throw new RefreshTokenError("FakeSupabaseAuth: invalid refresh token", false); + } + const [userId, email] = refreshToken.slice(REFRESH_PREFIX.length).split(":"); + return { + access_token: testUserToken(userId, email), + refresh_token: `${REFRESH_PREFIX}${userId}:${email}` + }; + }; + + return { + existingEmails, + otpRequests, + restore: () => { + SupabaseAuthService.verifyToken = originals.verifyToken; + SupabaseAuthService.checkUserExists = originals.checkUserExists; + SupabaseAuthService.sendOTP = originals.sendOTP; + SupabaseAuthService.verifyOTP = originals.verifyOTP; + SupabaseAuthService.refreshToken = originals.refreshToken; + } + }; +} diff --git a/apps/api/src/test-utils/fake-world/fake-evm.ts b/apps/api/src/test-utils/fake-world/fake-evm.ts new file mode 100644 index 000000000..a4733824b --- /dev/null +++ b/apps/api/src/test-utils/fake-world/fake-evm.ts @@ -0,0 +1,241 @@ +import { EvmClientManager, type EvmNetworks } from "@vortexfi/shared"; + +export interface RecordedEvmTx { + network: string; + from?: string; + to?: string; + data?: string; + value?: bigint; + serialized?: string; + hash: `0x${string}`; +} + +interface ReadContractParams { + abi: readonly unknown[]; + address: `0x${string}`; + functionName: string; + args?: readonly unknown[]; +} + +const CHAIN_IDS: Record = { + arbitrum: 42161, + avalanche: 43114, + base: 8453, + "base-sepolia": 84532, + bsc: 56, + ethereum: 1, + moonbeam: 1284, + polygon: 137, + polygonAmoy: 80002 +}; + +const MAX_UINT256 = 2n ** 256n - 1n; + +/** + * In-memory EVM world standing in for EvmClientManager. Balances are a simple + * ledger keyed by network/token/holder; transactions are recorded, assigned a + * deterministic hash, and confirmed instantly. Behavior that a test cares + * about (swap rates, transfer effects, failures) is scripted via the public + * hooks. + */ +export class FakeEvm { + private balances = new Map(); + private nonces = new Map(); + private txCounter = 0; + readonly sentTransactions: RecordedEvmTx[] = []; + private readonly transactionsByHash = new Map(); + + /** Called for every recorded transaction; use it to apply balance effects. */ + onTransaction?: (tx: RecordedEvmTx) => void; + /** First chance to answer any readContract call; return undefined to fall through to defaults. */ + onReadContract?: (network: string, params: ReadContractParams) => unknown; + /** Nabla router getAmountOut. Default: same-decimals 1:1.05. */ + onGetAmountOut: (network: string, routerAddress: string, amountIn: bigint) => bigint = (_n, _r, amountIn) => + (amountIn * 105n) / 100n; + /** Reverts the next `failNextSends` transaction submissions with the given error. */ + failNextSends = 0; + sendFailureMessage = "FakeEvm: scripted transaction failure"; + + private key(network: string, token: string, holder: string): string { + return `${network}:${token.toLowerCase()}:${holder.toLowerCase()}`; + } + + setErc20Balance(network: string, token: string, holder: string, amount: bigint): void { + this.balances.set(this.key(network, token, holder), amount); + } + + erc20Balance(network: string, token: string, holder: string): bigint { + return this.balances.get(this.key(network, token, holder)) ?? 0n; + } + + setNativeBalance(network: string, holder: string, amount: bigint): void { + this.balances.set(this.key(network, "native", holder), amount); + } + + nativeBalance(network: string, holder: string): bigint { + return this.balances.get(this.key(network, "native", holder)) ?? 0n; + } + + /** + * Records a transaction as if a user wallet had broadcast it (outside the + * EvmClientManager seam) and returns its hash — for corridors where the + * backend verifies an integrator-reported hash against a blueprint. + */ + broadcastUserTransaction(network: string, from: string, tx: { to: string; data?: string; value?: bigint }): `0x${string}` { + return this.recordTransaction({ data: tx.data, from, network, to: tx.to, value: tx.value }); + } + + private nextHash(): `0x${string}` { + this.txCounter += 1; + return `0x${this.txCounter.toString(16).padStart(64, "0")}` as `0x${string}`; + } + + private recordTransaction(tx: Omit): `0x${string}` { + if (this.failNextSends > 0) { + this.failNextSends -= 1; + throw new Error(this.sendFailureMessage); + } + const recorded = { ...tx, hash: this.nextHash() }; + this.sentTransactions.push(recorded); + this.transactionsByHash.set(recorded.hash, recorded); + this.onTransaction?.(recorded); + return recorded.hash; + } + + private readContract(network: string, params: ReadContractParams): unknown { + const custom = this.onReadContract?.(network, params); + if (custom !== undefined) { + return custom; + } + switch (params.functionName) { + case "balanceOf": + return this.erc20Balance(network, params.address, params.args?.[0] as string); + case "allowance": + return MAX_UINT256; + case "getAmountOut": + return this.onGetAmountOut(network, params.address, params.args?.[0] as bigint); + default: + throw new Error( + `FakeEvm: readContract '${params.functionName}' on ${network} is not implemented — ` + + "script it via fakeEvm.onReadContract in the test." + ); + } + } + + private makeUnimplementedProxy(target: Record, label: string): unknown { + return new Proxy(target, { + get: (obj, prop) => { + if (prop in obj) { + return obj[prop as string]; + } + if (prop === "then") { + return undefined; + } + throw new Error(`FakeEvm: ${label}.${String(prop)} is not implemented — extend src/test-utils/fake-world/fake-evm.ts.`); + } + }); + } + + // --- EvmClientManager surface used by the API --- + + getClient(networkName: EvmNetworks): unknown { + const network = networkName as string; + // Receipts for recorded transactions carry from/to so verification code + // (e.g. user-tx-verifier) can cross-check them; unknown hashes still + // confirm generically for recovery paths that probe stored hashes. + const receipt = (hash: `0x${string}`) => { + const recorded = this.transactionsByHash.get(hash); + return { + blockNumber: 1n, + from: recorded?.from, + logs: [], + status: "success" as const, + to: recorded?.to, + transactionHash: hash + }; + }; + return this.makeUnimplementedProxy( + { + // Dry-runs (eth_call) succeed generically; scripted failures go through failNextSends instead. + call: async () => ({ data: "0x" as `0x${string}` }), + chain: { id: CHAIN_IDS[network] ?? 0, name: network, nativeCurrency: { decimals: 18, name: "Ether", symbol: "ETH" } }, + estimateFeesPerGas: async () => ({ maxFeePerGas: 1_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n }), + estimateGas: async () => 21_000n, + getBalance: async ({ address }: { address: string }) => this.nativeBalance(network, address), + getGasPrice: async () => 1_000_000_000n, + getTransaction: async ({ hash }: { hash: `0x${string}` }) => { + const recorded = this.transactionsByHash.get(hash); + if (!recorded) { + throw new Error(`FakeEvm: getTransaction called with unknown hash ${hash}`); + } + return { + from: recorded.from, + hash, + input: recorded.data ?? "0x", + to: recorded.to, + value: recorded.value ?? 0n + }; + }, + getTransactionCount: async ({ address }: { address: string }) => this.nonces.get(`${network}:${address}`) ?? 0, + getTransactionReceipt: async ({ hash }: { hash: `0x${string}` }) => receipt(hash), + readContract: async (params: ReadContractParams) => this.readContract(network, params), + sendRawTransaction: async ({ serializedTransaction }: { serializedTransaction: string }) => + this.recordTransaction({ network, serialized: serializedTransaction }), + waitForTransactionReceipt: async ({ hash }: { hash: `0x${string}` }) => receipt(hash) + }, + `PublicClient(${network})` + ); + } + + getWalletClient(networkName: EvmNetworks, account: { address: string }): unknown { + const network = networkName as string; + return this.makeUnimplementedProxy( + { + account, + sendTransaction: async (params: { to?: string; data?: string; value?: bigint }) => + this.recordTransaction({ data: params.data, from: account.address, network, to: params.to, value: params.value }), + writeContract: async (params: { address: string; functionName: string }) => + this.recordTransaction({ data: params.functionName, from: account.address, network, to: params.address }) + }, + `WalletClient(${network})` + ); + } + + async readContractWithRetry(networkName: EvmNetworks, contractParams: ReadContractParams): Promise { + return this.readContract(networkName as string, contractParams) as T; + } + + async getBalanceWithRetry(networkName: EvmNetworks, address: `0x${string}`): Promise { + return this.nativeBalance(networkName as string, address); + } + + async sendRawTransactionWithRetry(networkName: EvmNetworks, serializedTransaction: `0x${string}`): Promise { + return this.recordTransaction({ network: networkName as string, serialized: serializedTransaction }); + } + + async sendTransactionWithBlindRetry( + networkName: EvmNetworks, + account: { address: string }, + transactionParams: { data?: `0x${string}`; to: `0x${string}`; value?: bigint } + ): Promise<`0x${string}`> { + return this.recordTransaction({ + data: transactionParams.data, + from: account.address, + network: networkName as string, + to: transactionParams.to, + value: transactionParams.value + }); + } +} + +export function installFakeEvm(): { fakeEvm: FakeEvm; restore: () => void } { + const original = EvmClientManager.getInstance; + const fakeEvm = new FakeEvm(); + EvmClientManager.getInstance = () => fakeEvm as unknown as EvmClientManager; + return { + fakeEvm, + restore: () => { + EvmClientManager.getInstance = original; + } + }; +} diff --git a/apps/api/src/test-utils/fake-world/fake-prices.ts b/apps/api/src/test-utils/fake-world/fake-prices.ts new file mode 100644 index 000000000..3825b5fba --- /dev/null +++ b/apps/api/src/test-utils/fake-world/fake-prices.ts @@ -0,0 +1,87 @@ +import type { RampCurrency } from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../api/services/priceFeed.service"; + +/** + * Deterministic price world. Patches the exported priceFeedService instance + * (the object every caller imports) rather than the class. Unknown lookups + * throw so a test never computes fees from an accidental default. + */ +export class FakePrices { + /** CoinGecko-style token id → USD price. */ + cryptoUsd: Record = { + ethereum: 2500, + moonbeam: 0.08, + "polygon-ecosystem-token": 0.5, + "usd-coin": 1 + }; + /** Fiat/RampCurrency code (lowercased) → units of that currency per 1 USD. */ + perUsd: Record = { + ars: 1000, + brl: 5, + // BRLA is the on-chain twin of BRL and shares its peg. + brla: 5, + cop: 4000, + eur: 0.9, + // Consistent with cryptoUsd["polygon-ecosystem-token"] = 0.5. + matic: 2, + mxn: 17, + usd: 1, + usdc: 1, + "usdc.e": 1, + usdt: 1 + }; + + getCryptoUsd(tokenId: string): number { + const price = this.cryptoUsd[tokenId]; + if (price === undefined) { + throw new Error(`FakePrices: no USD price for token id '${tokenId}' — set fakePrices.cryptoUsd['${tokenId}'].`); + } + return price; + } + + getPerUsd(currency: string): number { + const rate = this.perUsd[currency.toLowerCase()]; + if (rate === undefined) { + throw new Error(`FakePrices: no per-USD rate for '${currency}' — set fakePrices.perUsd['${currency.toLowerCase()}'].`); + } + return rate; + } +} + +type PatchedMethods = "getCryptoPrice" | "getUsdToFiatExchangeRate" | "convertCurrency" | "getOnchainOraclePrice"; + +export function installFakePrices(): { fakePrices: FakePrices; restore: () => void } { + const fakePrices = new FakePrices(); + const originals: Partial> = { + convertCurrency: priceFeedService.convertCurrency, + getCryptoPrice: priceFeedService.getCryptoPrice, + getOnchainOraclePrice: priceFeedService.getOnchainOraclePrice, + getUsdToFiatExchangeRate: priceFeedService.getUsdToFiatExchangeRate + }; + + priceFeedService.getCryptoPrice = async (tokenId: string) => fakePrices.getCryptoUsd(tokenId); + priceFeedService.getUsdToFiatExchangeRate = async (toCurrency: RampCurrency) => fakePrices.getPerUsd(toCurrency as string); + priceFeedService.convertCurrency = async ( + amount: string, + fromCurrency: RampCurrency, + toCurrency: RampCurrency, + decimals?: number | null + ) => { + const usd = new Big(amount).div(fakePrices.getPerUsd(fromCurrency as string)); + const converted = usd.times(fakePrices.getPerUsd(toCurrency as string)); + return decimals != null ? converted.toFixed(decimals, 0) : converted.toString(); + }; + priceFeedService.getOnchainOraclePrice = async (currency: RampCurrency) => ({ + lastUpdateTimestamp: Date.now(), + name: currency as string, + price: new Big(1).div(fakePrices.getPerUsd(currency as string)) + }); + + return { + fakePrices, + restore: () => { + Object.assign(priceFeedService, originals); + } + }; +} diff --git a/apps/api/src/test-utils/fake-world/fake-squidrouter.ts b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts new file mode 100644 index 000000000..2b73995ce --- /dev/null +++ b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts @@ -0,0 +1,79 @@ +import { mock } from "bun:test"; +import type { RouteParams } from "@vortexfi/shared"; +import * as shared from "@vortexfi/shared"; + +/** + * Fake SquidRouter route source. getRoute is a plain function export of + * @vortexfi/shared (not a singleton), so it is replaced via mock.module with + * the rest of the package passed through untouched. + */ +export class FakeSquidRouter { + /** Native value attached to the route tx (wei); drives the derived network fee. */ + transactionValueWei = "1000000000000000"; + /** Router contract the route's swap tx calls; approve blueprints approve this spender. */ + transactionTarget = "0x00000000000000000000000000000000005a11d0"; + /** Calldata of the route's swap tx (opaque to the corridor code; only echoed back). */ + transactionData = "0x5a11d0000000000000000000000000000000000000000000000000000000000000cafe"; + transactionGasLimit = "500000"; + /** Raw destination amount for a requested route. Default: 1:1 with the input. */ + computeToAmount: (params: RouteParams) => string = params => params.fromAmount; + toTokenDecimals = 18; + failNextRoute: Error | null = null; + readonly requestedRoutes: RouteParams[] = []; + /** Status the squidRouterPay bridge poll reports. Default: immediate success. */ + bridgeStatus = "success"; + + async getRoute(params: RouteParams) { + if (this.failNextRoute) { + const error = this.failNextRoute; + this.failNextRoute = null; + throw error; + } + this.requestedRoutes.push(params); + return { + data: { + route: { + estimate: { + toAmount: this.computeToAmount(params), + toToken: { decimals: this.toTokenDecimals } + }, + transactionRequest: { + data: this.transactionData, + gasLimit: this.transactionGasLimit, + target: this.transactionTarget, + value: this.transactionValueWei + } + } + }, + requestId: "fake-squid-request" + }; + } + + /** Fake of the shared getStatus (SquidRouter status API) used by squidRouterPay. */ + async getStatus() { + return { + id: "fake-squid-status", + isGMPTransaction: false, + routeStatus: [], + squidTransactionStatus: this.bridgeStatus, + status: this.bridgeStatus + }; + } +} + +export function installFakeSquidRouter(): { fakeSquidRouter: FakeSquidRouter; restore: () => void } { + const fakeSquidRouter = new FakeSquidRouter(); + + mock.module("@vortexfi/shared", () => ({ + ...shared, + getRoute: (params: RouteParams) => fakeSquidRouter.getRoute(params), + getStatus: () => fakeSquidRouter.getStatus() + })); + + return { + fakeSquidRouter, + restore: () => { + mock.module("@vortexfi/shared", () => ({ ...shared })); + } + }; +} diff --git a/apps/api/src/test-utils/fake-world/fetch-guard.ts b/apps/api/src/test-utils/fake-world/fetch-guard.ts new file mode 100644 index 000000000..ba431afc5 --- /dev/null +++ b/apps/api/src/test-utils/fake-world/fetch-guard.ts @@ -0,0 +1,39 @@ +/** + * Replaces global fetch with a guard that only allows loopback traffic + * (the in-process test app). Any other HTTP call is a hermeticity violation + * and fails with a descriptive error instead of silently reaching a real + * service. + */ +let originalFetch: typeof fetch | null = null; + +const LOOPBACK_PATTERN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/; + +export function installFetchGuard(): void { + if (originalFetch) { + return; + } + const realFetch = globalThis.fetch; + originalFetch = realFetch; + + const guard = ((input: Parameters[0], init?: Parameters[1]) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (!/^https?:\/\//.test(url) || LOOPBACK_PATTERN.test(url)) { + return realFetch(input, init); + } + return Promise.reject( + new Error( + `Hermetic test violation: attempted external HTTP call to ${url}. ` + + "Extend the fakes in src/test-utils/fake-world instead of letting code reach the network." + ) + ); + }) as typeof fetch; + + globalThis.fetch = Object.assign(guard, realFetch); +} + +export function uninstallFetchGuard(): void { + if (originalFetch) { + globalThis.fetch = originalFetch; + originalFetch = null; + } +} diff --git a/apps/api/src/test-utils/fake-world/index.ts b/apps/api/src/test-utils/fake-world/index.ts new file mode 100644 index 000000000..4f01d0e76 --- /dev/null +++ b/apps/api/src/test-utils/fake-world/index.ts @@ -0,0 +1,113 @@ +import { ApiManager } from "@vortexfi/shared"; +import { type FakeAlfredpay, type FakeBrla, type FakeMykobo, installFakeAnchors } from "./fake-anchors"; +import { type FakeEvm, installFakeEvm } from "./fake-evm"; +import { type FakePrices, installFakePrices } from "./fake-prices"; +import { type FakeSquidRouter, installFakeSquidRouter } from "./fake-squidrouter"; +import { installFetchGuard, uninstallFetchGuard } from "./fetch-guard"; + +export type { FakeAlfredpay, FakeBrla, FakeEvm, FakeMykobo, FakePrices, FakeSquidRouter }; +export { installFetchGuard, uninstallFetchGuard }; + +export interface FakeWorld { + evm: FakeEvm; + mykobo: FakeMykobo; + brla: FakeBrla; + alfredpay: FakeAlfredpay; + prices: FakePrices; + squidRouter: FakeSquidRouter; + restore: () => void; +} + +/** + * Replaces every external boundary of the API with deterministic in-memory + * fakes and installs the fetch guard so nothing can slip through to a real + * service. Call once in beforeAll and restore() in afterAll — bun runs all + * test files in one process, so leaked patches bleed into other files. + */ +export function installFakeWorld(): FakeWorld { + installFetchGuard(); + const { fakeEvm, restore: restoreEvm } = installFakeEvm(); + const { fakeAlfredpay, fakeBrla, fakeMykobo, restore: restoreAnchors } = installFakeAnchors(); + const { fakePrices, restore: restorePrices } = installFakePrices(); + const { fakeSquidRouter, restore: restoreSquidRouter } = installFakeSquidRouter(); + + // Substrate/Pendulum flows are not faked yet; fail loudly if a code path + // unexpectedly needs them so the gap is explicit rather than a hang. + // Exceptions: getApi succeeds and returns an inert node, because handlers like + // fundEphemeral resolve the Pendulum API unconditionally even on corridors + // that never touch Substrate — only actual USE of the node should fail. The + // one supported use is `api.query.system.account`, which the registration + // freshness check reads for Substrate ephemerals (SDK clients always send + // one): every account is reported fresh (nonce 0, zero balance). + const substrateNodeUnfaked = (member: string) => + new Error( + `FakeWorld: Substrate node .${member} was used but Substrate chains are not faked yet — ` + + "extend src/test-utils/fake-world if this flow must be covered hermetically." + ); + const freshSubstrateApi = new Proxy( + { + query: { system: { account: async () => ({ data: { free: "0" }, nonce: { toNumber: () => 0 } }) } } + }, + { + get: (obj, prop) => { + if (prop in obj) { + return obj[prop as keyof typeof obj]; + } + if (prop === "then") { + return undefined; + } + throw substrateNodeUnfaked(`api.${String(prop)}`); + } + } + ); + const originalGetApiManager = ApiManager.getInstance; + ApiManager.getInstance = () => + new Proxy( + {}, + { + get: (_obj, prop) => { + if (prop === "then") { + return undefined; + } + if (prop === "getApi") { + return async () => + new Proxy( + { api: freshSubstrateApi }, + { + get: (node, nodeProp) => { + if (nodeProp in node) { + return node[nodeProp as keyof typeof node]; + } + if (nodeProp === "then") { + return undefined; + } + throw substrateNodeUnfaked(String(nodeProp)); + } + } + ); + } + throw new Error( + `FakeWorld: ApiManager.${String(prop)} was called but Substrate chains are not faked yet — ` + + "extend src/test-utils/fake-world if this flow must be covered hermetically." + ); + } + } + ) as unknown as ApiManager; + + return { + alfredpay: fakeAlfredpay, + brla: fakeBrla, + evm: fakeEvm, + mykobo: fakeMykobo, + prices: fakePrices, + restore: () => { + ApiManager.getInstance = originalGetApiManager; + restoreSquidRouter(); + restorePrices(); + restoreAnchors(); + restoreEvm(); + uninstallFetchGuard(); + }, + squidRouter: fakeSquidRouter + }; +} diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts new file mode 100644 index 000000000..480671402 --- /dev/null +++ b/apps/api/src/test-utils/preload.ts @@ -0,0 +1,71 @@ +/** + * Test environment safety net, loaded before every test file via bunfig.toml. + * + * Hermetic by default: points the database at the dedicated test instance and + * replaces any real integration credentials (which bun auto-loads from .env) + * with sentinel values so no test can accidentally reach a real external + * service. Live tests opt out with RUN_LIVE_TESTS=1 and keep the real env. + */ +if (!process.env.RUN_LIVE_TESTS) { + process.env.NODE_ENV = "test"; + process.env.DEPLOYMENT_ENV = "test"; + process.env.FLOW_VARIANT = process.env.FLOW_VARIANT || "mykobo"; + + // Dedicated test database (started via `bun test:db:start`), never the dev database. + process.env.DB_HOST = process.env.TEST_DB_HOST || "localhost"; + process.env.DB_PORT = process.env.TEST_DB_PORT || "54329"; + process.env.DB_NAME = process.env.TEST_DB_NAME || "vortex_test"; + process.env.DB_USERNAME = process.env.TEST_DB_USERNAME || "postgres"; + process.env.DB_PASSWORD = process.env.TEST_DB_PASSWORD || "postgres"; + + // Neutralize integration credentials/endpoints. The .invalid TLD is reserved + // (RFC 2606), so any un-faked call fails fast instead of hitting production. + process.env.MYKOBO_BASE_URL = "http://mykobo.invalid"; + process.env.MYKOBO_ACCESS_KEY = "test-mykobo-access-key"; + process.env.MYKOBO_SECRET_KEY = "test-mykobo-secret-key"; + process.env.BRLA_BASE_URL = "http://brla.invalid"; + process.env.BRLA_API_KEY = "test-brla-api-key"; + process.env.BRLA_PRIVATE_KEY = ""; + process.env.ALFREDPAY_BASE_URL = "http://alfredpay.invalid"; + process.env.ALFREDPAY_API_KEY = "test-alfredpay-api-key"; + process.env.ALFREDPAY_API_SECRET = "test-alfredpay-api-secret"; + // COINGECKO_API_URL is deliberately NOT overridden: priceFeed config tests + // assert its default, and the fetch guard blocks real calls anyway. + process.env.ALCHEMY_API_KEY = ""; + process.env.SUPABASE_URL = "http://supabase.invalid"; + process.env.SUPABASE_ANON_KEY = "test-anon-key"; + process.env.SUPABASE_SERVICE_KEY = "test-service-key"; + process.env.ADMIN_SECRET = "test-admin-secret"; + process.env.METRICS_DASHBOARD_SECRET = "test-metrics-secret"; + // Empty → cryptoService generates a throwaway RSA pair; a real operator key + // in a local .env must never sign test webhook deliveries. + process.env.WEBHOOK_PRIVATE_KEY = ""; + + // Dummy signing keys (well-known dev keys, no real funds) so code that + // derives accounts works without ever using the operator's real seeds. + process.env.MOONBEAM_EXECUTOR_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + process.env.EVM_FUNDING_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + process.env.PENDULUM_FUNDING_SEED = "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; + process.env.FUNDING_SECRET = ""; + process.env.CLIENT_DOMAIN_SECRET = ""; + + // Keep rate limiting out of the way of HTTP-level tests. + process.env.RATE_LIMIT_MAX_REQUESTS = "100000"; + + // Fast retries so recoverable-error scenarios don't wait 30s per attempt. + process.env.PHASE_PROCESSOR_RETRY_DELAY_MS = "25"; + // The fake EVM ledger settles instantly; skip the 15s settlement waits and + // the 20s backoffs between scripted transaction failures. + process.env.SUBSIDY_SETTLEMENT_DELAY_MS = "25"; + process.env.PHASE_SETTLEMENT_RETRY_BACKOFF_MS = "25"; + + // Close the shared Sequelize pool after the whole run so lingering pg + // connections don't surface as unhandled "Connection terminated" errors. + const { afterAll } = await import("bun:test"); + afterAll(async () => { + const { default: sequelize } = await import("../config/database"); + await sequelize.close().catch(() => undefined); + }); +} + +export {}; diff --git a/apps/api/src/test-utils/test-app.ts b/apps/api/src/test-utils/test-app.ts new file mode 100644 index 000000000..fa4df8843 --- /dev/null +++ b/apps/api/src/test-utils/test-app.ts @@ -0,0 +1,40 @@ +import type { Server } from "node:http"; + +export interface TestApp { + baseUrl: string; + /** fetch against the in-process API, e.g. api.request("/v1/status") */ + request(path: string, init?: RequestInit): Promise; + close(): Promise; +} + +/** + * Boots the real Express app in-process on an ephemeral port. + * + * Deliberately does NOT run src/index.ts: no background workers, no chain + * clients, no crypto-service init. Phase handlers are registered so ramp + * routes work. Call AFTER installing fakes so module singletons resolve to + * the fake world. + */ +export async function startTestApp(): Promise { + const { default: app } = await import("../config/express"); + const { default: registerPhaseHandlers } = await import("../api/services/phases/register-handlers"); + registerPhaseHandlers(); + + const server: Server = await new Promise(resolve => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("Test server did not bind to a port"); + } + const baseUrl = `http://127.0.0.1:${address.port}`; + + return { + baseUrl, + close: () => + new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + }), + request: (path, init) => fetch(`${baseUrl}${path}`, init) + }; +} diff --git a/apps/api/src/tests/aaa-leak-probe.test.ts b/apps/api/src/tests/aaa-leak-probe.test.ts new file mode 100644 index 000000000..fcb45c855 --- /dev/null +++ b/apps/api/src/tests/aaa-leak-probe.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; +import { ApiManager, BrlaApiService, EvmClientManager, MykoboApiService } from "@vortexfi/shared"; +import QuoteTicket from "../models/quoteTicket.model"; +import RampState from "../models/rampState.model"; + +/** + * Leak canary: asserts that the process-wide seams other test files patch + * (model statics, service singletons, global fetch) are pristine when this + * file runs. The `aaa-` prefix makes it the first file in src/tests/, i.e. + * after every src/api unit test in bun's discovery order — a patch that was + * not restored (or not gated behind RUN_LIVE_TESTS) fails here with a message + * naming the leaked seam instead of poisoning the integration suites below. + */ +describe("leak canary: no test file leaked a singleton patch", () => { + it("model statics are the real Sequelize implementations", () => { + for (const [name, fn] of [ + ["QuoteTicket.findByPk", QuoteTicket.findByPk], + ["QuoteTicket.update", QuoteTicket.update], + ["RampState.findByPk", RampState.findByPk], + ["RampState.update", RampState.update] + ] as const) { + // bun:test mock() functions carry a `.mock` call-tracking property. + expect((fn as unknown as { mock?: unknown }).mock, `${name} is a leftover bun mock`).toBeUndefined(); + } + }); + + it("service singleton accessors are the real static methods", () => { + // The fake world replaces these with anonymous arrows; the real static + // methods keep their declared names. + expect(EvmClientManager.getInstance.name, "EvmClientManager.getInstance was left faked").toBe("getInstance"); + expect(BrlaApiService.getInstance.name, "BrlaApiService.getInstance was left faked").toBe("getInstance"); + expect(MykoboApiService.getInstance.name, "MykoboApiService.getInstance was left faked").toBe("getInstance"); + expect(ApiManager.getInstance.name, "ApiManager.getInstance was left faked").toBe("getInstance"); + }); + + it("global fetch is not a leftover fetch guard", () => { + expect(globalThis.fetch.name, "the fetch guard was left installed").toBe("fetch"); + }); +}); diff --git a/apps/api/src/tests/auth.invariants.test.ts b/apps/api/src/tests/auth.invariants.test.ts new file mode 100644 index 000000000..1fe483a4c --- /dev/null +++ b/apps/api/src/tests/auth.invariants.test.ts @@ -0,0 +1,246 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { FiatToken, RampDirection } from "@vortexfi/shared"; +import { installFakeWorld, type FakeWorld } from "../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../test-utils/fake-world/fake-auth"; +import { setupTestDatabase, truncateAllTables } from "../test-utils/db"; +import { createTestApiKey, createTestPartner, createTestQuote, createTestRampState, createTestUser } from "../test-utils/factories"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +/** + * HTTP-level auth and ownership invariants, derived from + * docs/security-spec/01-auth and 03-ramp-engine. Every test drives the real + * Express app against the real (test) database. + */ +describe("auth and ownership invariants", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + const SIGNING_ACCOUNTS = [{ address: "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3", type: "EVM" }]; + + const register = (body: object, headers: Record = {}) => + app.request("/v1/ramp/register", { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json", ...headers }, + method: "POST" + }); + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await truncateAllTables(); + }); + + describe("POST /v1/ramp/register credential checks", () => { + const body = { quoteId: crypto.randomUUID(), signingAccounts: SIGNING_ACCOUNTS }; + + it("rejects requests without credentials", async () => { + const response = await register(body); + expect(response.status).toBe(401); + }); + + it("rejects an invalid bearer token", async () => { + const response = await register(body, { Authorization: "Bearer not-a-real-token" }); + expect(response.status).toBe(401); + }); + + it("rejects a malformed API key", async () => { + const response = await register(body, { "X-API-Key": "sk_garbage" }); + expect(response.status).toBe(401); + const payload = (await response.json()) as { error: { code: string } }; + expect(payload.error.code).toBe("INVALID_SECRET_KEY"); + }); + + it("rejects a well-formed but unknown API key", async () => { + const response = await register(body, { + "X-API-Key": "sk_test_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }); + expect(response.status).toBe(401); + const payload = (await response.json()) as { error: { code: string } }; + expect(payload.error.code).toBe("INVALID_API_KEY"); + }); + + it("rejects a revoked API key", async () => { + const user = await createTestUser(); + const { record, plaintextKey } = await createTestApiKey({ userId: user.id }); + await record.update({ isActive: false }); + + const response = await register(body, { "X-API-Key": plaintextKey }); + expect(response.status).toBe(401); + }); + + it("rejects an expired API key", async () => { + const user = await createTestUser(); + const { record, plaintextKey } = await createTestApiKey({ userId: user.id }); + await record.update({ expiresAt: new Date(Date.now() - 1000) }); + + const response = await register(body, { "X-API-Key": plaintextKey }); + expect(response.status).toBe(401); + }); + + it("lets a valid user token past auth (404 for unknown quote, not 401)", async () => { + const user = await createTestUser(); + const response = await register(body, { Authorization: `Bearer ${testUserToken(user.id)}` }); + expect(response.status).toBe(404); + }); + + it("lets a valid partner API key past auth (404 for unknown quote, not 401)", async () => { + const user = await createTestUser(); + const { plaintextKey } = await createTestApiKey({ userId: user.id }); + const response = await register(body, { "X-API-Key": plaintextKey }); + expect(response.status).toBe(404); + }); + }); + + describe("GET /v1/ramp/:id ownership", () => { + it("serves the owner and denies other users and anonymous callers", async () => { + const owner = await createTestUser(); + const stranger = await createTestUser(); + const quote = await createTestQuote({ userId: owner.id }); + const ramp = await createTestRampState({ quoteId: quote.id, userId: owner.id }); + + const asOwner = await app.request(`/v1/ramp/${ramp.id}`, { + headers: { Authorization: `Bearer ${testUserToken(owner.id)}` } + }); + expect(asOwner.status).toBe(200); + + const asStranger = await app.request(`/v1/ramp/${ramp.id}`, { + headers: { Authorization: `Bearer ${testUserToken(stranger.id)}` } + }); + expect(asStranger.status).toBe(403); + + const asAnonymous = await app.request(`/v1/ramp/${ramp.id}`); + expect(asAnonymous.status).toBe(401); + }); + + it("serves fully anonymous ramps to anonymous callers", async () => { + const quote = await createTestQuote(); + const ramp = await createTestRampState({ quoteId: quote.id }); + + const response = await app.request(`/v1/ramp/${ramp.id}`); + expect(response.status).toBe(200); + }); + + it("denies a partner key that does not own the ramp's quote", async () => { + const owningPartner = await createTestPartner(); + const otherPartner = await createTestPartner(); + const { plaintextKey: otherKey } = await createTestApiKey({ partnerName: otherPartner.name }); + const { plaintextKey: owningKey } = await createTestApiKey({ partnerName: owningPartner.name }); + + const quote = await createTestQuote({ partnerId: owningPartner.id }); + const ramp = await createTestRampState({ quoteId: quote.id }); + + const asOther = await app.request(`/v1/ramp/${ramp.id}`, { headers: { "X-API-Key": otherKey } }); + expect(asOther.status).toBe(403); + + const asOwner = await app.request(`/v1/ramp/${ramp.id}`, { headers: { "X-API-Key": owningKey } }); + expect(asOwner.status).toBe(200); + }); + }); + + describe("quote lifecycle guards on registration", () => { + it("rejects an expired quote", async () => { + const user = await createTestUser(); + const quote = await createTestQuote({ expiresAt: new Date(Date.now() - 1000), userId: user.id }); + + const response = await register( + { quoteId: quote.id, signingAccounts: SIGNING_ACCOUNTS }, + { Authorization: `Bearer ${testUserToken(user.id)}` } + ); + expect(response.status).toBe(400); + expect(await response.text()).toContain("expired"); + }); + + it("rejects an already-consumed quote", async () => { + const user = await createTestUser(); + const quote = await createTestQuote({ status: "consumed", userId: user.id }); + + const response = await register( + { quoteId: quote.id, signingAccounts: SIGNING_ACCOUNTS }, + { Authorization: `Bearer ${testUserToken(user.id)}` } + ); + expect(response.status).toBe(400); + expect(await response.text()).toContain("consumed"); + }); + + it("rejects registration by a user who does not own the quote", async () => { + const owner = await createTestUser(); + const stranger = await createTestUser(); + const quote = await createTestQuote({ userId: owner.id }); + + const response = await register( + { quoteId: quote.id, signingAccounts: SIGNING_ACCOUNTS }, + { Authorization: `Bearer ${testUserToken(stranger.id)}` } + ); + expect(response.status).toBe(403); + }); + + it("rejects EUR ramps while the EUR kill-switch is active", async () => { + // Current production behavior (ramp.service.ts): EURC quotes cannot be + // registered. Remove/adjust this test when EUR ramps are re-enabled. + const user = await createTestUser(); + const quote = await createTestQuote({ inputCurrency: FiatToken.EURC, userId: user.id }); + + const response = await register( + { quoteId: quote.id, signingAccounts: SIGNING_ACCOUNTS }, + { Authorization: `Bearer ${testUserToken(user.id)}` } + ); + expect(response.status).toBe(503); + }); + + it("rejects registration of a quote from the other flow variant", async () => { + const user = await createTestUser(); + const quote = await createTestQuote({ + flowVariant: "monerium", + inputCurrency: FiatToken.BRL, + rampType: RampDirection.BUY, + userId: user.id + }); + + const response = await register( + { quoteId: quote.id, signingAccounts: SIGNING_ACCOUNTS }, + { Authorization: `Bearer ${testUserToken(user.id)}` } + ); + expect(response.status).toBeGreaterThanOrEqual(400); + }); + }); + + describe("admin authentication", () => { + it("guards partner API key admin routes with the admin secret", async () => { + const partner = await createTestPartner(); + const path = `/v1/admin/partners/${partner.name}/api-keys`; + + const noAuth = await app.request(path); + expect(noAuth.status).toBe(401); + + const wrongSecret = await app.request(path, { headers: { Authorization: "Bearer wrong-secret" } }); + expect(wrongSecret.status).toBe(403); + + const correctSecret = await app.request(path, { headers: { Authorization: "Bearer test-admin-secret" } }); + expect(correctSecret.status).toBe(200); + }); + + it("guards api-client-events with the metrics dashboard secret", async () => { + const wrongSecret = await app.request("/v1/admin/api-client-events", { + headers: { Authorization: "Bearer test-admin-secret" } + }); + expect(wrongSecret.status).toBe(403); + + const correctSecret = await app.request("/v1/admin/api-client-events", { + headers: { Authorization: "Bearer test-metrics-secret" } + }); + expect(correctSecret.status).toBe(200); + }); + }); +}); diff --git a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts new file mode 100644 index 000000000..ac4a7047c --- /dev/null +++ b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts @@ -0,0 +1,878 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ERC20_TOKEN, + AlfredPayCountry, + AlfredpayOfframpStatus, + AlfredpayOnrampStatus, + EvmToken, + evmTokenConfig, + FiatToken, + getAnyFiatTokenDetails, + multiplyByPowerOfTen, + Networks, + RampDirection, + type RampPhase, + type UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import { BaseError, ContractFunctionExecutionError, decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import { parseUnits } from "viem/utils"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestAlfredpayCustomer, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +const USDT_ON_ARBITRUM = evmTokenConfig[Networks.Arbitrum][EvmToken.USDT]?.erc20AddressSourceChain as `0x${string}`; +if (!USDT_ON_ARBITRUM) { + throw new Error("USDT token config missing for Arbitrum"); +} + +const CHAIN_IDS: Partial> = { + [Networks.Arbitrum]: 42161, + [Networks.Polygon]: 137 +}; + +const ONRAMP_PHASES: RampPhase[] = [ + "initial", + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +// Cross-chain BUY: squidRouterSwap executes for real (Polygon mint token → +// Arbitrum USDT) and squidRouterPay settles via the destination balance check. +const CROSS_CHAIN_ONRAMP_PHASES: RampPhase[] = [ + "initial", + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +const OFFRAMP_PHASES: RampPhase[] = [ + "initial", + "squidRouterPermitExecute", + "fundEphemeral", + "finalSettlementSubsidy", + "alfredpayOfframpTransfer", + "complete" +]; + +interface CurrencyCase { + fiat: FiatToken; + /** Alfredpay-side currency code expected on created orders. */ + alfredpayCurrency: string; + /** KYC country the registration guard requires a completed profile for. */ + country: AlfredPayCountry; + /** Quote destination string (payment rail). */ + rail: string; + /** Fiat amount for the BUY quote (within the per-currency limits). */ + onrampInputAmount: string; + /** USDT the anchor mints per unit of fiat. */ + onrampRate: number; + /** USDT amount for the SELL quote (within the per-currency limits). */ + offrampInputAmount: string; + /** Fiat the anchor pays out per USDT. */ + offrampRate: number; +} + +// Rates mirror the FakePrices per-USD feeds so quote pricing stays sane. +const CURRENCY_CASES: CurrencyCase[] = [ + { + alfredpayCurrency: "USD", + country: AlfredPayCountry.US, + fiat: FiatToken.USD, + offrampInputAmount: "5", + offrampRate: 1, + onrampInputAmount: "20000", + onrampRate: 1, + rail: "ach" + }, + { + alfredpayCurrency: "COP", + country: AlfredPayCountry.CO, + fiat: FiatToken.COP, + offrampInputAmount: "100", + offrampRate: 4000, + onrampInputAmount: "50000", + onrampRate: 1 / 4000, + rail: "ach" + }, + { + alfredpayCurrency: "ARS", + country: AlfredPayCountry.AR, + fiat: FiatToken.ARS, + offrampInputAmount: "100", + offrampRate: 1000, + onrampInputAmount: "10000", + onrampRate: 1 / 1000, + rail: "cbu" + } +]; + +// MXN's own corridor files cover the direct Polygon paths (mxn-onramp / +// mxn-offramp) and the cross-chain BUY leg (mxn-onramp-crosschain); its +// cross-chain SELL case lives here with the rest of the cross-chain matrix. +const CROSS_CHAIN_OFFRAMP_CASES: CurrencyCase[] = [ + ...CURRENCY_CASES, + { + alfredpayCurrency: "MXN", + country: AlfredPayCountry.MX, + fiat: FiatToken.MXN, + offrampInputAmount: "100", + offrampRate: 20, + onrampInputAmount: "2000", + onrampRate: 0.05, + rail: "spei" + } +]; + +/** + * Parameterized scenarios for the Alfredpay corridors beyond MXN: USD (ach), + * COP (ach) and ARS (cbu), each in both directions. The deeper security and + * subsidy-cap variants live in the MXN corridor files — the phase handlers are + * shared, so what these tests protect is the per-currency configuration + * (rails, limits, and the fiat↔Alfredpay currency mapping) on the happy path + * plus one transient (recoverable) and one unrecoverable failure per currency. + * The cross-chain legs run here per currency too: BUY bridges the Polygon mint + * to Arbitrum via squid (mirroring the MXN cross-chain corridor) and SELL + * takes the no-permit cross-chain fallback (user-broadcast squid approve+swap + * on Arbitrum, verified by hash before the Polygon deposit transfer) — the + * latter including MXN, whose own files only cover the direct Polygon path. + */ +describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.evm.onReadContract = undefined; + world.alfredpay.onCreateOnramp = undefined; + world.alfredpay.onrampStatus = AlfredpayOnrampStatus.TRADE_COMPLETED; + world.alfredpay.onrampStatusMetadata = null; + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + world.alfredpay.offrampDepositAddress = privateKeyToAccount(generatePrivateKey()).address.toLowerCase(); + // The direct corridors never bridge; the cross-chain setups switch the + // fake route to USDT's 6 decimals, so reset to the fake's default here. + world.squidRouter.toTokenDecimals = 18; + world.squidRouter.bridgeStatus = "success"; + }); + + function applyErc20TransfersToLedger(): void { + world.evm.onTransaction = tx => { + if (!tx.serialized) { + return; + } + const parsed = parseTransaction(tx.serialized as `0x${string}`); + if (!parsed.to || !parsed.data) { + return; + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + if (functionName !== "transfer") { + return; + } + const [recipient, amount] = args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + async function createQuoteViaApi(body: Record): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status, `quote creation failed: ${await response.clone().text()}`).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + async function registerViaApi( + quoteId: string, + userId: string, + signingAccounts: Array<{ address: string; type: string }>, + additionalData: Record + ): Promise<{ id: string }> { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ additionalData, quoteId, signingAccounts }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status, `registration failed: ${await response.clone().text()}`).toBe(201); + return (await response.json()) as { id: string }; + } + + async function updateRampViaApi(rampId: string, userId: string, body: Record): Promise { + const response = await app.request("/v1/ramp/update", { + body: JSON.stringify({ rampId, ...body }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status, `ramp update failed: ${await response.clone().text()}`).toBe(200); + } + + function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase): UnsignedTx { + const blueprint = unsignedTxs.find(tx => tx.phase === phase); + expect(blueprint, `missing ${phase} blueprint in persisted ramp state`).toBeDefined(); + return blueprint as UnsignedTx; + } + + /** Signs a blueprint exactly as issued; the nonce may be overridden for backups. */ + async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx, nonce?: number): Promise<`0x${string}`> { + const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const chainId = CHAIN_IDS[blueprint.network]; + if (!chainId) { + throw new Error(`No chain id mapped for ${blueprint.network}`); + } + return ephemeral.signTransaction({ + chainId, + data: txData.data, + gas: 600_000n, + // validatePresignedTxs enforces the blueprint's fee minimums (3 gwei floor on Polygon). + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce: nonce ?? blueprint.nonce, + to: txData.to, + type: "eip1559", + value: BigInt(txData.value ?? "0") + }); + } + + /** validatePresignedTxs requires 4 same-call backups at the next 4 nonces. */ + async function presignWithBackups(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx) { + const backups: Record = {}; + for (let i = 1; i <= 4; i++) { + backups[`backup${i}`] = { nonce: blueprint.nonce + i, txData: await signBlueprint(ephemeral, blueprint, blueprint.nonce + i) }; + } + return { + meta: { additionalTxs: backups }, + network: blueprint.network, + nonce: blueprint.nonce, + phase: blueprint.phase, + signer: ephemeral.address, + txData: await signBlueprint(ephemeral, blueprint) + }; + } + + /** Broadcasts a user-wallet blueprint on its source chain exactly as issued. */ + function broadcastUserBlueprint(userWallet: PrivateKeyAccount, blueprint: UnsignedTx): `0x${string}` { + const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + return world.evm.broadcastUserTransaction(blueprint.network, userWallet.address, { + data: txData.data, + to: txData.to, + value: BigInt(txData.value ?? "0") + }); + } + + /** Scripts the EIP-2612 nonces() probe to revert so registration takes the no-permit path. */ + function failNoncesProbe(): void { + world.evm.onReadContract = (_network, params) => { + if (params.functionName === "nonces") { + throw new ContractFunctionExecutionError(new BaseError("nonces() reverted"), { + abi: erc20Abi, + contractAddress: params.address, + functionName: "nonces" + }); + } + return undefined; + }; + } + + interface OnrampSetup { + rampId: string; + quoteId: string; + destination: `0x${string}`; + /** Raw quoted USDT output the destination must receive. */ + amountRaw: bigint; + } + + /** + * Full BUY setup via the HTTP API — quote, register, presign the destination + * transfer (plus the four required backups), update — then script the fake + * world for the happy path: minted USDT and gas already on the ephemeral, + * raw ERC-20 transfers applied to the in-memory ledger. + */ + async function setUpOnrampRamp(currency: CurrencyCase): Promise { + world.alfredpay.onrampRate = currency.onrampRate; + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id, { country: currency.country }); + const quote = await createQuoteViaApi({ + from: currency.rail, + inputAmount: currency.onrampInputAmount, + inputCurrency: currency.fiat, + network: Networks.Polygon, + outputCurrency: EvmToken.USDT, + rampType: RampDirection.BUY, + to: Networks.Polygon + }); + const ramp = await registerViaApi(quote.id, user.id, [{ address: ephemeral.address, type: "EVM" }], { + destinationAddress: destination + }); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const mintAmountRaw = BigInt(persistedQuote?.metadata.alfredpayMint?.outputAmountRaw ?? "0"); + expect(mintAmountRaw).toBeGreaterThan(0n); + const amountRaw = parseUnits(quote.outputAmount, ALFREDPAY_ERC20_DECIMALS); + + const signTransfer = (nonce: number) => + ephemeral.signTransaction({ + chainId: 137, + data: encodeFunctionData({ abi: erc20Abi, args: [destination, amountRaw], functionName: "transfer" }), + gas: 100_000n, + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce, + to: ALFREDPAY_ERC20_TOKEN, + type: "eip1559" + }); + const backups: Record = {}; + for (let i = 1; i <= 4; i++) { + backups[`backup${i}`] = { nonce: i, txData: await signTransfer(i) }; + } + await updateRampViaApi(ramp.id, user.id, { + presignedTxs: [ + { + meta: { additionalTxs: backups }, + network: Networks.Polygon, + nonce: 0, + phase: "destinationTransfer", + signer: ephemeral.address, + txData: await signTransfer(0) + } + ] + }); + + world.evm.setNativeBalance(Networks.Polygon, ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, ephemeral.address, mintAmountRaw); + applyErc20TransfersToLedger(); + + return { amountRaw, destination, quoteId: quote.id, rampId: ramp.id }; + } + + interface OfframpSetup { + rampId: string; + quoteId: string; + /** Raw (6-decimal) USDT amount the offramp moves. */ + inputAmountRaw: bigint; + /** Anchor deposit address the final transfer must pay. */ + depositAddress: string; + } + + /** + * Full SELL setup via the HTTP API on the no-permit path — Polygon USDT has + * no EIP-2612 support, so the nonces() probe is scripted to fail as a + * contract-call error and the user broadcasts a plain transfer: quote, + * register, user-wallet broadcast, presign of the ephemeral's deposit + * transfer (plus backups), update — then script the fake world for the + * happy path. + */ + async function setUpOfframpRamp(currency: CurrencyCase): Promise { + world.alfredpay.offrampRate = currency.offrampRate; + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + + failNoncesProbe(); + + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id, { country: currency.country }); + const quote = await createQuoteViaApi({ + from: Networks.Polygon, + inputAmount: currency.offrampInputAmount, + inputCurrency: EvmToken.USDT, + network: Networks.Polygon, + outputCurrency: currency.fiat, + rampType: RampDirection.SELL, + to: currency.rail + }); + const ramp = await registerViaApi(quote.id, user.id, [{ address: ephemeral.address, type: "EVM" }], { + fiatAccountId: "test-fiat-account-1", + walletAddress: userWallet.address + }); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const inputAmountRaw = BigInt(persistedQuote?.metadata.alfredpayOfframp?.inputAmountRaw ?? "0"); + expect(inputAmountRaw).toBeGreaterThan(0n); + + const registered = await RampState.findByPk(ramp.id); + const allUnsignedTxs: UnsignedTx[] = registered?.unsignedTxs ?? []; + const userTransferBlueprint = allUnsignedTxs.find(tx => tx.phase === "squidRouterNoPermitTransfer"); + const offrampTransferBlueprint = allUnsignedTxs.find(tx => tx.phase === "alfredpayOfframpTransfer"); + expect(userTransferBlueprint).toBeDefined(); + expect(offrampTransferBlueprint).toBeDefined(); + const userTxData = userTransferBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const offrampTxData = offrampTransferBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + + const signOfframpTransfer = (nonce: number) => + ephemeral.signTransaction({ + chainId: 137, + data: offrampTxData.data, + gas: 100_000n, + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce, + to: offrampTxData.to, + type: "eip1559" + }); + const backups: Record = {}; + for (let i = 1; i <= 4; i++) { + backups[`backup${i}`] = { nonce: i, txData: await signOfframpTransfer(i) }; + } + + const userTxHash = world.evm.broadcastUserTransaction(Networks.Polygon, userWallet.address, { + data: userTxData.data, + to: userTxData.to, + value: 0n + }); + await updateRampViaApi(ramp.id, user.id, { + additionalData: { squidRouterNoPermitTransferHash: userTxHash }, + presignedTxs: [ + { + meta: { additionalTxs: backups }, + network: Networks.Polygon, + nonce: 0, + phase: "alfredpayOfframpTransfer", + signer: ephemeral.address, + txData: await signOfframpTransfer(0) + } + ] + }); + + world.evm.setNativeBalance(Networks.Polygon, ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, ephemeral.address, inputAmountRaw); + applyErc20TransfersToLedger(); + + return { + depositAddress: world.alfredpay.offrampDepositAddress, + inputAmountRaw, + quoteId: quote.id, + rampId: ramp.id + }; + } + + interface CrossChainOnrampSetup extends OnrampSetup { + signedSquidApprove: `0x${string}`; + signedSquidSwap: `0x${string}`; + } + + /** + * Cross-chain BUY setup via the HTTP API, mirroring the MXN cross-chain + * corridor: quote to Arbitrum, register, presign the squid approve/swap + * (Polygon) and destination transfer (Arbitrum) blueprints exactly as + * issued, update — then script the fake world so the mint and gas are + * already on the ephemeral and the broadcast squid swap credits the bridged + * USDT on Arbitrum. + */ + async function setUpCrossChainOnrampRamp(currency: CurrencyCase): Promise { + world.alfredpay.onrampRate = currency.onrampRate; + // The bridge leg swaps the 6-decimal Polygon mint token into 6-decimal + // Arbitrum USDT; the fake route must report matching decimals. + world.squidRouter.toTokenDecimals = 6; + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id, { country: currency.country }); + const quote = await createQuoteViaApi({ + from: currency.rail, + inputAmount: currency.onrampInputAmount, + inputCurrency: currency.fiat, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDT, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + }); + const ramp = await registerViaApi(quote.id, user.id, [{ address: ephemeral.address, type: "EVM" }], { + destinationAddress: destination + }); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const mintAmountRaw = BigInt(persistedQuote?.metadata.alfredpayMint?.outputAmountRaw ?? "0"); + const bridgedAmountRaw = BigInt(persistedQuote?.metadata.evmToEvm?.outputAmountRaw ?? "0"); + expect(mintAmountRaw).toBeGreaterThan(0n); + expect(bridgedAmountRaw).toBeGreaterThan(0n); + + const registered = await RampState.findByPk(ramp.id); + const unsignedTxs: UnsignedTx[] = registered?.unsignedTxs ?? []; + const approveBlueprint = blueprintOf(unsignedTxs, "squidRouterApprove"); + const swapBlueprint = blueprintOf(unsignedTxs, "squidRouterSwap"); + const transferBlueprint = blueprintOf(unsignedTxs, "destinationTransfer"); + expect(approveBlueprint.network).toBe(Networks.Polygon); + expect(swapBlueprint.network).toBe(Networks.Polygon); + expect(transferBlueprint.network).toBe(Networks.Arbitrum); + + const approvePresign = await presignWithBackups(ephemeral, approveBlueprint); + const swapPresign = await presignWithBackups(ephemeral, swapBlueprint); + const transferPresign = await presignWithBackups(ephemeral, transferBlueprint); + await updateRampViaApi(ramp.id, user.id, { presignedTxs: [approvePresign, swapPresign, transferPresign] }); + + const transferTxData = transferBlueprint.txData as unknown as { data: `0x${string}` }; + const { args } = decodeFunctionData({ abi: erc20Abi, data: transferTxData.data }); + const amountRaw = (args as [string, bigint])[1]; + + world.evm.setNativeBalance(Networks.Polygon, ephemeral.address, parseUnits("2", 18)); + world.evm.setNativeBalance(Networks.Arbitrum, ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, ephemeral.address, mintAmountRaw); + applyErc20TransfersToLedger(); + const applyErc20Transfers = world.evm.onTransaction; + world.evm.onTransaction = tx => { + if (tx.serialized === swapPresign.txData) { + world.evm.setErc20Balance( + Networks.Arbitrum, + USDT_ON_ARBITRUM, + ephemeral.address, + world.evm.erc20Balance(Networks.Arbitrum, USDT_ON_ARBITRUM, ephemeral.address) + bridgedAmountRaw + ); + return; + } + applyErc20Transfers?.(tx); + }; + + return { + amountRaw, + destination, + quoteId: quote.id, + rampId: ramp.id, + signedSquidApprove: approvePresign.txData, + signedSquidSwap: swapPresign.txData + }; + } + + interface CrossChainOfframpSetup extends OfframpSetup { + ephemeralAddress: `0x${string}`; + userWalletAddress: `0x${string}`; + } + + /** + * Cross-chain SELL setup via the HTTP API on the no-permit fallback (the + * Alfredpay analog of the BRL cross-chain offramp's squid-hash flow): the + * user broadcasts the squid approve + swap from their own wallet on + * Arbitrum, the hashes are reported through /v1/ramp/update together with + * the presigned Polygon deposit transfer, and squidRouterPermitExecute + * verifies them against the blueprints before any ephemeral funds move. + */ + async function setUpCrossChainOfframpRamp(currency: CurrencyCase): Promise { + world.alfredpay.offrampRate = currency.offrampRate; + // The user's squid leg swaps 6-decimal Arbitrum USDT into 6-decimal + // Polygon USDT; the fake route must report matching decimals. + world.squidRouter.toTokenDecimals = 6; + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + + failNoncesProbe(); + + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id, { country: currency.country }); + const quote = await createQuoteViaApi({ + from: Networks.Arbitrum, + inputAmount: currency.offrampInputAmount, + inputCurrency: EvmToken.USDT, + network: Networks.Arbitrum, + outputCurrency: currency.fiat, + rampType: RampDirection.SELL, + to: currency.rail + }); + const ramp = await registerViaApi(quote.id, user.id, [{ address: ephemeral.address, type: "EVM" }], { + fiatAccountId: "test-fiat-account-1", + walletAddress: userWallet.address + }); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const inputAmountRaw = BigInt(persistedQuote?.metadata.alfredpayOfframp?.inputAmountRaw ?? "0"); + expect(inputAmountRaw).toBeGreaterThan(0n); + + const registered = await RampState.findByPk(ramp.id); + const allUnsignedTxs: UnsignedTx[] = registered?.unsignedTxs ?? []; + // The cross-chain no-permit branch: user-wallet squid approve + swap on + // the source chain instead of the direct Polygon transfer. + expect(allUnsignedTxs.some(tx => tx.phase === "squidRouterNoPermitTransfer")).toBe(false); + const approveBlueprint = blueprintOf(allUnsignedTxs, "squidRouterNoPermitApprove"); + const swapBlueprint = blueprintOf(allUnsignedTxs, "squidRouterNoPermitSwap"); + const offrampTransferBlueprint = blueprintOf(allUnsignedTxs, "alfredpayOfframpTransfer"); + expect(approveBlueprint.network).toBe(Networks.Arbitrum); + expect(swapBlueprint.network).toBe(Networks.Arbitrum); + expect(approveBlueprint.signer.toLowerCase()).toBe(userWallet.address.toLowerCase()); + expect(swapBlueprint.signer.toLowerCase()).toBe(userWallet.address.toLowerCase()); + + // The user broadcasts the squid leg from their own wallet; the frontend + // reports the hashes together with the presigned deposit transfer. + const approveHash = broadcastUserBlueprint(userWallet, approveBlueprint); + const swapHash = broadcastUserBlueprint(userWallet, swapBlueprint); + await updateRampViaApi(ramp.id, user.id, { + additionalData: { squidRouterNoPermitApproveHash: approveHash, squidRouterNoPermitSwapHash: swapHash }, + presignedTxs: [await presignWithBackups(ephemeral, offrampTransferBlueprint)] + }); + + // The squid-bridged USDT has already landed on the Polygon ephemeral, + // which also has Polygon gas. + world.evm.setNativeBalance(Networks.Polygon, ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, ephemeral.address, inputAmountRaw); + applyErc20TransfersToLedger(); + + return { + depositAddress: world.alfredpay.offrampDepositAddress, + ephemeralAddress: ephemeral.address as `0x${string}`, + inputAmountRaw, + quoteId: quote.id, + rampId: ramp.id, + userWalletAddress: userWallet.address as `0x${string}` + }; + } + + for (const currency of CURRENCY_CASES) { + it( + `${currency.fiat} onramp (${currency.rail} → USDT on Polygon) completes and maps to Alfredpay ${currency.alfredpayCurrency}`, + async () => { + // The in-memory anchor keeps orders across tests in this file. + const ordersBefore = world.alfredpay.onrampOrders.length; + const setup = await setUpOnrampRamp(currency); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(ONRAMP_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + // The per-currency contract with the anchor: the order carries the + // Alfredpay currency code for this fiat and mints USDT. + expect(world.alfredpay.onrampOrders.length).toBe(ordersBefore + 1); + const order = world.alfredpay.onrampOrders[world.alfredpay.onrampOrders.length - 1]; + expect(order.fromCurrency).toBe(currency.alfredpayCurrency as never); + expect(order.toCurrency).toBe("USDT" as never); + expect(Number(order.amount)).toBe(Number(currency.onrampInputAmount)); + + const quoteRow = await QuoteTicket.findByPk(setup.quoteId); + expect(quoteRow?.status).toBe("consumed"); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + `${currency.fiat} offramp (USDT on Polygon → ${currency.rail}) completes and maps to Alfredpay ${currency.alfredpayCurrency}`, + async () => { + // The in-memory anchor keeps orders across tests in this file. + const offrampOrdersBefore = world.alfredpay.offrampOrders.length; + const setup = await setUpOfframpRamp(currency); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(OFFRAMP_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + // Per-currency contract with the anchor on the way out: USDT in, this + // fiat's Alfredpay currency code out, deposit received in full. + expect(world.alfredpay.offrampOrders.length).toBe(offrampOrdersBefore + 1); + const order = world.alfredpay.offrampOrders[world.alfredpay.offrampOrders.length - 1]; + expect(order.fromCurrency).toBe("USDT" as never); + expect(order.toCurrency).toBe(currency.alfredpayCurrency as never); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.depositAddress)).toBe( + setup.inputAmountRaw + ); + + const quoteRow = await QuoteTicket.findByPk(setup.quoteId); + expect(quoteRow?.status).toBe("consumed"); + }, + 30000 + ); + + it( + `${currency.fiat} cross-chain onramp (${currency.rail} → Polygon mint → USDT on Arbitrum) bridges via squid and completes`, + async () => { + const ordersBefore = world.alfredpay.onrampOrders.length; + const setup = await setUpCrossChainOnrampRamp(currency); + + // Registration requested a Polygon mint-token → Arbitrum USDT route + // for the ephemeral. + const registrationRoute = world.squidRouter.requestedRoutes.find( + route => + route.fromToken.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase() && + route.toToken.toLowerCase() === USDT_ON_ARBITRUM.toLowerCase() && + route.fromChain === "137" && + route.toChain === "42161" + ); + expect(registrationRoute, "registration should request a Polygon→Arbitrum route").toBeDefined(); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(CROSS_CHAIN_ONRAMP_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.state.squidRouterApproveHash).toBeTruthy(); + expect(final?.state.squidRouterSwapHash).toBeTruthy(); + + // The order still carries this currency's Alfredpay code, and the + // destination received exactly the quoted USDT on ARBITRUM. + expect(world.alfredpay.onrampOrders.length).toBe(ordersBefore + 1); + const order = world.alfredpay.onrampOrders[world.alfredpay.onrampOrders.length - 1]; + expect(order.fromCurrency).toBe(currency.alfredpayCurrency as never); + expect(world.evm.sentTransactions.filter(tx => tx.serialized === setup.signedSquidApprove).length).toBe(1); + expect(world.evm.sentTransactions.filter(tx => tx.serialized === setup.signedSquidSwap).length).toBe(1); + expect(world.evm.erc20Balance(Networks.Arbitrum, USDT_ON_ARBITRUM, setup.destination)).toBe(setup.amountRaw); + + const quoteRow = await QuoteTicket.findByPk(setup.quoteId); + expect(quoteRow?.status).toBe("consumed"); + }, + 30000 + ); + + it(`${currency.fiat} onramp quote beyond the per-currency maximum is rejected`, async () => { + const details = getAnyFiatTokenDetails(currency.fiat); + const maxBuyUnits = multiplyByPowerOfTen(Big(details.maxBuyAmountRaw), -details.decimals); + + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: currency.rail, + inputAmount: maxBuyUnits.plus(1).toFixed(), + inputCurrency: currency.fiat, + network: Networks.Polygon, + outputCurrency: EvmToken.USDT, + rampType: RampDirection.BUY, + to: Networks.Polygon + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { message?: string; error?: string }; + expect(JSON.stringify(body).toLowerCase()).toContain("limit"); + }); + + it( + `transient failure (${currency.fiat}): an RPC outage on the destination transfer is recoverable and the onramp still completes`, + async () => { + const setup = await setUpOnrampRamp(currency); + // The first broadcast of this corridor is the destination transfer. + world.evm.failNextSends = 1; + world.evm.sendFailureMessage = "FakeEvm: scripted RPC outage"; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(ONRAMP_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + // The scripted outage was recorded as a recoverable destinationTransfer + // error, and after the retry the destination was still paid in full. + const outageLogs = final?.errorLogs.filter(log => log.error.includes("scripted RPC outage")) ?? []; + expect(outageLogs.length).toBeGreaterThanOrEqual(1); + expect(outageLogs.every(log => log.phase === "destinationTransfer")).toBe(true); + expect(outageLogs.some(log => log.recoverable === true)).toBe(true); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + `unrecoverable failure (${currency.fiat}): a FAILED Alfredpay offramp order fails the ramp without completing`, + async () => { + const setup = await setUpOfframpRamp(currency); + // The anchor reports the order FAILED while the transfer phase polls it. + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FAILED; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + }, + 30000 + ); + } + + for (const currency of CROSS_CHAIN_OFFRAMP_CASES) { + it( + `${currency.fiat} cross-chain offramp (USDT on Arbitrum → squid → Polygon → ${currency.rail}) verifies the user's squid txs and completes`, + async () => { + const offrampOrdersBefore = world.alfredpay.offrampOrders.length; + const setup = await setUpCrossChainOfframpRamp(currency); + + // Registration requested an Arbitrum USDT → Polygon USDT route from + // the user's wallet, delivering to the ephemeral. + const registrationRoute = world.squidRouter.requestedRoutes.find( + route => + route.fromToken.toLowerCase() === USDT_ON_ARBITRUM.toLowerCase() && + route.toToken.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase() && + route.toAddress?.toLowerCase() === setup.ephemeralAddress.toLowerCase() + ); + expect(registrationRoute, "registration should request an Arbitrum→Polygon USDT route to the ephemeral").toBeDefined(); + expect(registrationRoute?.fromChain).toBe("42161"); + expect(registrationRoute?.toChain).toBe("137"); + expect(registrationRoute?.fromAddress.toLowerCase()).toBe(setup.userWalletAddress.toLowerCase()); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(OFFRAMP_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.state.isNoPermitFallback).toBe(true); + expect(final?.state.isDirectTransfer).toBeFalsy(); + + // Per-currency contract with the anchor on the way out, unchanged by + // the cross-chain source: USDT in, this fiat's code out, deposit paid + // in full on Polygon. + expect(world.alfredpay.offrampOrders.length).toBe(offrampOrdersBefore + 1); + const order = world.alfredpay.offrampOrders[world.alfredpay.offrampOrders.length - 1]; + expect(order.fromCurrency).toBe("USDT" as never); + expect(order.toCurrency).toBe(currency.alfredpayCurrency as never); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.depositAddress)).toBe( + setup.inputAmountRaw + ); + + const quoteRow = await QuoteTicket.findByPk(setup.quoteId); + expect(quoteRow?.status).toBe("consumed"); + }, + 30000 + ); + } +}); diff --git a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts new file mode 100644 index 000000000..44d445f91 --- /dev/null +++ b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts @@ -0,0 +1,437 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { Keyring } from "@polkadot/api"; +import { cryptoWaitReady, mnemonicGenerate } from "@polkadot/util-crypto"; +import { + AveniaTicketStatus, + EvmToken, + evmTokenConfig, + FiatToken, + Networks, + RampDirection, + type RampPhase, + type UnsignedTx +} from "@vortexfi/shared"; +import { parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import Partner from "../../models/partner.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestTaxId, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +function requireToken(network: Networks.Base | Networks.Polygon, token: EvmToken) { + const details = evmTokenConfig[network][token]; + if (!details) { + throw new Error(`${token} token config missing for ${network}`); + } + return details; +} +const USDC_ON_BASE = requireToken(Networks.Base, EvmToken.USDC).erc20AddressSourceChain as `0x${string}`; +const USDC_ON_POLYGON = requireToken(Networks.Polygon, EvmToken.USDC).erc20AddressSourceChain as `0x${string}`; +const BRLA_ON_BASE = requireToken(Networks.Base, EvmToken.BRLA).erc20AddressSourceChain as `0x${string}`; + +const TAX_ID = "12345678901"; +// FakeBrla.validatePixKey reports this as the pix key owner's tax id; the +// registration-time receiver check must be given a matching value. +const RECEIVER_TAX_ID = "12345678900"; +const PIX_KEY = "test-pix-key"; + +// Identical to the Base→Base swap corridor: the squidRouterApprove/Swap leg is +// user-broadcast on Polygon before the processor runs, so it never appears in +// the processor's phase history. +const HAPPY_PATH_PHASES: RampPhase[] = [ + "initial", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "brlaPayoutOnBase", + "complete" +]; + +interface CorridorSetup { + rampId: string; + quoteId: string; + userWallet: PrivateKeyAccount; + ephemeral: PrivateKeyAccount; + /** Raw (6-decimal) USDC amount the Nabla swap consumes on Base. */ + swapInputRaw: bigint; + /** Raw (18-decimal) BRLA amount the swap yields and the payout transfers. */ + swapOutputRaw: bigint; + signedNablaSwap: `0x${string}`; + signedPayout: `0x${string}`; + approveBlueprint: UnsignedTx; + swapBlueprint: UnsignedTx; + /** Hash of the user's broadcast squidRouterApprove on Polygon. */ + approveHash: `0x${string}`; + /** Hash of the user's broadcast squidRouterSwap on Polygon. */ + swapHash: `0x${string}`; +} + +/** + * Corridor scenario tests for the CROSS-CHAIN BRL offramp path (USDC on + * Polygon → SquidRouter → USDC on Base → Nabla swap → pix via Avenia). This is + * the branch of prepareEvmToBRLOfframpBaseTransactions the Base→Base corridor + * never reaches: registration must issue squidRouterApprove + squidRouterSwap + * blueprints for the user's wallet on the source chain, and fundEphemeral must + * verify the user-reported hashes against those blueprints before any + * ephemeral funds are spent (F-021/F-038 class). + */ +describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via Avenia)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + // The EVM fee distribution transaction builder requires the vortex + // partner's EVM payout address even when the resulting fees are zero. + await Partner.update( + { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, + { where: { name: "vortex", rampType: RampDirection.SELL } } + ); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.brla.onPixOutputTicket = undefined; + world.brla.accountBalances = { BRLA: 1_000_000, USDC: 0, USDM: 0, USDT: 0 }; + world.brla.payoutTicketStatus = AveniaTicketStatus.PAID; + // Fresh subaccount wallet per test: the in-memory EVM ledger persists + // across tests, so a shared payout recipient would accumulate balances. + world.brla.subaccountEvmWallet = privateKeyToAccount(generatePrivateKey()).address.toLowerCase(); + // Both the quote pipeline's bridge estimate and the registration-time + // squid transactions route Polygon USDC → Base USDC: the fake route's + // destination token must report USDC's 6 decimals. + world.squidRouter.toTokenDecimals = 6; + // Deterministic Nabla quoter for USDC (6 decimals) → BRLA (18 decimals) + // at a flat 5 BRLA per USDC, matching the FakePrices 5 BRL/USD feed. + world.evm.onReadContract = (_network, params) => { + if (params.functionName === "quoteSwapExactTokensForTokens") { + const amountIn = params.args?.[0] as bigint; + return amountIn * 5n * 10n ** 12n; + } + return undefined; + }; + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Polygon, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Polygon, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: "pix" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + // The EVM→BRL route still requires a Substrate ephemeral in signingAccounts + // (validateOfframpQuote legacy default) even though this path never uses it. + async function createSubstrateEphemeralAddress(): Promise { + await cryptoWaitReady(); + const keyring = new Keyring({ type: "sr25519" }); + return keyring.addFromUri(mnemonicGenerate()).address; + } + + async function registerViaApi( + quoteId: string, + userId: string, + ephemeral: PrivateKeyAccount, + userWallet: PrivateKeyAccount + ): Promise<{ id: string }> { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { + pixDestination: PIX_KEY, + receiverTaxId: RECEIVER_TAX_ID, + taxId: TAX_ID, + walletAddress: userWallet.address + }, + quoteId, + signingAccounts: [ + { address: ephemeral.address, type: "EVM" }, + { address: await createSubstrateEphemeralAddress(), type: "Substrate" } + ] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string }; + } + + function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase): UnsignedTx { + const blueprint = unsignedTxs.find(tx => tx.phase === phase); + expect(blueprint, `missing ${phase} blueprint in persisted ramp state`).toBeDefined(); + return blueprint as UnsignedTx; + } + + async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx): Promise<`0x${string}`> { + const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + return ephemeral.signTransaction({ + chainId: 8453, + data: txData.data, + gas: 600_000n, + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce: blueprint.nonce, + to: txData.to, + type: "eip1559", + value: BigInt(txData.value ?? "0") + }); + } + + /** Broadcasts a user-wallet blueprint on its source chain exactly as issued. */ + function broadcastUserBlueprint(userWallet: PrivateKeyAccount, blueprint: UnsignedTx): `0x${string}` { + const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + return world.evm.broadcastUserTransaction(blueprint.network, userWallet.address, { + data: txData.data, + to: txData.to, + value: BigInt(txData.value ?? "0") + }); + } + + /** + * Creates quote + registration through the HTTP API, broadcasts the user's + * squidRouterApprove + squidRouterSwap on Polygon, and stores their hashes + * plus the ephemeral's presigned Base-side transactions the way the + * frontend/SDK would via /v1/ramp/update. + */ + async function setUpRegisteredRamp(options: { reportHashes?: boolean } = {}): Promise { + const reportHashes = options.reportHashes ?? true; + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + const ramp = await registerViaApi(quote.id, user.id, ephemeral, userWallet); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const swapInputRaw = BigInt(persistedQuote?.metadata.nablaSwapEvm?.inputAmountForSwapRaw ?? "0"); + const swapOutputRaw = BigInt(persistedQuote?.metadata.nablaSwapEvm?.outputAmountRaw ?? "0"); + expect(swapInputRaw).toBeGreaterThan(0n); + expect(swapOutputRaw).toBeGreaterThan(0n); + + const rampState = await RampState.findByPk(ramp.id); + if (!rampState) { + throw new Error("Ramp state not found after registration"); + } + const unsignedTxs = rampState.unsignedTxs ?? []; + + // The cross-chain branch: user-wallet squid transactions on the source + // chain instead of the Base-direct squidRouterNoPermitTransfer. + expect(unsignedTxs.some(tx => tx.phase === "squidRouterNoPermitTransfer")).toBe(false); + const approveBlueprint = blueprintOf(unsignedTxs, "squidRouterApprove"); + const swapBlueprint = blueprintOf(unsignedTxs, "squidRouterSwap"); + + const nablaApproveBlueprint = blueprintOf(unsignedTxs, "nablaApprove"); + const nablaSwapBlueprint = blueprintOf(unsignedTxs, "nablaSwap"); + const payoutBlueprint = blueprintOf(unsignedTxs, "brlaPayoutOnBase"); + + const signedNablaApprove = await signBlueprint(ephemeral, nablaApproveBlueprint); + const signedNablaSwap = await signBlueprint(ephemeral, nablaSwapBlueprint); + const signedPayout = await signBlueprint(ephemeral, payoutBlueprint); + + const presign = (blueprint: UnsignedTx, txData: `0x${string}`) => ({ + meta: {}, + network: blueprint.network, + nonce: blueprint.nonce, + phase: blueprint.phase, + signer: ephemeral.address, + txData + }); + + const approveHash = broadcastUserBlueprint(userWallet, approveBlueprint); + const swapHash = broadcastUserBlueprint(userWallet, swapBlueprint); + + await rampState.update({ + presignedTxs: [ + presign(nablaApproveBlueprint, signedNablaApprove), + presign(nablaSwapBlueprint, signedNablaSwap), + presign(payoutBlueprint, signedPayout) + ], + state: reportHashes + ? { ...rampState.state, squidRouterApproveHash: approveHash, squidRouterSwapHash: swapHash } + : rampState.state + }); + + return { + approveBlueprint, + approveHash, + ephemeral, + quoteId: quote.id, + rampId: ramp.id, + signedNablaSwap, + signedPayout, + swapBlueprint, + swapHash, + swapInputRaw, + swapOutputRaw, + userWallet + }; + } + + /** + * Scripts the fake world for the happy path: the ephemeral has Base gas, the + * squid-bridged USDC has already landed on Base, and broadcast transactions + * apply their ledger effects (Nabla swap credit + raw ERC-20 transfers). + */ + function scriptHappyWorld(setup: CorridorSetup): void { + world.evm.setNativeBalance(Networks.Base, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Base, USDC_ON_BASE, setup.ephemeral.address, setup.swapInputRaw); + world.evm.onTransaction = tx => { + if (tx.serialized === setup.signedNablaSwap) { + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, setup.ephemeral.address, setup.swapOutputRaw); + return; + } + if (tx.serialized === setup.signedPayout) { + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet, setup.swapOutputRaw); + } + }; + } + + function submissionsOf(signedTransfer: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTransfer).length; + } + + it( + "happy path: registration issues source-chain squid blueprints and the corridor completes end to end", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const pixOutBefore = world.brla.pixOutputTickets.length; + + // Registration requested a Polygon USDC → Base USDC squid route for the + // user's wallet, delivering to the ephemeral. + expect(setup.approveBlueprint.network).toBe(Networks.Polygon); + expect(setup.swapBlueprint.network).toBe(Networks.Polygon); + expect(setup.approveBlueprint.signer.toLowerCase()).toBe(setup.userWallet.address.toLowerCase()); + expect(setup.swapBlueprint.signer.toLowerCase()).toBe(setup.userWallet.address.toLowerCase()); + const approveTxData = setup.approveBlueprint.txData as unknown as { to: string }; + expect(approveTxData.to.toLowerCase()).toBe(USDC_ON_POLYGON.toLowerCase()); + const registrationRoute = world.squidRouter.requestedRoutes.find( + route => + route.fromToken.toLowerCase() === USDC_ON_POLYGON.toLowerCase() && + route.toToken.toLowerCase() === USDC_ON_BASE.toLowerCase() && + route.toAddress?.toLowerCase() === setup.ephemeral.address.toLowerCase() + ); + expect(registrationRoute, "registration should request a Polygon→Base USDC route to the ephemeral").toBeDefined(); + expect(registrationRoute?.fromChain).toBe("137"); + expect(registrationRoute?.toChain).toBe("8453"); + expect(registrationRoute?.fromAddress.toLowerCase()).toBe(setup.userWallet.address.toLowerCase()); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + expect(submissionsOf(setup.signedNablaSwap)).toBe(1); + expect(submissionsOf(setup.signedPayout)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(setup.swapOutputRaw); + expect(world.brla.pixOutputTickets.length).toBe(pixOutBefore + 1); + }, + 30000 + ); + + it( + "recoverable pause: with no reported squid hashes the ramp waits in fundEphemeral and resumes once they arrive", + async () => { + const setup = await setUpRegisteredRamp({ reportHashes: false }); + scriptHappyWorld(setup); + + await phaseProcessor.processRamp(setup.rampId); + + // The user has not (yet) broadcast/reported the squid leg: the processor + // must park the ramp recoverably without spending ephemeral funds. + const paused = await RampState.findByPk(setup.rampId); + expect(paused?.currentPhase).toBe("fundEphemeral"); + expect(paused?.processingLock).toEqual({ locked: false, lockedAt: null }); + const waitLogs = paused?.errorLogs.filter(log => log.error.includes("hash not yet reported")) ?? []; + expect(waitLogs.length).toBeGreaterThanOrEqual(1); + expect(waitLogs.every(log => log.recoverable === true)).toBe(true); + expect(submissionsOf(setup.signedNablaSwap)).toBe(0); + expect(submissionsOf(setup.signedPayout)).toBe(0); + + // The hashes arrive (the frontend reports them) and processing resumes. + await paused?.update({ + state: { ...paused.state, squidRouterApproveHash: setup.approveHash, squidRouterSwapHash: setup.swapHash } + }); + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(setup.swapOutputRaw); + }, + 60000 + ); + + it( + "security regression (F-021 class): a reported swap hash whose calldata differs from the blueprint fails the ramp", + async () => { + const setup = await setUpRegisteredRamp({ reportHashes: false }); + scriptHappyWorld(setup); + + // The attacker points us at a REAL Polygon tx from the right wallet to + // the right router — but with different calldata (e.g. a swap that pays + // them instead of the ephemeral). + const swapTxData = setup.swapBlueprint.txData as unknown as { to: `0x${string}`; value?: string }; + const tamperedHash = world.evm.broadcastUserTransaction(Networks.Polygon, setup.userWallet.address, { + data: "0xdeadbeef", + to: swapTxData.to, + value: BigInt(swapTxData.value ?? "0") + }); + const rampState = await RampState.findByPk(setup.rampId); + await rampState?.update({ + state: { ...rampState.state, squidRouterApproveHash: setup.approveHash, squidRouterSwapHash: tamperedHash } + }); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.errorLogs.some(log => log.error.includes("calldata does not match"))).toBe(true); + + // No ephemeral funds moved: the swap and payout never reached the chain. + expect(submissionsOf(setup.signedNablaSwap)).toBe(0); + expect(submissionsOf(setup.signedPayout)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(0n); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts new file mode 100644 index 000000000..1d456832e --- /dev/null +++ b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts @@ -0,0 +1,530 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { Keyring } from "@polkadot/api"; +import { cryptoWaitReady, mnemonicGenerate } from "@polkadot/util-crypto"; +import { + AveniaTicketStatus, + EvmToken, + evmTokenConfig, + FiatToken, + Networks, + RampDirection, + type RampPhase, + type UnsignedTx +} from "@vortexfi/shared"; +import { decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import Subsidy from "../../models/subsidy.model"; +import Partner from "../../models/partner.model"; +import type { SubsidyToken } from "../../models/subsidy.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestTaxId, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +function requireBaseToken(token: EvmToken) { + const details = evmTokenConfig[Networks.Base][token]; + if (!details) { + throw new Error(`${token} token config missing for Base`); + } + return details; +} +const USDC_ON_BASE = requireBaseToken(EvmToken.USDC).erc20AddressSourceChain as `0x${string}`; +const BRLA_ON_BASE = requireBaseToken(EvmToken.BRLA).erc20AddressSourceChain as `0x${string}`; + +const TAX_ID = "12345678901"; +// FakeBrla.validatePixKey reports this as the pix key owner's tax id; the +// registration-time receiver check must be given a matching value. +const RECEIVER_TAX_ID = "12345678900"; +const PIX_KEY = "test-pix-key"; + +const HAPPY_PATH_PHASES: RampPhase[] = [ + "initial", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "brlaPayoutOnBase", + "complete" +]; + +interface CorridorSetup { + rampId: string; + quoteId: string; + /** Raw (6-decimal) USDC amount the Nabla swap consumes. */ + swapInputRaw: bigint; + /** Raw (18-decimal) BRLA amount the swap yields and the payout transfers. */ + swapOutputRaw: bigint; + signedNablaSwap: `0x${string}`; + signedPayout: `0x${string}`; + ephemeral: PrivateKeyAccount; + payoutBlueprint: UnsignedTx; +} + +/** + * Corridor scenario tests for the BRL offramp swap path (USDC on Base → pix + * via Avenia): quote and registration go through the real HTTP API, then the + * REAL PhaseProcessor drives initial → fundEphemeral → distributeFees → + * subsidizePreSwap → nablaApprove → nablaSwap → subsidizePostSwap → + * brlaPayoutOnBase → complete against the fake external world. Unlike the + * direct corridors, the Nabla swap and both EVM subsidy phases execute for + * real here, so the subsidy caps (F-001 class) are covered end to end. + */ +describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + // The EVM fee distribution transaction builder requires the vortex + // partner's EVM payout address even when the resulting fees are zero. + await Partner.update( + { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, + { where: { name: "vortex", rampType: RampDirection.SELL } } + ); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.brla.onPixOutputTicket = undefined; + world.brla.accountBalances = { BRLA: 1_000_000, USDC: 0, USDM: 0, USDT: 0 }; + world.brla.payoutTicketStatus = AveniaTicketStatus.PAID; + // Fresh subaccount wallet per test: the in-memory EVM ledger persists + // across tests, so a shared payout recipient would accumulate balances. + world.brla.subaccountEvmWallet = privateKeyToAccount(generatePrivateKey()).address.toLowerCase(); + // The initialize stage bridges Base USDC → Base USDC: the fake route's + // destination token must report USDC's 6 decimals or the quote pipeline + // mis-scales the swap input and pads the whole output with subsidy. + world.squidRouter.toTokenDecimals = 6; + // Deterministic Nabla quoter for USDC (6 decimals) → BRLA (18 decimals) + // at a flat 5 BRLA per USDC, matching the FakePrices 5 BRL/USD feed. + world.evm.onReadContract = (_network, params) => { + if (params.functionName === "quoteSwapExactTokensForTokens") { + const amountIn = params.args?.[0] as bigint; + return amountIn * 5n * 10n ** 12n; + } + return undefined; + }; + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: "pix" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + // The EVM→BRL route still requires a Substrate ephemeral in signingAccounts + // (validateOfframpQuote legacy default) even though this path never uses it. + async function createSubstrateEphemeralAddress(): Promise { + await cryptoWaitReady(); + const keyring = new Keyring({ type: "sr25519" }); + return keyring.addFromUri(mnemonicGenerate()).address; + } + + async function registerViaApi( + quoteId: string, + userId: string, + ephemeral: PrivateKeyAccount, + userWallet: PrivateKeyAccount + ): Promise<{ id: string }> { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { + pixDestination: PIX_KEY, + receiverTaxId: RECEIVER_TAX_ID, + taxId: TAX_ID, + walletAddress: userWallet.address + }, + quoteId, + signingAccounts: [ + { address: ephemeral.address, type: "EVM" }, + { address: await createSubstrateEphemeralAddress(), type: "Substrate" } + ] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string }; + } + + function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase): UnsignedTx { + const blueprint = unsignedTxs.find(tx => tx.phase === phase); + expect(blueprint, `missing ${phase} blueprint in persisted ramp state`).toBeDefined(); + return blueprint as UnsignedTx; + } + + async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx, nonce?: number): Promise<`0x${string}`> { + const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + return ephemeral.signTransaction({ + chainId: 8453, + data: txData.data, + gas: 600_000n, + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce: nonce ?? blueprint.nonce, + to: txData.to, + type: "eip1559", + value: BigInt(txData.value ?? "0") + }); + } + + /** + * Creates quote + registration through the HTTP API with a fresh ephemeral, + * signs the ephemeral phase blueprints exactly as issued, and stores them as + * presigned transactions the way /v1/ramp/update would. + */ + async function setUpRegisteredRamp(): Promise { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + const ramp = await registerViaApi(quote.id, user.id, ephemeral, userWallet); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const swapInputRaw = BigInt(persistedQuote?.metadata.nablaSwapEvm?.inputAmountForSwapRaw ?? "0"); + const swapOutputRaw = BigInt(persistedQuote?.metadata.nablaSwapEvm?.outputAmountRaw ?? "0"); + expect(swapInputRaw).toBeGreaterThan(0n); + expect(swapOutputRaw).toBeGreaterThan(0n); + + const rampState = await RampState.findByPk(ramp.id); + if (!rampState) { + throw new Error("Ramp state not found after registration"); + } + const unsignedTxs = rampState.unsignedTxs ?? []; + + const nablaApproveBlueprint = blueprintOf(unsignedTxs, "nablaApprove"); + const nablaSwapBlueprint = blueprintOf(unsignedTxs, "nablaSwap"); + const payoutBlueprint = blueprintOf(unsignedTxs, "brlaPayoutOnBase"); + + const signedNablaApprove = await signBlueprint(ephemeral, nablaApproveBlueprint); + const signedNablaSwap = await signBlueprint(ephemeral, nablaSwapBlueprint); + const signedPayout = await signBlueprint(ephemeral, payoutBlueprint); + + const presign = (blueprint: UnsignedTx, txData: `0x${string}`) => ({ + meta: {}, + network: blueprint.network, + nonce: blueprint.nonce, + phase: blueprint.phase, + signer: ephemeral.address, + txData + }); + + // The user broadcasts the source-of-funds USDC transfer from their own + // wallet; fundEphemeral verifies the reported hash against the blueprint. + const userBlueprint = blueprintOf(unsignedTxs, "squidRouterNoPermitTransfer"); + const userTxData = userBlueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const userTxHash = world.evm.broadcastUserTransaction(Networks.Base, userWallet.address, { + data: userTxData.data, + to: userTxData.to, + value: 0n + }); + + await rampState.update({ + presignedTxs: [ + presign(nablaApproveBlueprint, signedNablaApprove), + presign(nablaSwapBlueprint, signedNablaSwap), + presign(payoutBlueprint, signedPayout) + ], + state: { ...rampState.state, squidRouterNoPermitTransferHash: userTxHash } + }); + + return { + ephemeral, + payoutBlueprint, + quoteId: quote.id, + rampId: ramp.id, + signedNablaSwap, + signedPayout, + swapInputRaw, + swapOutputRaw + }; + } + + /** + * Scripts the fake world for the happy path: + * - the ephemeral has Base gas and the user's USDC already arrived, short by + * `usdcShortfallRaw` so subsidizePreSwap tops it up from the funding wallet, + * - raw ERC-20 transfers (serialized presigns and funding-wallet data txs) + * are applied to the in-memory ledger, + * - the broadcast Nabla swap credits the ephemeral's BRLA at the quoted output. + */ + function scriptHappyWorld(setup: CorridorSetup, options: { usdcShortfallRaw?: bigint; swapOutputRaw?: bigint } = {}): void { + const shortfall = options.usdcShortfallRaw ?? 0n; + const swapOutput = options.swapOutputRaw ?? setup.swapOutputRaw; + world.evm.setNativeBalance(Networks.Base, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Base, USDC_ON_BASE, setup.ephemeral.address, setup.swapInputRaw - shortfall); + world.evm.onTransaction = tx => { + if (tx.serialized === setup.signedNablaSwap) { + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, setup.ephemeral.address, swapOutput); + return; + } + const parsed = tx.serialized ? parseTransaction(tx.serialized as `0x${string}`) : { data: tx.data, to: tx.to }; + if (!parsed.to || !parsed.data) { + return; + } + let decoded: { functionName: string; args: readonly unknown[] }; + try { + decoded = decodeFunctionData({ abi: erc20Abi, data: parsed.data as `0x${string}` }); + } catch { + return; + } + if (decoded.functionName !== "transfer") { + return; + } + const [recipient, amount] = decoded.args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + function submissionsOf(signedTransfer: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTransfer).length; + } + + it( + "happy path: swap corridor completes with a capped pre-swap subsidy and pays the Avenia subaccount", + async () => { + const setup = await setUpRegisteredRamp(); + // 1 USDC short of the swap input: well below the 5% subsidy cap. + const shortfall = parseUnits("1", 6); + scriptHappyWorld(setup, { usdcShortfallRaw: shortfall }); + const pixOutBefore = world.brla.pixOutputTickets.length; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + // Quote stays consumed and the pre-swap shortfall was subsidized once. + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + const subsidies = await Subsidy.findAll(); + expect(subsidies.length).toBe(1); + // The handler stores the EvmToken value cast into the SubsidyToken column. + expect(subsidies[0].token).toBe(EvmToken.USDC as unknown as SubsidyToken); + expect(Number(subsidies[0].amount)).toBeCloseTo(1); + expect(subsidies[0].phase).toBe("subsidizePreSwap"); + + // The swap and payout were each broadcast exactly once; the Avenia + // subaccount wallet received exactly the swap output per the fake ledger. + expect(submissionsOf(setup.signedNablaSwap)).toBe(1); + expect(submissionsOf(setup.signedPayout)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(setup.swapOutputRaw); + expect(world.brla.pixOutputTickets.length).toBe(pixOutBefore + 1); + }, + 30000 + ); + + it( + "transient failure: a scripted RPC outage is recorded as recoverable and the corridor still completes", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + // Arm the outage once the swap has landed so it hits the NEXT broadcast — + // the brlaPayoutOnBase transfer, whose send failures are recoverable by + // design (a failed nablaSwap broadcast is deliberately unrecoverable). + const applyLedgerEffects = world.evm.onTransaction; + world.evm.sendFailureMessage = "FakeEvm: scripted RPC outage"; + world.evm.onTransaction = tx => { + applyLedgerEffects?.(tx); + if (tx.serialized === setup.signedNablaSwap) { + world.evm.failNextSends = 1; + } + }; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + // The payout handler wraps broadcast errors in its own recoverable message. + const outageLogs = final?.errorLogs.filter(log => log.error.includes("Failed to send BRLA payout transaction")) ?? []; + expect(outageLogs.length).toBeGreaterThanOrEqual(1); + expect(outageLogs.every(log => log.phase === "brlaPayoutOnBase")).toBe(true); + expect(outageLogs.some(log => log.recoverable === true)).toBe(true); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(setup.swapOutputRaw); + }, + 30000 + ); + + it( + "security regression: a presigned payout paying the wrong recipient is rejected by /v1/ramp/update", + async () => { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + const attacker = privateKeyToAccount(generatePrivateKey()).address; + + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + const ramp = await registerViaApi(quote.id, user.id, ephemeral, userWallet); + + const rampState = await RampState.findByPk(ramp.id); + const payoutBlueprint = blueprintOf(rampState?.unsignedTxs ?? [], "brlaPayoutOnBase"); + const blueprintData = payoutBlueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const { args } = decodeFunctionData({ abi: erc20Abi, data: blueprintData.data }); + const amount = (args as [string, bigint])[1]; + + // Same token, same amount — but the BRLA goes to the attacker instead of + // the Avenia subaccount wallet the blueprint demands. + const tamper = (nonce: number) => + ephemeral.signTransaction({ + chainId: 8453, + data: encodeFunctionData({ abi: erc20Abi, args: [attacker, amount], functionName: "transfer" }), + gas: 600_000n, + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce, + to: blueprintData.to, + type: "eip1559" + }); + const tamperedPayout = await tamper(payoutBlueprint.nonce); + const backups: Record = {}; + for (let i = 1; i <= 4; i++) { + backups[`backup${i}`] = { nonce: payoutBlueprint.nonce + i, txData: await tamper(payoutBlueprint.nonce + i) }; + } + + const updateResponse = await app.request("/v1/ramp/update", { + body: JSON.stringify({ + presignedTxs: [ + { + meta: { additionalTxs: backups }, + network: Networks.Base, + nonce: payoutBlueprint.nonce, + phase: "brlaPayoutOnBase", + signer: ephemeral.address, + txData: tamperedPayout + } + ], + rampId: ramp.id + }), + headers: { + Authorization: `Bearer ${testUserToken(user.id)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + + expect(updateResponse.status).toBe(400); + const body = (await updateResponse.json()) as { message?: string }; + expect(body.message ?? JSON.stringify(body)).toContain("does not match expected data"); + + // Nothing was stored and nothing can be broadcast. + const after = await RampState.findByPk(ramp.id); + expect(after?.presignedTxs ?? []).toEqual([]); + expect(submissionsOf(tamperedPayout)).toBe(0); + }, + 30000 + ); + + it( + "subsidy cap (F-001 class): a post-swap shortfall beyond the cap pauses the ramp instead of paying out", + async () => { + const setup = await setUpRegisteredRamp(); + // The swap yields only half the quoted BRLA: the discrepancy subsidy + // would be ~50% of the quote output, far beyond the 5% cap. + scriptHappyWorld(setup, { swapOutputRaw: setup.swapOutputRaw / 2n }); + + await phaseProcessor.processRamp(setup.rampId); + + const stuck = await RampState.findByPk(setup.rampId); + // The cap breach is a recoverable pause for operator intervention — the + // ramp must NOT be failed, NOT completed, and the lock must be released. + expect(stuck?.currentPhase).toBe("subsidizePostSwap"); + expect(stuck?.processingLock).toEqual({ locked: false, lockedAt: null }); + const capLogs = stuck?.errorLogs.filter(log => log.error.includes("exceeds cap")) ?? []; + expect(capLogs.length).toBeGreaterThanOrEqual(1); + expect(capLogs.every(log => log.recoverable === true)).toBe(true); + + // No subsidy was paid beyond the cap and the payout never reached the chain. + expect(await Subsidy.count()).toBe(0); + expect(submissionsOf(setup.signedPayout)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(0n); + }, + 60000 + ); + + it( + "subsidy cap (F-001 class): a pre-swap shortfall beyond the cap pauses the ramp before the swap", + async () => { + const setup = await setUpRegisteredRamp(); + // Only half the swap input arrived: the required top-up (~50% of the + // quote output) is far beyond the 5% pre-swap subsidy cap. + scriptHappyWorld(setup, { usdcShortfallRaw: setup.swapInputRaw / 2n }); + + await phaseProcessor.processRamp(setup.rampId); + + const stuck = await RampState.findByPk(setup.rampId); + expect(stuck?.currentPhase).toBe("subsidizePreSwap"); + expect(stuck?.processingLock).toEqual({ locked: false, lockedAt: null }); + const capLogs = stuck?.errorLogs.filter(log => log.error.includes("exceeds cap")) ?? []; + expect(capLogs.length).toBeGreaterThanOrEqual(1); + expect(capLogs.every(log => log.recoverable === true)).toBe(true); + + // Nothing was subsidized, swapped, or paid out. + expect(await Subsidy.count()).toBe(0); + expect(submissionsOf(setup.signedNablaSwap)).toBe(0); + expect(submissionsOf(setup.signedPayout)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(0n); + }, + 60000 + ); + + it( + "unrecoverable failure: a FAILED Avenia payout ticket fails the ramp during brlaPayoutOnBase", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.brla.payoutTicketStatus = AveniaTicketStatus.FAILED; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts new file mode 100644 index 000000000..1e1cf7cf9 --- /dev/null +++ b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts @@ -0,0 +1,386 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + EvmToken, + evmTokenConfig, + FiatToken, + Networks, + RampDirection, + type RampPhase, + type UnsignedTx +} from "@vortexfi/shared"; +import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import Partner from "../../models/partner.model"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestTaxId, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +function requireToken(network: Networks.Base | Networks.Arbitrum, token: EvmToken) { + const details = evmTokenConfig[network][token]; + if (!details) { + throw new Error(`${token} token config missing for ${network}`); + } + return details; +} +const USDC_ON_BASE = requireToken(Networks.Base, EvmToken.USDC).erc20AddressSourceChain as `0x${string}`; +const USDC_ON_ARBITRUM = requireToken(Networks.Arbitrum, EvmToken.USDC).erc20AddressSourceChain as `0x${string}`; +const BRLA_ON_BASE = requireToken(Networks.Base, EvmToken.BRLA).erc20AddressSourceChain as `0x${string}`; + +const TAX_ID = "12345678901"; + +const CHAIN_IDS: Partial> = { + [Networks.Arbitrum]: 42161, + [Networks.Base]: 8453 +}; + +// Unlike the direct pix→BRLA-on-Base corridor, the full swap-and-bridge chain +// executes here: Nabla swaps the minted BRLA into USDC on Base, the squid +// approve+swap bridge it to Arbitrum, and squidRouterPay settles via the +// destination-chain balance check before the Arbitrum payout. +const HAPPY_PATH_PHASES: RampPhase[] = [ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +interface CorridorSetup { + rampId: string; + quoteId: string; + /** Raw (6-decimal) USDC amount the presigned destination transfer pays out on Arbitrum. */ + amountRaw: bigint; + /** Raw (18-decimal) BRLA amount the Nabla swap consumes on Base. */ + swapInputRaw: bigint; + /** Raw (6-decimal) USDC amount the Nabla swap yields on Base. */ + swapOutputRaw: bigint; + /** Raw (6-decimal) USDC amount the squid bridge delivers on Arbitrum. */ + bridgedAmountRaw: bigint; + signedNablaSwap: `0x${string}`; + signedSquidApprove: `0x${string}`; + signedSquidSwap: `0x${string}`; + signedTransfer: `0x${string}`; + ephemeral: PrivateKeyAccount; + destination: `0x${string}`; +} + +/** + * Corridor scenario tests for the CROSS-CHAIN BRL onramp (pix → BRLA minted on + * Base → Nabla swap to USDC → SquidRouter bridge → USDC on Arbitrum). This is + * the route the resolver picks for any BRL BUY to a non-Base EVM destination + * (OnRampAveniaToEvmBase with the Base→EVM squid leg): quote and registration + * go through the real HTTP API, then the REAL PhaseProcessor drives the whole + * chain — mint, Nabla swap, squid approve+swap on Base, bridge settlement on + * Arbitrum, destination payout — against the fake external world. The direct + * BRL corridor and the MXN cross-chain corridor each cover only half of this + * path; failure modes of the shared handlers are covered in those files. + */ +describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Arbitrum)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + // The EVM fee distribution transaction builder requires the vortex + // partner's EVM payout address even when the resulting fees are zero. + await Partner.update( + { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, + { where: { name: "vortex", rampType: RampDirection.BUY } } + ); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.brla.onPixOutputTicket = undefined; + world.brla.accountBalances = { BRLA: 1_000_000, USDC: 0, USDM: 0, USDT: 0 }; + world.squidRouter.bridgeStatus = "success"; + // The bridge leg swaps 6-decimal Base USDC into 6-decimal Arbitrum USDC; + // the fake route must report matching decimals. + world.squidRouter.toTokenDecimals = 6; + // Deterministic Nabla quoter for BRLA (18 decimals) → USDC (6 decimals) at + // a flat 5 BRLA per USDC, matching the FakePrices 5 BRL/USD feed. + world.evm.onReadContract = (_network, params) => { + if (params.functionName === "quoteSwapExactTokensForTokens") { + const amountIn = params.args?.[0] as bigint; + return amountIn / 5n / 10n ** 12n; + } + return undefined; + }; + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: "pix", + inputAmount: "500", + inputCurrency: FiatToken.BRL, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status, `quote creation failed: ${await response.clone().text()}`).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + async function registerViaApi( + quoteId: string, + userId: string, + ephemeral: PrivateKeyAccount, + destination: `0x${string}` + ): Promise<{ id: string }> { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: destination, taxId: TAX_ID }, + quoteId, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status, `registration failed: ${await response.clone().text()}`).toBe(201); + return (await response.json()) as { id: string }; + } + + function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase): UnsignedTx { + const blueprint = unsignedTxs.find(tx => tx.phase === phase); + expect(blueprint, `missing ${phase} blueprint in persisted ramp state`).toBeDefined(); + return blueprint as UnsignedTx; + } + + async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx): Promise<`0x${string}`> { + const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const chainId = CHAIN_IDS[blueprint.network]; + if (!chainId) { + throw new Error(`No chain id mapped for ${blueprint.network}`); + } + return ephemeral.signTransaction({ + chainId, + data: txData.data, + gas: 600_000n, + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce: blueprint.nonce, + to: txData.to, + type: "eip1559", + value: BigInt(txData.value ?? "0") + }); + } + + /** + * Creates quote + registration through the HTTP API, then signs the + * ephemeral phase blueprints exactly as issued — the Nabla pair and squid + * pair on Base plus the destination transfer on Arbitrum — and stores them + * as presigned transactions the way /v1/ramp/update would. + */ + async function setUpRegisteredRamp(): Promise { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + const ramp = await registerViaApi(quote.id, user.id, ephemeral, destination); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const swapInputRaw = BigInt(persistedQuote?.metadata.nablaSwapEvm?.inputAmountForSwapRaw ?? "0"); + const swapOutputRaw = BigInt(persistedQuote?.metadata.nablaSwapEvm?.outputAmountRaw ?? "0"); + const bridgedAmountRaw = BigInt(persistedQuote?.metadata.evmToEvm?.outputAmountRaw ?? "0"); + expect(swapInputRaw).toBeGreaterThan(0n); + expect(swapOutputRaw).toBeGreaterThan(0n); + expect(bridgedAmountRaw).toBeGreaterThan(0n); + + const rampState = await RampState.findByPk(ramp.id); + if (!rampState) { + throw new Error("Ramp state not found after registration"); + } + const unsignedTxs = rampState.unsignedTxs ?? []; + + const nablaApproveBlueprint = blueprintOf(unsignedTxs, "nablaApprove"); + const nablaSwapBlueprint = blueprintOf(unsignedTxs, "nablaSwap"); + const squidApproveBlueprint = blueprintOf(unsignedTxs, "squidRouterApprove"); + const squidSwapBlueprint = blueprintOf(unsignedTxs, "squidRouterSwap"); + const transferBlueprint = blueprintOf(unsignedTxs, "destinationTransfer"); + expect(squidApproveBlueprint.network).toBe(Networks.Base); + expect(squidSwapBlueprint.network).toBe(Networks.Base); + expect(transferBlueprint.network).toBe(Networks.Arbitrum); + + const signedNablaApprove = await signBlueprint(ephemeral, nablaApproveBlueprint); + const signedNablaSwap = await signBlueprint(ephemeral, nablaSwapBlueprint); + const signedSquidApprove = await signBlueprint(ephemeral, squidApproveBlueprint); + const signedSquidSwap = await signBlueprint(ephemeral, squidSwapBlueprint); + const signedTransfer = await signBlueprint(ephemeral, transferBlueprint); + + const presign = (blueprint: UnsignedTx, txData: `0x${string}`) => ({ + meta: {}, + network: blueprint.network, + nonce: blueprint.nonce, + phase: blueprint.phase, + signer: ephemeral.address, + txData + }); + + await rampState.update({ + presignedTxs: [ + presign(nablaApproveBlueprint, signedNablaApprove), + presign(nablaSwapBlueprint, signedNablaSwap), + presign(squidApproveBlueprint, signedSquidApprove), + presign(squidSwapBlueprint, signedSquidSwap), + presign(transferBlueprint, signedTransfer) + ] + }); + + const transferTxData = transferBlueprint.txData as unknown as { data: `0x${string}` }; + const { args } = decodeFunctionData({ abi: erc20Abi, data: transferTxData.data }); + const amountRaw = (args as [string, bigint])[1]; + + return { + amountRaw, + bridgedAmountRaw, + destination, + ephemeral, + quoteId: quote.id, + rampId: ramp.id, + signedNablaSwap, + signedSquidApprove, + signedSquidSwap, + signedTransfer, + swapInputRaw, + swapOutputRaw + }; + } + + /** + * Scripts the fake world so every polling loop succeeds on its first check: + * - the Avenia subaccount holds the minted BRL and the mint ticket credits + * the ephemeral's BRLA on Base instantly, + * - the ephemeral has gas on Base AND Arbitrum (destination funding), + * - the broadcast Nabla swap credits the ephemeral's Base USDC, + * - the broadcast squid swap credits the bridged USDC on Arbitrum, + * - raw ERC-20 transfers are applied to the in-memory ledger. + */ + function scriptHappyWorld(setup: CorridorSetup): void { + world.evm.setNativeBalance(Networks.Base, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setNativeBalance(Networks.Arbitrum, setup.ephemeral.address, parseUnits("2", 18)); + world.brla.onPixOutputTicket = ({ walletAddress }) => { + if (walletAddress) { + // Generous credit (same as the direct corridor): the mint handler + // polls for the full live-quote amount, which sits slightly above the + // pre-computed swap input. + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, walletAddress, parseUnits("1000000", 18)); + } + }; + world.evm.onTransaction = tx => { + if (tx.serialized === setup.signedNablaSwap) { + world.evm.setErc20Balance(Networks.Base, USDC_ON_BASE, setup.ephemeral.address, setup.swapOutputRaw); + return; + } + if (tx.serialized === setup.signedSquidSwap) { + world.evm.setErc20Balance( + Networks.Arbitrum, + USDC_ON_ARBITRUM, + setup.ephemeral.address, + world.evm.erc20Balance(Networks.Arbitrum, USDC_ON_ARBITRUM, setup.ephemeral.address) + setup.bridgedAmountRaw + ); + return; + } + const parsed = tx.serialized ? parseTransaction(tx.serialized as `0x${string}`) : { data: tx.data, to: tx.to }; + if (!parsed.to || !parsed.data) { + return; + } + let decoded: { functionName: string; args: readonly unknown[] }; + try { + decoded = decodeFunctionData({ abi: erc20Abi, data: parsed.data as `0x${string}` }); + } catch { + return; + } + if (decoded.functionName !== "transfer") { + return; + } + const [recipient, amount] = decoded.args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + function submissionsOf(signedTx: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTx).length; + } + + it( + "happy path: mints on Base, swaps BRLA to USDC via Nabla, bridges via squid, and pays the destination on Arbitrum", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const pixOutBefore = world.brla.pixOutputTickets.length; + + // Registration requested a Base USDC → Arbitrum USDC squid route. + const registrationRoute = world.squidRouter.requestedRoutes.find( + route => + route.fromToken.toLowerCase() === USDC_ON_BASE.toLowerCase() && + route.toToken.toLowerCase() === USDC_ON_ARBITRUM.toLowerCase() && + route.fromChain === "8453" && + route.toChain === "42161" + ); + expect(registrationRoute, "registration should request a Base→Arbitrum USDC route").toBeDefined(); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.state.squidRouterApproveHash).toBeTruthy(); + expect(final?.state.squidRouterSwapHash).toBeTruthy(); + + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + + // The full Avenia mint flow ran, the Nabla swap and both squid legs each + // hit Base exactly once, and the destination received exactly the quoted + // USDC on Arbitrum. + expect(world.brla.pixOutputTickets.length).toBe(pixOutBefore + 1); + expect(submissionsOf(setup.signedNablaSwap)).toBe(1); + expect(submissionsOf(setup.signedSquidApprove)).toBe(1); + expect(submissionsOf(setup.signedSquidSwap)).toBe(1); + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Arbitrum, USDC_ON_ARBITRUM, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/corridors/brl-onramp.scenario.test.ts b/apps/api/src/tests/corridors/brl-onramp.scenario.test.ts new file mode 100644 index 000000000..76eb75878 --- /dev/null +++ b/apps/api/src/tests/corridors/brl-onramp.scenario.test.ts @@ -0,0 +1,383 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { EvmToken, evmTokenConfig, FiatToken, Networks, RampDirection, type RampPhase } from "@vortexfi/shared"; +import { decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import Subsidy from "../../models/subsidy.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestTaxId, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +function requireBrlaOnBase() { + const details = evmTokenConfig[Networks.Base][EvmToken.BRLA]; + if (!details) { + throw new Error("BRLA token config missing for Base"); + } + return details; +} +const brlaTokenDetails = requireBrlaOnBase(); +const BRLA_ON_BASE = brlaTokenDetails.erc20AddressSourceChain as `0x${string}`; + +const TAX_ID = "12345678901"; +const HAPPY_PATH_PHASES: RampPhase[] = ["initial", "brlaOnrampMint", "fundEphemeral", "destinationTransfer", "complete"]; + +interface CorridorSetup { + rampId: string; + quoteId: string; + /** Raw (18-decimal) BRLA amount the presigned transfer pays out. */ + amountRaw: bigint; + signedTransfer: `0x${string}`; + ephemeral: PrivateKeyAccount; + destination: `0x${string}`; +} + +/** + * Corridor scenario tests for the BRL onramp direct path (pix → BRLA on Base): + * quote and registration go through the real HTTP API, then the REAL + * PhaseProcessor drives initial → brlaOnrampMint → fundEphemeral → + * destinationTransfer → complete against the fake external world. + */ +describe("BRL onramp direct corridor (pix → BRLA on Base)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.brla.onPixOutputTicket = undefined; + world.brla.accountBalances = { BRLA: 0, USDC: 0, USDM: 0, USDT: 0 }; + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: "pix", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + rampType: RampDirection.BUY, + to: Networks.Base + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + async function registerViaApi( + quoteId: string, + userId: string, + ephemeral: PrivateKeyAccount, + destination: `0x${string}` + ): Promise<{ id: string }> { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: destination, taxId: TAX_ID }, + quoteId, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string }; + } + + /** + * Creates quote + registration through the HTTP API with a fresh ephemeral + * key pair (fresh addresses keep the in-memory EVM ledger isolated between + * tests), then stores a REAL signed ERC-20 transfer as the presigned + * destinationTransfer. Pass a recipient to sign a transfer that pays someone + * other than the registered destination. + */ + async function setUpRegisteredRamp(options: { recipient?: `0x${string}` } = {}): Promise { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + const ramp = await registerViaApi(quote.id, user.id, ephemeral, destination); + + const amountRaw = parseUnits(quote.outputAmount, brlaTokenDetails.decimals); + const signedTransfer = await ephemeral.signTransaction({ + chainId: 8453, + data: encodeFunctionData({ + abi: erc20Abi, + args: [options.recipient ?? destination, amountRaw], + functionName: "transfer" + }), + gas: 100_000n, + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: BRLA_ON_BASE, + type: "eip1559" + }); + + const rampState = await RampState.findByPk(ramp.id); + if (!rampState) { + throw new Error("Ramp state not found after registration"); + } + await rampState.update({ + presignedTxs: [ + { + meta: {}, + network: Networks.Base, + nonce: 0, + phase: "destinationTransfer", + signer: ephemeral.address, + txData: signedTransfer + } + ] + }); + + return { amountRaw, destination, ephemeral, quoteId: quote.id, rampId: ramp.id, signedTransfer }; + } + + /** + * Scripts the fake world so every polling loop in the corridor succeeds on + * its first (immediate) check: + * - the Avenia subaccount already holds the minted BRL, + * - the ephemeral already has Base gas, so fundEphemeral sends nothing, + * - the Avenia→Base transfer ticket credits the ephemeral's BRLA instantly, + * - submitted raw ERC-20 transfers are applied to the in-memory ledger. + */ + function scriptHappyWorld(setup: CorridorSetup): void { + world.brla.accountBalances.BRLA = 1_000_000; + world.evm.setNativeBalance(Networks.Base, setup.ephemeral.address, parseUnits("0.001", 18)); + world.brla.onPixOutputTicket = ({ walletAddress }) => { + if (walletAddress) { + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, walletAddress, parseUnits("1000000", 18)); + } + }; + world.evm.onTransaction = tx => { + if (!tx.serialized) { + return; + } + const parsed = parseTransaction(tx.serialized as `0x${string}`); + if (!parsed.to || !parsed.data) { + return; + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + if (functionName !== "transfer") { + return; + } + const [recipient, amount] = args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + function submissionsOf(signedTransfer: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTransfer).length; + } + + it( + "happy path: processes initial → brlaOnrampMint → fundEphemeral → destinationTransfer → complete", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const pixOutBefore = world.brla.pixOutputTickets.length; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.state.destinationTransferTxHash).toBeTruthy(); + + // Quote stays consumed; no subsidy is expected on the direct corridor. + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + expect(await Subsidy.count()).toBe(0); + + // The full Avenia mint flow ran (recovery shortcut not taken) and the + // destination received exactly the quoted BRLA per the fake ledger. + expect(world.brla.pixOutputTickets.length).toBe(pixOutBefore + 1); + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + "transient failure: retries a failed destinationTransfer broadcast (recoverable) and still completes", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.evm.failNextSends = 1; + world.evm.sendFailureMessage = "FakeEvm: scripted RPC outage"; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + // The scripted outage was recorded as a recoverable destinationTransfer error... + const outageLogs = final?.errorLogs.filter(log => log.error.includes("scripted RPC outage")) ?? []; + expect(outageLogs.length).toBeGreaterThanOrEqual(1); + expect(outageLogs.every(log => log.phase === "destinationTransfer")).toBe(true); + expect(outageLogs.some(log => log.recoverable === true)).toBe(true); + + // ...and the transfer was broadcast exactly once (first attempt never hit the chain). + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + "security regression: presigned transfer paying the wrong recipient fails the ramp unrecoverably", + async () => { + const wrongRecipient = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + const setup = await setUpRegisteredRamp({ recipient: wrongRecipient }); + scriptHappyWorld(setup); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.errorLogs.some(log => log.error.includes("recipient mismatch"))).toBe(true); + + // The mismatching transfer must never reach the chain, and nobody gets paid. + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, wrongRecipient)).toBe(0n); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, setup.destination)).toBe(0n); + + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + }, + 30000 + ); + + it( + "retry exhaustion: a permanently failing broadcast stops processing without moving funds, and stays resumable", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.evm.failNextSends = 100; + world.evm.sendFailureMessage = "FakeEvm: scripted permanent outage"; + + await phaseProcessor.processRamp(setup.rampId); + + // Per docs/security-spec/03-ramp-engine/state-machine.md (F-004): after the + // recoverable-retry budget is exhausted the processor stops WITHOUT a + // terminal transition — the ramp stays in its phase, the lock is released, + // and nothing was broadcast. + const stuck = await RampState.findByPk(setup.rampId); + expect(stuck?.currentPhase).toBe("destinationTransfer"); + expect(stuck?.processingLock).toEqual({ locked: false, lockedAt: null }); + const outageLogs = stuck?.errorLogs.filter(log => log.error.includes("scripted permanent outage")) ?? []; + // 1 initial attempt + MAX_RETRIES (8) retries. + expect(outageLogs.length).toBe(9); + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, setup.destination)).toBe(0n); + + // Once the outage clears, a fresh processing cycle completes the ramp. + world.evm.failNextSends = 0; + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + "lock takeover: an expired stale lock (e.g. from a crashed process) is reclaimed and the ramp completes", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + await RampState.update( + { processingLock: { locked: true, lockedAt: new Date(Date.now() - 16 * 60 * 1000) } }, + { where: { id: setup.rampId } } + ); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + "lock respect: a fresh foreign lock is neither processed past nor clobbered", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const foreignLockedAt = new Date(); + await RampState.update( + { processingLock: { locked: true, lockedAt: foreignLockedAt } }, + { where: { id: setup.rampId } } + ); + const sentBefore = world.evm.sentTransactions.length; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("initial"); + expect(final?.processingLock.locked).toBe(true); + expect(new Date(final?.processingLock.lockedAt as unknown as string).getTime()).toBe(foreignLockedAt.getTime()); + expect(world.evm.sentTransactions.length).toBe(sentBefore); + }, + 30000 + ); + + it( + "lock behavior: concurrent processRamp calls execute each phase exactly once", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const pixOutBefore = world.brla.pixOutputTickets.length; + + await Promise.all([phaseProcessor.processRamp(setup.rampId), phaseProcessor.processRamp(setup.rampId)]); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + // No duplicated phase execution: one mint ticket, one broadcast, one payout, a single clean history. + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(world.brla.pixOutputTickets.length).toBe(pixOutBefore + 1); + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts b/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts new file mode 100644 index 000000000..07151bc2e --- /dev/null +++ b/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts @@ -0,0 +1,485 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + EphemeralAccountType, + EvmToken, + evmTokenConfig, + FiatToken, + MykoboTransactionStatus, + MykoboTransactionType, + Networks, + RampDirection, + type RampPhase, + type UnsignedTx +} from "@vortexfi/shared"; +import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import { validateEphemeralAccountsFresh } from "../../api/services/ramp/ephemeral-freshness"; +import { normalizeAndValidateSigningAccounts } from "../../api/services/ramp/ramp.service"; +import { prepareOfframpTransactions } from "../../api/services/transactions/offramp"; +import Partner from "../../models/partner.model"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import Subsidy from "../../models/subsidy.model"; +import type { SubsidyToken } from "../../models/subsidy.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestRampState, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +function requireBaseToken(token: EvmToken) { + const details = evmTokenConfig[Networks.Base][token]; + if (!details) { + throw new Error(`${token} token config missing for Base`); + } + return details; +} +const USDC_ON_BASE = requireBaseToken(EvmToken.USDC).erc20AddressSourceChain as `0x${string}`; +const EURC_ON_BASE = requireBaseToken(EvmToken.EURC).erc20AddressSourceChain as `0x${string}`; + +const IP_ADDRESS = "203.0.113.7"; + +const HAPPY_PATH_PHASES: RampPhase[] = [ + "initial", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "mykoboPayoutOnBase", + "complete" +]; + +interface CorridorSetup { + rampId: string; + quoteId: string; + /** Raw (6-decimal) USDC amount the Nabla swap consumes. */ + swapInputRaw: bigint; + /** Raw (6-decimal) EURC amount the swap yields. */ + swapOutputRaw: bigint; + /** Raw (6-decimal) EURC amount the payout transfers to Mykobo's receivables. */ + payoutAmountRaw: bigint; + signedNablaSwap: `0x${string}`; + signedPayout: `0x${string}`; + ephemeral: PrivateKeyAccount; + mykoboTransactionId: string; + receivablesAddress: string; +} + +/** + * Corridor scenario tests for the EUR offramp (USDC on Base → SEPA via + * Mykobo): the quote goes through the real HTTP API; registration is seeded + * through the SAME code the registration service runs below its EUR + * kill-switch (`registerRamp` throws 503 for EURC quotes before preparing any + * transaction, so the HTTP entry point is unavailable — the seeding helper + * mirrors only the thin glue and calls the REAL `prepareOfframpTransactions`, + * which resolves the KYC-gated Mykobo customer, creates the WITHDRAW intent + * and builds all blueprints). The REAL PhaseProcessor then drives initial → + * fundEphemeral → distributeFees → subsidizePreSwap → nablaApprove → + * nablaSwap → subsidizePostSwap → mykoboPayoutOnBase → complete against the + * fake external world. + * + * This is the hermetic-coverage precondition documented next to the + * kill-switch and in docs/testing-strategy.md ("EUR re-enablement + * precondition"). The kill-switch itself stays on; once lifted, replace the + * seeding helper with a plain POST /v1/ramp/register like the BRL corridor. + */ +describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + // The EVM fee distribution transaction builder requires the vortex + // partner's EVM payout address even when the resulting fees are zero. + await Partner.update( + { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, + { where: { name: "vortex", rampType: RampDirection.SELL } } + ); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.mykobo.failNextIntent = null; + world.mykobo.profileKycReviewStatus = "approved"; + // Fresh receivables address per test: the in-memory EVM ledger persists + // across tests, so a shared payout recipient would accumulate balances. + world.mykobo.withdrawReceivablesAddress = privateKeyToAccount(generatePrivateKey()).address.toLowerCase(); + // The initialize stage bridges Base USDC → Base USDC: the fake route's + // destination token must report USDC's 6 decimals or the quote pipeline + // mis-scales the swap input and pads the whole output with subsidy. + world.squidRouter.toTokenDecimals = 6; + // Deterministic Nabla quoter for USDC → EURC (both 6 decimals) at a flat + // 0.9 EURC per USDC, matching the FakePrices 0.9 EUR/USD feed. The EURC + // token itself shares the euro peg for USD conversions. + world.prices.perUsd.eurc = 0.9; + world.evm.onReadContract = (_network, params) => { + if (params.functionName === "quoteSwapExactTokensForTokens") { + const amountIn = params.args?.[0] as bigint; + return (amountIn * 9n) / 10n; + } + return undefined; + }; + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency: FiatToken.EURC, + rampType: RampDirection.SELL, + to: "sepa" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + /** + * Kill-switch check: the REAL /v1/ramp/register endpoint must keep refusing + * EUR quotes with 503 until the re-enablement precondition is lifted. The + * seeding below deliberately starts where this rejection ends. + */ + async function assertRegisterEndpointStillKillSwitched(quoteId: string, userId: string, wallet: string): Promise { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: wallet, ipAddress: IP_ADDRESS, walletAddress: wallet }, + quoteId, + signingAccounts: [{ address: wallet, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(503); + } + + function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase): UnsignedTx { + const blueprint = unsignedTxs.find(tx => tx.phase === phase); + expect(blueprint, `missing ${phase} blueprint in persisted ramp state`).toBeDefined(); + return blueprint as UnsignedTx; + } + + async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx): Promise<`0x${string}`> { + const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + return ephemeral.signTransaction({ + chainId: 8453, + data: txData.data, + gas: 600_000n, + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce: blueprint.nonce, + to: txData.to, + type: "eip1559", + value: BigInt(txData.value ?? "0") + }); + } + + /** + * Registers the ramp exactly as `RampService.registerRamp` would if the EUR + * kill-switch were lifted: signing-account normalization, ephemeral + * freshness validation, the REAL offramp transaction preparation (which + * creates the Mykobo WITHDRAW intent), quote consumption, and a RampState + * row with the identical shape. Then signs the ephemeral blueprints exactly + * as issued and stores them the way /v1/ramp/update would, and broadcasts + * the user's source-of-funds USDC transfer whose hash fundEphemeral + * verifies against the blueprint. + */ + async function setUpRegisteredRamp(): Promise { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + + const user = await createTestUser(); + const quote = await createQuoteViaApi(); + await assertRegisterEndpointStillKillSwitched(quote.id, user.id, userWallet.address); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + if (!persistedQuote) { + throw new Error("Quote not persisted"); + } + const swapInputRaw = BigInt(persistedQuote.metadata.nablaSwapEvm?.inputAmountForSwapRaw ?? "0"); + const swapOutputRaw = BigInt(persistedQuote.metadata.nablaSwapEvm?.outputAmountRaw ?? "0"); + expect(swapInputRaw).toBeGreaterThan(0n); + expect(swapOutputRaw).toBeGreaterThan(0n); + + const additionalData = { + destinationAddress: userWallet.address, + ipAddress: IP_ADDRESS, + walletAddress: userWallet.address + }; + const { normalizedSigningAccounts, ephemerals } = normalizeAndValidateSigningAccounts([ + { address: ephemeral.address, type: EphemeralAccountType.EVM } + ]); + await validateEphemeralAccountsFresh(ephemerals); + + const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({ + destinationAddress: additionalData.destinationAddress, + ipAddress: additionalData.ipAddress, + quote: persistedQuote, + signingAccounts: normalizedSigningAccounts, + userAddress: additionalData.walletAddress, + userId: user.id + }); + + const [consumed] = await QuoteTicket.update( + { status: "consumed" }, + { where: { id: persistedQuote.id, status: "pending" } } + ); + expect(consumed).toBe(1); + + const rampState = await createTestRampState({ + currentPhase: "initial", + from: persistedQuote.from, + paymentMethod: persistedQuote.paymentMethod, + phaseHistory: [{ phase: "initial", timestamp: new Date() }], + quoteId: persistedQuote.id, + state: { + evmEphemeralAddress: ephemerals.EVM, + substrateEphemeralAddress: ephemerals.Substrate, + ...additionalData, + ...stateMeta + } as unknown as RampState["state"], + to: persistedQuote.to, + type: persistedQuote.rampType, + unsignedTxs, + userId: user.id + }); + + const nablaApproveBlueprint = blueprintOf(unsignedTxs, "nablaApprove"); + const nablaSwapBlueprint = blueprintOf(unsignedTxs, "nablaSwap"); + const payoutBlueprint = blueprintOf(unsignedTxs, "mykoboPayoutOnBase"); + + const payoutData = payoutBlueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const decodedPayout = decodeFunctionData({ abi: erc20Abi, data: payoutData.data }); + const [payoutRecipient, payoutAmountRaw] = decodedPayout.args as [string, bigint]; + expect(payoutRecipient.toLowerCase()).toBe(world.mykobo.withdrawReceivablesAddress); + + const signedNablaApprove = await signBlueprint(ephemeral, nablaApproveBlueprint); + const signedNablaSwap = await signBlueprint(ephemeral, nablaSwapBlueprint); + const signedPayout = await signBlueprint(ephemeral, payoutBlueprint); + + const presign = (blueprint: UnsignedTx, txData: `0x${string}`) => ({ + meta: {}, + network: blueprint.network, + nonce: blueprint.nonce, + phase: blueprint.phase, + signer: ephemeral.address, + txData + }); + + // The user broadcasts the source-of-funds USDC transfer from their own + // wallet; fundEphemeral verifies the reported hash against the blueprint. + const userBlueprint = blueprintOf(unsignedTxs, "squidRouterNoPermitTransfer"); + const userTxData = userBlueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const userTxHash = world.evm.broadcastUserTransaction(Networks.Base, userWallet.address, { + data: userTxData.data, + to: userTxData.to, + value: 0n + }); + + await rampState.update({ + presignedTxs: [ + presign(nablaApproveBlueprint, signedNablaApprove), + presign(nablaSwapBlueprint, signedNablaSwap), + presign(payoutBlueprint, signedPayout) + ], + state: { ...rampState.state, squidRouterNoPermitTransferHash: userTxHash } + }); + + return { + ephemeral, + mykoboTransactionId: stateMeta.mykoboTransactionId as string, + payoutAmountRaw, + quoteId: persistedQuote.id, + rampId: rampState.id, + receivablesAddress: world.mykobo.withdrawReceivablesAddress, + signedNablaSwap, + signedPayout, + swapInputRaw, + swapOutputRaw + }; + } + + /** + * Scripts the fake world for the happy path: + * - the ephemeral has Base gas and the user's USDC already arrived, short by + * `usdcShortfallRaw` so subsidizePreSwap tops it up from the funding wallet, + * - raw ERC-20 transfers are applied to the in-memory ledger, + * - the broadcast Nabla swap credits the ephemeral's EURC at the quoted output, + * - Mykobo reports the withdraw transaction as COMPLETED once polled. + */ + function scriptHappyWorld(setup: CorridorSetup, options: { usdcShortfallRaw?: bigint } = {}): void { + const shortfall = options.usdcShortfallRaw ?? 0n; + world.evm.setNativeBalance(Networks.Base, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Base, USDC_ON_BASE, setup.ephemeral.address, setup.swapInputRaw - shortfall); + world.mykobo.setTransactionStatus(setup.mykoboTransactionId, MykoboTransactionStatus.COMPLETED); + world.evm.onTransaction = tx => { + if (tx.serialized === setup.signedNablaSwap) { + world.evm.setErc20Balance(Networks.Base, EURC_ON_BASE, setup.ephemeral.address, setup.swapOutputRaw); + return; + } + const parsed = tx.serialized ? parseTransaction(tx.serialized as `0x${string}`) : { data: tx.data, to: tx.to }; + if (!parsed.to || !parsed.data) { + return; + } + let decoded: { functionName: string; args: readonly unknown[] }; + try { + decoded = decodeFunctionData({ abi: erc20Abi, data: parsed.data as `0x${string}` }); + } catch { + return; + } + if (decoded.functionName !== "transfer") { + return; + } + const [recipient, amount] = decoded.args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + function submissionsOf(signedTransfer: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTransfer).length; + } + + it( + "happy path: swap corridor completes with a capped pre-swap subsidy and pays Mykobo's receivables in full", + async () => { + const setup = await setUpRegisteredRamp(); + // 1 USDC short of the swap input: well below the 5% subsidy cap. + const shortfall = parseUnits("1", 6); + scriptHappyWorld(setup, { usdcShortfallRaw: shortfall }); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + // Quote stays consumed and the pre-swap shortfall was subsidized once. + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + const subsidies = await Subsidy.findAll(); + expect(subsidies.length).toBe(1); + expect(subsidies[0].token).toBe(EvmToken.USDC as unknown as SubsidyToken); + expect(Number(subsidies[0].amount)).toBeCloseTo(1); + expect(subsidies[0].phase).toBe("subsidizePreSwap"); + + // The swap and payout were each broadcast exactly once; Mykobo's + // receivables wallet received exactly the intent value in EURC. + expect(submissionsOf(setup.signedNablaSwap)).toBe(1); + expect(submissionsOf(setup.signedPayout)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, EURC_ON_BASE, setup.receivablesAddress)).toBe(setup.payoutAmountRaw); + + // Registration created exactly one WITHDRAW intent whose 2-decimal value + // matches the on-chain payout (Mykobo truncates to cents). + const intents = world.mykobo.intents.filter(intent => intent.wallet_address === setup.ephemeral.address); + expect(intents.length).toBe(1); + expect(intents[0].transaction_type).toBe(MykoboTransactionType.WITHDRAW); + expect(parseUnits(intents[0].value, 6)).toBe(setup.payoutAmountRaw); + }, + 30000 + ); + + it( + "transient failure: a scripted RPC outage on the payout is recorded as recoverable and the corridor still completes", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + // Arm the outage once the swap has landed so it hits the NEXT broadcast — + // the mykoboPayoutOnBase transfer, whose send failures are recoverable. + const applyLedgerEffects = world.evm.onTransaction; + world.evm.sendFailureMessage = "FakeEvm: scripted RPC outage"; + world.evm.onTransaction = tx => { + applyLedgerEffects?.(tx); + if (tx.serialized === setup.signedNablaSwap) { + world.evm.failNextSends = 1; + } + }; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + // The payout handler wraps broadcast errors in its own recoverable message. + const outageLogs = final?.errorLogs.filter(log => log.error.includes("Failed to send Mykobo payout transaction")) ?? []; + expect(outageLogs.length).toBeGreaterThanOrEqual(1); + expect(outageLogs.every(log => log.phase === "mykoboPayoutOnBase")).toBe(true); + expect(outageLogs.some(log => log.recoverable === true)).toBe(true); + expect(world.evm.erc20Balance(Networks.Base, EURC_ON_BASE, setup.receivablesAddress)).toBe(setup.payoutAmountRaw); + }, + 30000 + ); + + it( + "unrecoverable failure: a FAILED Mykobo withdraw transaction fails the ramp during mykoboPayoutOnBase", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.mykobo.setTransactionStatus(setup.mykoboTransactionId, MykoboTransactionStatus.FAILED); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.errorLogs.some(log => log.error.includes("ended with status FAILED"))).toBe(true); + }, + 30000 + ); + + it( + "registration guard: a Mykobo WITHDRAW intent failure aborts the registration path before any blueprint exists", + async () => { + const user = await createTestUser(); + const quote = await createQuoteViaApi(); + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + world.mykobo.failNextIntent = new Error("FakeMykobo: scripted intent failure"); + + const { normalizedSigningAccounts } = normalizeAndValidateSigningAccounts([ + { address: ephemeral.address, type: EphemeralAccountType.EVM } + ]); + await expect( + prepareOfframpTransactions({ + destinationAddress: userWallet.address, + ipAddress: IP_ADDRESS, + quote: persistedQuote as QuoteTicket, + signingAccounts: normalizedSigningAccounts, + userAddress: userWallet.address, + userId: user.id + }) + ).rejects.toThrow("scripted intent failure"); + expect((await QuoteTicket.findByPk(quote.id))?.status).toBe("pending"); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts b/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts new file mode 100644 index 000000000..084d93296 --- /dev/null +++ b/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts @@ -0,0 +1,423 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + EphemeralAccountType, + EvmToken, + evmTokenConfig, + FiatToken, + type IbanPaymentData, + MykoboApiService, + MykoboCurrency, + MykoboCustomerStatus, + MykoboTransactionType, + Networks, + RampDirection, + type RampPhase, + type UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import { decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import { resolveMykoboCustomerForUser } from "../../api/services/mykobo/mykobo-customer.service"; +import { normalizeAndValidateSigningAccounts } from "../../api/services/ramp/ramp.service"; +import { validateEphemeralAccountsFresh } from "../../api/services/ramp/ephemeral-freshness"; +import { prepareMykoboToEvmOnrampTransactions } from "../../api/services/transactions/onramp/routes/mykobo-to-evm"; +import MykoboCustomer from "../../models/mykoboCustomer.model"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestRampState, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +function requireEurcOnBase() { + const details = evmTokenConfig[Networks.Base][EvmToken.EURC]; + if (!details) { + throw new Error("EURC token config missing for Base"); + } + return details; +} +const eurcTokenDetails = requireEurcOnBase(); +const EURC_ON_BASE = eurcTokenDetails.erc20AddressSourceChain as `0x${string}`; + +const IP_ADDRESS = "203.0.113.7"; + +const HAPPY_PATH_PHASES: RampPhase[] = ["initial", "mykoboOnrampDeposit", "fundEphemeral", "destinationTransfer", "complete"]; + +interface CorridorSetup { + rampId: string; + quoteId: string; + /** Raw (6-decimal) EURC amount Mykobo settles on the ephemeral (input minus anchor fee). */ + mykoboMintRaw: bigint; + /** Raw (6-decimal) EURC amount the presigned transfer pays out. */ + amountRaw: bigint; + signedTransfer: `0x${string}`; + ephemeral: PrivateKeyAccount; + destination: `0x${string}`; + mykoboTransactionId: string; +} + +/** + * Corridor scenario tests for the EUR onramp direct path (SEPA → EURC on Base + * via Mykobo): the quote goes through the real HTTP API; registration is then + * seeded through the SAME code the registration service runs below its EUR + * kill-switch (`registerRamp` in ramp.service.ts throws 503 for EURC quotes + * BEFORE preparing transactions, so the HTTP entry point is unavailable — see + * `registerEurOnrampBelowKillSwitch` below). The REAL PhaseProcessor then + * drives initial → mykoboOnrampDeposit → fundEphemeral → destinationTransfer + * → complete against the fake external world. + * + * This scenario is the hermetic-coverage precondition documented next to the + * kill-switch and in docs/testing-strategy.md ("EUR re-enablement + * precondition"). The kill-switch itself stays on; once it is lifted, replace + * the seeding helper with a plain POST /v1/ramp/register like the BRL/MXN + * corridor files. + */ +describe("EUR onramp direct corridor (SEPA → EURC on Base via Mykobo)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.mykobo.failNextIntent = null; + world.mykobo.profileKycReviewStatus = "approved"; + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: "sepa", + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency: EvmToken.EURC, + rampType: RampDirection.BUY, + to: Networks.Base + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + /** + * Kill-switch check: the REAL /v1/ramp/register endpoint must keep refusing + * EUR quotes with 503 until the re-enablement precondition is lifted. The + * seeding below deliberately starts where this rejection ends. + */ + async function assertRegisterEndpointStillKillSwitched(quoteId: string, userId: string, destination: string): Promise { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: destination, ipAddress: IP_ADDRESS }, + quoteId, + signingAccounts: [{ address: destination, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(503); + } + + /** + * Registers the ramp exactly as `RampService.registerRamp` would if the EUR + * kill-switch were lifted, by running the same sequence of service calls the + * method performs below the switch (ramp.service.ts): signing-account + * normalization, ephemeral freshness validation, the Mykobo customer/KYC + * resolution + deposit intent (mirroring prepareMykoboOnrampTransactions), + * the REAL transaction builder, quote consumption, and a RampState row with + * the identical shape. No registration logic is re-implemented — only the + * thin glue is mirrored. + */ + async function registerEurOnrampBelowKillSwitch( + quote: QuoteTicket, + userId: string, + ephemeral: PrivateKeyAccount, + destination: `0x${string}` + ): Promise { + const additionalData = { destinationAddress: destination, ipAddress: IP_ADDRESS }; + + const { normalizedSigningAccounts, ephemerals } = normalizeAndValidateSigningAccounts([ + { address: ephemeral.address, type: EphemeralAccountType.EVM } + ]); + await validateEphemeralAccountsFresh(ephemerals); + + // Mirrors prepareMykoboOnrampTransactions: derive the Mykobo identity from + // the user's profile (KYC-gated), create the deposit intent, then build. + const { email } = await resolveMykoboCustomerForUser(userId); + const intent = await MykoboApiService.getInstance().createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: email, + ip_address: additionalData.ipAddress, + transaction_type: MykoboTransactionType.DEPOSIT, + value: new Big(quote.inputAmount).toFixed(2, 0), + wallet_address: ephemeral.address + }); + if (!intent.instructions || !("iban" in intent.instructions)) { + throw new Error("FakeMykobo deposit intent did not return IBAN instructions"); + } + + const { unsignedTxs, stateMeta } = await prepareMykoboToEvmOnrampTransactions({ + destinationAddress: additionalData.destinationAddress, + ipAddress: additionalData.ipAddress, + mykoboEmail: email, + mykoboTransactionId: intent.transaction.id, + mykoboTransactionReference: intent.transaction.reference, + quote, + signingAccounts: normalizedSigningAccounts + }); + + const ibanPaymentData: IbanPaymentData = { + bic: "", + iban: intent.instructions.iban, + receiverName: intent.instructions.bank_account_name, + reference: intent.transaction.reference + }; + + const [consumed] = await QuoteTicket.update({ status: "consumed" }, { where: { id: quote.id, status: "pending" } }); + expect(consumed).toBe(1); + + return createTestRampState({ + currentPhase: "initial", + from: quote.from, + paymentMethod: quote.paymentMethod, + phaseHistory: [{ phase: "initial", timestamp: new Date() }], + quoteId: quote.id, + state: { + evmEphemeralAddress: ephemerals.EVM, + ibanPaymentData, + substrateEphemeralAddress: ephemerals.Substrate, + ...additionalData, + ...stateMeta + } as unknown as RampState["state"], + to: quote.to, + type: quote.rampType, + unsignedTxs, + userId + }); + } + + /** + * Quote via the real HTTP API, registration via the below-kill-switch + * seeding, then a REAL signed EURC transfer stored as the presigned + * destinationTransfer (pass a recipient to tamper with the payee). + */ + async function setUpRegisteredRamp(options: { recipient?: `0x${string}` } = {}): Promise { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + + const user = await createTestUser(); + const quote = await createQuoteViaApi(); + await assertRegisterEndpointStillKillSwitched(quote.id, user.id, destination); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + if (!persistedQuote) { + throw new Error("Quote not persisted"); + } + const mykoboMintRaw = BigInt(persistedQuote.metadata.mykoboMint?.outputAmountRaw ?? "0"); + expect(mykoboMintRaw).toBeGreaterThan(0n); + + const rampState = await registerEurOnrampBelowKillSwitch(persistedQuote, user.id, ephemeral, destination); + + const blueprint = (rampState.unsignedTxs ?? []).find(tx => tx.phase === "destinationTransfer"); + expect(blueprint, "missing destinationTransfer blueprint").toBeDefined(); + const blueprintData = (blueprint as UnsignedTx).txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const decoded = decodeFunctionData({ abi: erc20Abi, data: blueprintData.data }); + const amountRaw = (decoded.args as [string, bigint])[1]; + expect(amountRaw).toBe(parseUnits(quote.outputAmount, eurcTokenDetails.decimals)); + + const signedTransfer = await ephemeral.signTransaction({ + chainId: 8453, + data: options.recipient + ? encodeFunctionData({ abi: erc20Abi, args: [options.recipient, amountRaw], functionName: "transfer" }) + : blueprintData.data, + gas: 100_000n, + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: (blueprint as UnsignedTx).nonce, + to: blueprintData.to, + type: "eip1559" + }); + + await rampState.update({ + presignedTxs: [ + { + meta: {}, + network: Networks.Base, + nonce: (blueprint as UnsignedTx).nonce, + phase: "destinationTransfer", + signer: ephemeral.address, + txData: signedTransfer + } + ] + }); + + return { + amountRaw, + destination, + ephemeral, + mykoboMintRaw, + mykoboTransactionId: rampState.state.mykoboTransactionId as string, + quoteId: quote.id, + rampId: rampState.id, + signedTransfer + }; + } + + /** + * Scripts the fake world for the happy path: + * - Mykobo's SEPA settlement already delivered the EURC on the ephemeral, + * - the ephemeral has Base gas so fundEphemeral sends nothing, + * - broadcast raw ERC-20 transfers are applied to the in-memory ledger. + */ + function scriptHappyWorld(setup: CorridorSetup): void { + world.evm.setNativeBalance(Networks.Base, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Base, EURC_ON_BASE, setup.ephemeral.address, setup.mykoboMintRaw); + world.evm.onTransaction = tx => { + if (!tx.serialized) { + return; + } + const parsed = parseTransaction(tx.serialized as `0x${string}`); + if (!parsed.to || !parsed.data) { + return; + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + if (functionName !== "transfer") { + return; + } + const [recipient, amount] = args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + function submissionsOf(signedTransfer: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTransfer).length; + } + + it( + "happy path: processes initial → mykoboOnrampDeposit → fundEphemeral → destinationTransfer → complete", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.state.destinationTransferTxHash).toBeTruthy(); + + // Quote stays consumed and the destination received exactly the quoted + // EURC per the fake ledger. + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, EURC_ON_BASE, setup.destination)).toBe(setup.amountRaw); + + // Registration created exactly one Mykobo DEPOSIT intent addressed at + // the ephemeral for the full EUR input, and the KYC mirror was synced. + const intents = world.mykobo.intents.filter(intent => intent.wallet_address === setup.ephemeral.address); + expect(intents.length).toBe(1); + expect(intents[0].transaction_type).toBe(MykoboTransactionType.DEPOSIT); + expect(intents[0].value).toBe("100.00"); + expect((await MykoboCustomer.findOne({ where: { userId: final?.userId as string } }))?.status).toBe( + MykoboCustomerStatus.APPROVED + ); + }, + 30000 + ); + + it( + "transient failure: a scripted RPC outage on the destination transfer is recoverable and the corridor still completes", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.evm.failNextSends = 1; + world.evm.sendFailureMessage = "FakeEvm: scripted RPC outage"; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + const outageLogs = final?.errorLogs.filter(log => log.error.includes("scripted RPC outage")) ?? []; + expect(outageLogs.length).toBeGreaterThanOrEqual(1); + expect(outageLogs.every(log => log.phase === "destinationTransfer")).toBe(true); + expect(outageLogs.some(log => log.recoverable === true)).toBe(true); + + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, EURC_ON_BASE, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + "unrecoverable failure: a presigned transfer paying the wrong recipient fails the ramp without paying anyone", + async () => { + const wrongRecipient = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + const setup = await setUpRegisteredRamp({ recipient: wrongRecipient }); + scriptHappyWorld(setup); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.errorLogs.some(log => log.error.includes("recipient mismatch"))).toBe(true); + + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, EURC_ON_BASE, wrongRecipient)).toBe(0n); + expect(world.evm.erc20Balance(Networks.Base, EURC_ON_BASE, setup.destination)).toBe(0n); + }, + 30000 + ); + + it( + "registration guard: an unapproved Mykobo KYC blocks the EUR onramp registration path", + async () => { + world.mykobo.profileKycReviewStatus = "pending"; + const user = await createTestUser(); + const quote = await createQuoteViaApi(); + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + const intentsBefore = world.mykobo.intents.length; + + await expect( + registerEurOnrampBelowKillSwitch(persistedQuote as QuoteTicket, user.id, ephemeral, destination) + ).rejects.toThrow("Mykobo KYC is not approved"); + // No intent was created and the quote was not consumed. + expect(world.mykobo.intents.length).toBe(intentsBefore); + expect((await QuoteTicket.findByPk(quote.id))?.status).toBe("pending"); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts new file mode 100644 index 000000000..dbfa5a82e --- /dev/null +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -0,0 +1,427 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + ALFREDPAY_ERC20_TOKEN, + AlfredpayOfframpStatus, + EvmToken, + FiatToken, + Networks, + RampDirection, + type RampPhase, + type UnsignedTx +} from "@vortexfi/shared"; +import { BaseError, ContractFunctionExecutionError, decodeFunctionData, erc20Abi, parseTransaction } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import { parseUnits } from "viem/utils"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestAlfredpayCustomer, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +// finalSettlementSubsidy is a no-op here (the user transfer delivers the full +// amount) but appears in the history. +const HAPPY_PATH_PHASES: RampPhase[] = [ + "initial", + "squidRouterPermitExecute", + "fundEphemeral", + "finalSettlementSubsidy", + "alfredpayOfframpTransfer", + "complete" +]; + +// 100 USDT * 20 = 2000 MXN: a legible flat rate for the fake anchor. +const ALFREDPAY_OFFRAMP_RATE = 20; +const FIAT_ACCOUNT_ID = "test-fiat-account-1"; + +interface EvmTxBlueprint { + to: `0x${string}`; + data: `0x${string}`; + value?: string; +} + +interface CorridorSetup { + rampId: string; + quoteId: string; + /** Raw (6-decimal) USDT amount the offramp moves. */ + inputAmountRaw: bigint; + signedOfframpTransfer: `0x${string}`; + ephemeral: PrivateKeyAccount; + userWallet: PrivateKeyAccount; + userTransferBlueprint: EvmTxBlueprint; +} + +/** + * Corridor scenario tests for the MXN offramp direct no-permit path (USDT on + * Polygon → spei via Alfredpay): quote and registration go through the real + * HTTP API (registration creates the Alfredpay order and probes EIP-2612 + * support — scripted away so the user broadcasts a plain transfer), the user's + * reported tx hash and the presigned deposit transfer go through + * /v1/ramp/update, then the REAL PhaseProcessor drives the ramp to complete + * against the fake external world. + */ +describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.squidRouter.computeToAmount = params => params.fromAmount; + world.alfredpay.offrampRate = ALFREDPAY_OFFRAMP_RATE; + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + // Fresh deposit address per test: the in-memory EVM ledger persists across + // tests, so a shared address would accumulate balances between scenarios. + world.alfredpay.offrampDepositAddress = privateKeyToAccount(generatePrivateKey()).address.toLowerCase(); + // Polygon USDT has no EIP-2612 support in this scenario: the nonces() probe + // fails as a contract-call error, steering registration onto the no-permit + // path where the user broadcasts a plain transfer from their own wallet. + world.evm.onReadContract = (_network, params) => { + if (params.functionName === "nonces") { + throw new ContractFunctionExecutionError(new BaseError("nonces() reverted"), { + abi: erc20Abi, + contractAddress: params.address, + functionName: "nonces" + }); + } + return undefined; + }; + }); + + async function createQuoteViaApi(): Promise<{ id: string; inputAmount: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Polygon, + inputAmount: "100", + inputCurrency: EvmToken.USDT, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: "spei" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; inputAmount: string; outputAmount: string }; + } + + async function registerViaApi( + quoteId: string, + userId: string, + ephemeral: PrivateKeyAccount, + userWallet: PrivateKeyAccount + ): Promise<{ id: string; unsignedTxs: UnsignedTx[] }> { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { fiatAccountId: FIAT_ACCOUNT_ID, walletAddress: userWallet.address }, + quoteId, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; unsignedTxs: UnsignedTx[] }; + } + + function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase): EvmTxBlueprint { + const blueprint = unsignedTxs.find(tx => tx.phase === phase); + expect(blueprint, `missing ${phase} blueprint in register response`).toBeDefined(); + return blueprint?.txData as unknown as EvmTxBlueprint; + } + + async function setUpRegisteredRamp(): Promise { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id); + const quote = await createQuoteViaApi(); + const ramp = await registerViaApi(quote.id, user.id, ephemeral, userWallet); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const inputAmountRaw = BigInt(persistedQuote?.metadata.alfredpayOfframp?.inputAmountRaw ?? "0"); + expect(inputAmountRaw).toBeGreaterThan(0n); + + // The register RESPONSE withholds user-wallet txs until the ephemeral + // presigns pass (filterUnsignedTxsForResponse), so blueprints are read + // from the persisted state like the processor does. + const registered = await RampState.findByPk(ramp.id); + const allUnsignedTxs = registered?.unsignedTxs ?? []; + const userTransferBlueprint = blueprintOf(allUnsignedTxs, "squidRouterNoPermitTransfer"); + const offrampTransferBlueprint = blueprintOf(allUnsignedTxs, "alfredpayOfframpTransfer"); + + // Sign exactly the blueprint the backend issued for the ephemeral's + // deposit transfer (plus the four required same-call backups). + async function signBlueprint(nonce: number): Promise<`0x${string}`> { + return ephemeral.signTransaction({ + chainId: 137, + data: offrampTransferBlueprint.data, + gas: 100_000n, + // validatePresignedTxs enforces a 3 gwei floor on Polygon fees. + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce, + to: offrampTransferBlueprint.to, + type: "eip1559" + }); + } + const signedOfframpTransfer = await signBlueprint(0); + const backups: Record = {}; + for (let i = 1; i <= 4; i++) { + backups[`backup${i}`] = { nonce: i, txData: await signBlueprint(i) }; + } + + // The user "broadcasts" the source-of-funds transfer from their own wallet + // and the frontend reports the hash through the update endpoint together + // with the presigned deposit transfer. + const userTxHash = world.evm.broadcastUserTransaction(Networks.Polygon, userWallet.address, { + data: userTransferBlueprint.data, + to: userTransferBlueprint.to, + value: 0n + }); + + const updateResponse = await app.request("/v1/ramp/update", { + body: JSON.stringify({ + additionalData: { squidRouterNoPermitTransferHash: userTxHash }, + presignedTxs: [ + { + meta: { additionalTxs: backups }, + network: Networks.Polygon, + nonce: 0, + phase: "alfredpayOfframpTransfer", + signer: ephemeral.address, + txData: signedOfframpTransfer + } + ], + rampId: ramp.id + }), + headers: { + Authorization: `Bearer ${testUserToken(user.id)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(updateResponse.status).toBe(200); + + const rampState = await RampState.findByPk(ramp.id); + expect(rampState?.state.alfredpayTransactionId).toBeTruthy(); + expect(rampState?.state.isDirectTransfer).toBe(true); + expect(rampState?.state.isNoPermitFallback).toBe(true); + + return { + ephemeral, + inputAmountRaw, + quoteId: quote.id, + rampId: ramp.id, + signedOfframpTransfer, + userTransferBlueprint, + userWallet + }; + } + + /** + * Scripts the fake world for the happy path: the user's transfer already + * credited the ephemeral's USDT, the ephemeral has Polygon gas, and raw + * ERC-20 transfers are applied to the in-memory ledger. + */ + function scriptHappyWorld(setup: CorridorSetup): void { + world.evm.setNativeBalance(Networks.Polygon, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, setup.inputAmountRaw); + world.evm.onTransaction = tx => { + if (!tx.serialized) { + return; + } + const parsed = parseTransaction(tx.serialized as `0x${string}`); + if (!parsed.to || !parsed.data) { + return; + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + if (functionName !== "transfer") { + return; + } + const [recipient, amount] = args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + function submissionsOf(signedTransfer: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTransfer).length; + } + + it( + "happy path: processes the full Alfredpay offramp phase sequence to complete", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const depositAddress = world.alfredpay.offrampDepositAddress; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.state.alfredpayOfframpTransferTxHash).toBeTruthy(); + + // Quote stays consumed; exactly one Alfredpay order exists and the + // deposit address received exactly the quoted USDT per the fake ledger. + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + expect(world.alfredpay.offrampOrders.length).toBe(1); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, depositAddress)).toBe(setup.inputAmountRaw); + }, + 30000 + ); + + it( + "transient failure: an RPC outage on the ephemeral gas funding is recoverable and the ramp still completes", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const depositAddress = world.alfredpay.offrampDepositAddress; + + // The ephemeral starts without gas, so fundEphemeral must broadcast a + // native funding transfer from the funding account. Apply that value + // transfer to the ledger on top of scriptHappyWorld's ERC-20 effects. + world.evm.setNativeBalance(Networks.Polygon, setup.ephemeral.address, 0n); + const applyErc20Transfers = world.evm.onTransaction; + world.evm.onTransaction = tx => { + if (!tx.serialized && tx.to && tx.value) { + world.evm.setNativeBalance(tx.network, tx.to, world.evm.nativeBalance(tx.network, tx.to) + tx.value); + return; + } + applyErc20Transfers?.(tx); + }; + // The first broadcast of this corridor is now that funding transfer. + world.evm.failNextSends = 1; + world.evm.sendFailureMessage = "FakeEvm: scripted RPC outage"; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + // The outage surfaced as exactly one recoverable fundEphemeral error... + const outageLogs = final?.errorLogs.filter(log => log.error.includes("Error funding ephemeral account")) ?? []; + expect(outageLogs.length).toBe(1); + expect(outageLogs.every(log => log.phase === "fundEphemeral" && log.recoverable === true)).toBe(true); + + // ...and after the retry the deposit transfer reached the chain exactly + // once, paying the anchor in full. + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, depositAddress)).toBe(setup.inputAmountRaw); + }, + 30000 + ); + + it( + "security regression: a reported user tx whose calldata does not match the blueprint fails the ramp unrecoverably", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + + // Overwrite the reported hash with a tampered user transfer that pays a + // different recipient than the blueprint demanded. + const attacker = privateKeyToAccount(generatePrivateKey()).address; + const { encodeFunctionData } = await import("viem"); + const tamperedHash = world.evm.broadcastUserTransaction(Networks.Polygon, setup.userWallet.address, { + data: encodeFunctionData({ abi: erc20Abi, args: [attacker, setup.inputAmountRaw], functionName: "transfer" }), + to: ALFREDPAY_ERC20_TOKEN, + value: 0n + }); + const rampState = await RampState.findByPk(setup.rampId); + await rampState?.update({ + state: { ...rampState.state, squidRouterNoPermitTransferHash: tamperedHash } + }); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + // The ephemeral's deposit transfer must never have been broadcast. + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, world.alfredpay.offrampDepositAddress)).toBe(0n); + }, + 30000 + ); + + it( + "subsidy cap (F-001): a settlement shortfall needing more than MAX_FINAL_SETTLEMENT_SUBSIDY_USD of native fails instead of paying", + async () => { + const setup = await setUpRegisteredRamp(); + world.evm.setNativeBalance(Networks.Polygon, setup.ephemeral.address, parseUnits("2", 18)); + // Only 90% of the expected USDT arrived (exactly the minimum bridge + // delivery ratio, so the balance poll passes): the 10 USDT shortfall + // must be subsidized. The funding account holds no USDT, so the handler + // prices a native→USDT swap; at 0.5 USD/MATIC the required ~22 MATIC + // (incl. the 10% buffer) is worth $11 — above the $10 F-001 cap. + world.evm.setErc20Balance( + Networks.Polygon, + ALFREDPAY_ERC20_TOKEN, + setup.ephemeral.address, + (setup.inputAmountRaw * 9n) / 10n + ); + world.squidRouter.computeToAmount = params => (BigInt(params.fromAmount) / 2n / 10n ** 12n).toString(); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.errorLogs.some(log => log.error.includes("exceeds maximum allowed"))).toBe(true); + + // The deposit transfer never reached the chain and nothing was subsidized. + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, world.alfredpay.offrampDepositAddress)).toBe(0n); + }, + 30000 + ); + + it( + "unrecoverable failure: an Alfredpay FAILED order status fails the ramp during the transfer phase", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FAILED; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts new file mode 100644 index 000000000..188f2f752 --- /dev/null +++ b/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts @@ -0,0 +1,433 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + ALFREDPAY_ERC20_TOKEN, + AlfredpayOnrampStatus, + EvmToken, + evmTokenConfig, + FiatToken, + Networks, + RampDirection, + type RampPhase, + type UnsignedTx +} from "@vortexfi/shared"; +import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import { getEvmFundingAccount } from "../../api/services/phases/evm-funding"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import Subsidy, { SubsidyToken } from "../../models/subsidy.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestAlfredpayCustomer, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +const USDT_ON_ARBITRUM = evmTokenConfig[Networks.Arbitrum][EvmToken.USDT]?.erc20AddressSourceChain as `0x${string}`; +if (!USDT_ON_ARBITRUM) { + throw new Error("USDT token config missing for Arbitrum"); +} + +const CHAIN_IDS: Partial> = { + [Networks.Arbitrum]: 42161, + [Networks.Polygon]: 137 +}; + +// Unlike the Polygon-direct corridor, the squidRouterSwap phase executes for +// real here (Polygon mint token → Arbitrum USDT) and squidRouterPay settles +// via the destination-chain balance check. +const HAPPY_PATH_PHASES: RampPhase[] = [ + "initial", + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +// 2000 MXN * 0.05 = 100 USDT: a legible flat rate for the fake anchor. +const ALFREDPAY_RATE = 0.05; + +interface CorridorSetup { + rampId: string; + quoteId: string; + /** Raw (6-decimal) USDT amount the presigned destination transfer pays out on Arbitrum. */ + amountRaw: bigint; + /** Raw (6-decimal) mint-token amount Alfredpay mints on the Polygon ephemeral. */ + mintAmountRaw: bigint; + /** Raw (6-decimal) USDT amount the squid bridge delivers on Arbitrum. */ + bridgedAmountRaw: bigint; + signedSquidApprove: `0x${string}`; + signedSquidSwap: `0x${string}`; + signedTransfer: `0x${string}`; + ephemeral: PrivateKeyAccount; + destination: `0x${string}`; +} + +/** + * Corridor scenario tests for the CROSS-CHAIN Alfredpay onramp (MXN spei → + * mint on Polygon → SquidRouter bridge → USDT on Arbitrum): quote, + * registration and presigned-tx submission go through the real HTTP API, then + * the REAL PhaseProcessor executes the squid approve+swap on Polygon + * (squidRouterSwap), settles the bridge via the Arbitrum balance check + * (squidRouterPay), and pays the destination on Arbitrum — the path the + * Polygon-direct MXN corridor skips entirely. + */ +describe("MXN onramp cross-chain corridor (spei → Polygon mint → USDT on Arbitrum)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.alfredpay.onrampRate = ALFREDPAY_RATE; + world.alfredpay.onCreateOnramp = undefined; + world.alfredpay.onrampStatus = AlfredpayOnrampStatus.TRADE_COMPLETED; + world.alfredpay.onrampStatusMetadata = null; + world.squidRouter.bridgeStatus = "success"; + // The bridge leg swaps the 6-decimal Polygon mint token into 6-decimal + // Arbitrum USDT; the fake route must report matching decimals. + world.squidRouter.toTokenDecimals = 6; + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: "spei", + inputAmount: "2000", + inputCurrency: FiatToken.MXN, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDT, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + async function registerViaApi( + quoteId: string, + userId: string, + ephemeral: PrivateKeyAccount, + destination: `0x${string}` + ): Promise<{ id: string }> { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: destination }, + quoteId, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string }; + } + + function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase): UnsignedTx { + const blueprint = unsignedTxs.find(tx => tx.phase === phase); + expect(blueprint, `missing ${phase} blueprint in persisted ramp state`).toBeDefined(); + return blueprint as UnsignedTx; + } + + /** Signs a blueprint exactly as issued; the nonce may be overridden for backups. */ + async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx, nonce?: number): Promise<`0x${string}`> { + const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const chainId = CHAIN_IDS[blueprint.network]; + if (!chainId) { + throw new Error(`No chain id mapped for ${blueprint.network}`); + } + return ephemeral.signTransaction({ + chainId, + data: txData.data, + gas: 600_000n, + // validatePresignedTxs enforces the blueprint's fee minimums (3 gwei floor on Polygon). + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce: nonce ?? blueprint.nonce, + to: txData.to, + type: "eip1559", + value: BigInt(txData.value ?? "0") + }); + } + + /** validatePresignedTxs requires 4 same-call backups at the next 4 nonces. */ + async function presignWithBackups(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx) { + const backups: Record = {}; + for (let i = 1; i <= 4; i++) { + backups[`backup${i}`] = { nonce: blueprint.nonce + i, txData: await signBlueprint(ephemeral, blueprint, blueprint.nonce + i) }; + } + return { + meta: { additionalTxs: backups }, + network: blueprint.network, + nonce: blueprint.nonce, + phase: blueprint.phase, + signer: ephemeral.address, + txData: await signBlueprint(ephemeral, blueprint) + }; + } + + /** + * Creates quote + registration through the HTTP API, then submits the + * presigned squid approve/swap (Polygon) and destination transfer (Arbitrum) + * through the real update endpoint — which also creates the Alfredpay order. + */ + async function setUpRegisteredRamp(): Promise { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id); + const quote = await createQuoteViaApi(); + const ramp = await registerViaApi(quote.id, user.id, ephemeral, destination); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const mintAmountRaw = BigInt(persistedQuote?.metadata.alfredpayMint?.outputAmountRaw ?? "0"); + const bridgedAmountRaw = BigInt(persistedQuote?.metadata.evmToEvm?.outputAmountRaw ?? "0"); + expect(mintAmountRaw).toBeGreaterThan(0n); + expect(bridgedAmountRaw).toBeGreaterThan(0n); + + const rampState = await RampState.findByPk(ramp.id); + if (!rampState) { + throw new Error("Ramp state not found after registration"); + } + const unsignedTxs = rampState.unsignedTxs ?? []; + + // The cross-chain branch: real squid transactions on Polygon plus the + // destination transfer on Arbitrum. + const approveBlueprint = blueprintOf(unsignedTxs, "squidRouterApprove"); + const swapBlueprint = blueprintOf(unsignedTxs, "squidRouterSwap"); + const transferBlueprint = blueprintOf(unsignedTxs, "destinationTransfer"); + expect(approveBlueprint.network).toBe(Networks.Polygon); + expect(swapBlueprint.network).toBe(Networks.Polygon); + expect(transferBlueprint.network).toBe(Networks.Arbitrum); + + const approvePresign = await presignWithBackups(ephemeral, approveBlueprint); + const swapPresign = await presignWithBackups(ephemeral, swapBlueprint); + const transferPresign = await presignWithBackups(ephemeral, transferBlueprint); + + const response = await app.request("/v1/ramp/update", { + body: JSON.stringify({ presignedTxs: [approvePresign, swapPresign, transferPresign], rampId: ramp.id }), + headers: { + Authorization: `Bearer ${testUserToken(user.id)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(200); + + const updated = await RampState.findByPk(ramp.id); + expect(updated?.state.alfredpayTransactionId).toBeTruthy(); + + const transferTxData = transferBlueprint.txData as unknown as { data: `0x${string}` }; + const { args } = decodeFunctionData({ abi: erc20Abi, data: transferTxData.data }); + const amountRaw = (args as [string, bigint])[1]; + + return { + amountRaw, + bridgedAmountRaw, + destination, + ephemeral, + mintAmountRaw, + quoteId: quote.id, + rampId: ramp.id, + signedSquidApprove: approvePresign.txData, + signedSquidSwap: swapPresign.txData, + signedTransfer: transferPresign.txData + }; + } + + /** + * Scripts the fake world so every polling loop succeeds on its first check: + * - the Alfredpay mint has already credited the ephemeral's Polygon balance, + * - the ephemeral has gas on Polygon AND Arbitrum (destination funding), + * - the broadcast squid swap credits the bridged USDT on Arbitrum, + * - raw ERC-20 transfers are applied to the in-memory ledger. + */ + function scriptHappyWorld(setup: CorridorSetup, options: { bridgedAmountRaw?: bigint } = {}): void { + const bridged = options.bridgedAmountRaw ?? setup.bridgedAmountRaw; + world.evm.setNativeBalance(Networks.Polygon, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setNativeBalance(Networks.Arbitrum, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, setup.mintAmountRaw); + world.evm.onTransaction = tx => { + if (tx.serialized === setup.signedSquidSwap) { + world.evm.setErc20Balance( + Networks.Arbitrum, + USDT_ON_ARBITRUM, + setup.ephemeral.address, + world.evm.erc20Balance(Networks.Arbitrum, USDT_ON_ARBITRUM, setup.ephemeral.address) + bridged + ); + return; + } + const parsed = tx.serialized ? parseTransaction(tx.serialized as `0x${string}`) : { data: tx.data, to: tx.to }; + if (!parsed.to || !parsed.data) { + return; + } + let decoded: { functionName: string; args: readonly unknown[] }; + try { + decoded = decodeFunctionData({ abi: erc20Abi, data: parsed.data as `0x${string}` }); + } catch { + return; + } + if (decoded.functionName !== "transfer") { + return; + } + const [recipient, amount] = decoded.args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + function submissionsOf(signedTx: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTx).length; + } + + it( + "happy path: bridges the Polygon mint to Arbitrum via squid and pays the destination there", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + + // Registration requested a Polygon mint-token → Arbitrum USDT route for + // the ephemeral. + const registrationRoute = world.squidRouter.requestedRoutes.find( + route => + route.fromToken.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase() && + route.toToken.toLowerCase() === USDT_ON_ARBITRUM.toLowerCase() + ); + expect(registrationRoute, "registration should request a Polygon→Arbitrum route").toBeDefined(); + expect(registrationRoute?.fromChain).toBe("137"); + expect(registrationRoute?.toChain).toBe("42161"); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.state.squidRouterApproveHash).toBeTruthy(); + expect(final?.state.squidRouterSwapHash).toBeTruthy(); + + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + expect(world.alfredpay.onrampOrders.length).toBe(1); + + // The squid approve+swap each hit Polygon exactly once, and the + // destination received exactly the quoted USDT on Arbitrum. + expect(submissionsOf(setup.signedSquidApprove)).toBe(1); + expect(submissionsOf(setup.signedSquidSwap)).toBe(1); + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Arbitrum, USDT_ON_ARBITRUM, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + "transient failure: an RPC outage on the Arbitrum destination transfer is recoverable and the corridor still completes", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + // Arm the outage once the squid swap has landed so it hits the NEXT + // broadcast — the Arbitrum destination transfer. + const applyLedgerEffects = world.evm.onTransaction; + world.evm.sendFailureMessage = "FakeEvm: scripted RPC outage"; + world.evm.onTransaction = tx => { + applyLedgerEffects?.(tx); + if (tx.serialized === setup.signedSquidSwap) { + world.evm.failNextSends = 1; + } + }; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + const outageLogs = final?.errorLogs.filter(log => log.error.includes("scripted RPC outage")) ?? []; + expect(outageLogs.length).toBeGreaterThanOrEqual(1); + expect(outageLogs.every(log => log.phase === "destinationTransfer")).toBe(true); + expect(outageLogs.some(log => log.recoverable === true)).toBe(true); + + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Arbitrum, USDT_ON_ARBITRUM, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + "settlement subsidy: a small bridge shortfall on Arbitrum is topped up (within cap) and the destination is paid in full", + async () => { + const setup = await setUpRegisteredRamp(); + // The bridge slips by 1 USDT — well under the final-settlement cap, so + // finalSettlementSubsidy must top up the ephemeral on ARBITRUM. The + // subsidy is paid from the funding account's own USDT. + const fundingAccount = getEvmFundingAccount(Networks.Arbitrum); + world.evm.setErc20Balance(Networks.Arbitrum, USDT_ON_ARBITRUM, fundingAccount.address, parseUnits("1000", 6)); + const shortfall = parseUnits("1", 6); + scriptHappyWorld(setup, { bridgedAmountRaw: setup.bridgedAmountRaw - shortfall }); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + // The funding account sent exactly the shortfall as a USDT transfer to + // the ephemeral on ARBITRUM, and its hash was recorded for idempotency. + expect(final?.state.finalSettlementSubsidyTxHash).toBeTruthy(); + const subsidyTransfers = world.evm.sentTransactions.filter(tx => { + if (tx.network !== Networks.Arbitrum || tx.from?.toLowerCase() !== fundingAccount.address.toLowerCase() || !tx.data) { + return false; + } + const decoded = decodeFunctionData({ abi: erc20Abi, data: tx.data as `0x${string}` }); + const [recipient, amount] = decoded.args as [`0x${string}`, bigint]; + return ( + decoded.functionName === "transfer" && + recipient.toLowerCase() === setup.ephemeral.address.toLowerCase() && + amount === shortfall + ); + }); + expect(subsidyTransfers.length).toBe(1); + expect(world.evm.erc20Balance(Networks.Arbitrum, USDT_ON_ARBITRUM, setup.destination)).toBe(setup.amountRaw); + + // The top-up is recorded in the subsidies table so accounting sees it, + // just like the gas subsidy in squidRouterPay. + const subsidyRows = await Subsidy.findAll({ where: { phase: "finalSettlementSubsidy", rampId: setup.rampId } }); + expect(subsidyRows.length).toBe(1); + expect(subsidyRows[0].token).toBe(SubsidyToken.USDT); + expect(subsidyRows[0].amount).toBeCloseTo(1); + expect(subsidyRows[0].payerAccount.toLowerCase()).toBe(fundingAccount.address.toLowerCase()); + expect(subsidyRows[0].transactionHash).toBe(final?.state.finalSettlementSubsidyTxHash as string); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts new file mode 100644 index 000000000..c9420cee7 --- /dev/null +++ b/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts @@ -0,0 +1,360 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ERC20_TOKEN, + AlfredpayOnrampStatus, + EvmToken, + FiatToken, + Networks, + RampDirection, + type RampPhase +} from "@vortexfi/shared"; +import { decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestAlfredpayCustomer, createTestUser } from "../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +// squidRouterSwap appears in the history but skips internally on the direct +// (mint token == output token) corridor; the subsidy phases are no-ops here. +const HAPPY_PATH_PHASES: RampPhase[] = [ + "initial", + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +// 2000 MXN * 0.05 = 100 USDT: a legible flat rate for the fake anchor. +const ALFREDPAY_RATE = 0.05; + +interface CorridorSetup { + rampId: string; + quoteId: string; + /** Raw (6-decimal) USDT amount the presigned transfer pays out. */ + amountRaw: bigint; + /** Raw (6-decimal) USDT amount Alfredpay mints on the ephemeral. */ + mintAmountRaw: bigint; + signedTransfer: `0x${string}`; + ephemeral: PrivateKeyAccount; + destination: `0x${string}`; +} + +/** + * Corridor scenario tests for the MXN onramp direct path (spei → USDT on + * Polygon, the Alfredpay mint token): quote and registration go through the + * real HTTP API, /v1/ramp/update creates the Alfredpay order, then the REAL + * PhaseProcessor drives the ramp from initial to complete against the fake + * external world (see HAPPY_PATH_PHASES for the full sequence). + */ +describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.alfredpay.onrampRate = ALFREDPAY_RATE; + world.alfredpay.onCreateOnramp = undefined; + world.alfredpay.onrampStatus = AlfredpayOnrampStatus.TRADE_COMPLETED; + world.alfredpay.onrampStatusMetadata = null; + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: "spei", + inputAmount: "2000", + inputCurrency: FiatToken.MXN, + network: Networks.Polygon, + outputCurrency: EvmToken.USDT, + rampType: RampDirection.BUY, + to: Networks.Polygon + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + async function registerViaApi( + quoteId: string, + userId: string, + ephemeral: PrivateKeyAccount, + destination: `0x${string}` + ): Promise<{ id: string }> { + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: destination }, + quoteId, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string }; + } + + /** + * Submits a presigned tx through the real update endpoint. For Alfredpay + * corridors this also triggers the order creation (alfredpayTransactionId + * lands in state). + */ + async function updateRampViaApi( + rampId: string, + userId: string, + presignedTx: { meta?: object; network: Networks; nonce: number; phase: string; signer: string; txData: `0x${string}` } + ): Promise { + const response = await app.request("/v1/ramp/update", { + body: JSON.stringify({ presignedTxs: [{ meta: {}, ...presignedTx }], rampId }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(response.status).toBe(200); + } + + /** + * Creates quote + registration + Alfredpay order through the HTTP API with a + * fresh ephemeral key pair, then stores a REAL signed ERC-20 USDT transfer as + * the presigned destinationTransfer. Pass a recipient to sign a transfer that + * pays someone other than the registered destination. + */ + async function setUpRegisteredRamp(options: { recipient?: `0x${string}` } = {}): Promise { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id); + const quote = await createQuoteViaApi(); + const ramp = await registerViaApi(quote.id, user.id, ephemeral, destination); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const mintAmountRaw = BigInt(persistedQuote?.metadata.alfredpayMint?.outputAmountRaw ?? "0"); + expect(mintAmountRaw).toBeGreaterThan(0n); + + const amountRaw = parseUnits(quote.outputAmount, ALFREDPAY_ERC20_DECIMALS); + async function signTransfer(recipient: `0x${string}`, nonce: number): Promise<`0x${string}`> { + return ephemeral.signTransaction({ + chainId: 137, + data: encodeFunctionData({ + abi: erc20Abi, + args: [recipient, amountRaw], + functionName: "transfer" + }), + gas: 100_000n, + // validatePresignedTxs enforces a 3 gwei floor on Polygon fees. + maxFeePerGas: 5_000_000_000n, + maxPriorityFeePerGas: 5_000_000_000n, + nonce, + to: ALFREDPAY_ERC20_TOKEN, + type: "eip1559" + }); + } + + // validatePresignedTxs requires 4 same-call backups at the following nonces. + async function signBackups(recipient: `0x${string}`): Promise> { + const backups: Record = {}; + for (let i = 1; i <= 4; i++) { + backups[`backup${i}`] = { nonce: i, txData: await signTransfer(recipient, i) }; + } + return backups; + } + + // The correct transfer goes through the real update endpoint (which also + // creates the Alfredpay order). + await updateRampViaApi(ramp.id, user.id, { + meta: { additionalTxs: await signBackups(destination) }, + network: Networks.Polygon, + nonce: 0, + phase: "destinationTransfer", + signer: ephemeral.address, + txData: await signTransfer(destination, 0) + }); + + const rampState = await RampState.findByPk(ramp.id); + if (!rampState) { + throw new Error("Ramp state not found after registration"); + } + expect(rampState.state.alfredpayTransactionId).toBeTruthy(); + + let signedTransfer = rampState.presignedTxs?.[0]?.txData as `0x${string}`; + if (options.recipient) { + // The wrong-recipient variant is swapped in at the DB layer: it models a + // presigned tx that slipped past the API, so the corridor asserts the + // PROCESSOR-level validation net catches it before funds move. + signedTransfer = await signTransfer(options.recipient, 0); + await rampState.update({ + presignedTxs: [ + { + meta: {}, + network: Networks.Polygon, + nonce: 0, + phase: "destinationTransfer", + signer: ephemeral.address, + txData: signedTransfer + } + ] + }); + } + + return { amountRaw, destination, ephemeral, mintAmountRaw, quoteId: quote.id, rampId: ramp.id, signedTransfer }; + } + + /** + * Scripts the fake world so every polling loop succeeds on its first check: + * - the Alfredpay mint has already credited the ephemeral's USDT, + * - the ephemeral already has Polygon gas, so fundEphemeral sends nothing, + * - submitted raw ERC-20 transfers are applied to the in-memory ledger. + */ + function scriptHappyWorld(setup: CorridorSetup): void { + world.evm.setNativeBalance(Networks.Polygon, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, setup.mintAmountRaw); + world.evm.onTransaction = tx => { + if (!tx.serialized) { + return; + } + const parsed = parseTransaction(tx.serialized as `0x${string}`); + if (!parsed.to || !parsed.data) { + return; + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + if (functionName !== "transfer") { + return; + } + const [recipient, amount] = args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + function submissionsOf(signedTransfer: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTransfer).length; + } + + it( + "happy path: processes the full Alfredpay onramp phase sequence to complete", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.state.destinationTransferTxHash).toBeTruthy(); + + // Quote stays consumed; exactly one Alfredpay order was created and the + // destination received exactly the quoted USDT per the fake ledger. + const quote = await QuoteTicket.findByPk(setup.quoteId); + expect(quote?.status).toBe("consumed"); + expect(world.alfredpay.onrampOrders.length).toBe(1); + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + "transient failure: retries a failed destinationTransfer broadcast (recoverable) and still completes", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.evm.failNextSends = 1; + world.evm.sendFailureMessage = "FakeEvm: scripted RPC outage"; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + + const outageLogs = final?.errorLogs.filter(log => log.error.includes("scripted RPC outage")) ?? []; + expect(outageLogs.length).toBeGreaterThanOrEqual(1); + expect(outageLogs.every(log => log.phase === "destinationTransfer")).toBe(true); + expect(outageLogs.some(log => log.recoverable === true)).toBe(true); + + expect(submissionsOf(setup.signedTransfer)).toBe(1); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.destination)).toBe(setup.amountRaw); + }, + 30000 + ); + + it( + "security regression: presigned transfer paying the wrong recipient fails the ramp unrecoverably", + async () => { + const wrongRecipient = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + const setup = await setUpRegisteredRamp({ recipient: wrongRecipient }); + scriptHappyWorld(setup); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.errorLogs.some(log => log.error.includes("recipient mismatch"))).toBe(true); + + // The mismatching transfer must never reach the chain, and nobody gets paid. + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, wrongRecipient)).toBe(0n); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.destination)).toBe(0n); + }, + 30000 + ); + + it( + "unrecoverable failure: an Alfredpay FAILED order status fails the ramp during the mint phase", + async () => { + const setup = await setUpRegisteredRamp(); + // Gas is there, but the mint never arrives and Alfredpay reports FAILED. + world.evm.setNativeBalance(Networks.Polygon, setup.ephemeral.address, parseUnits("2", 18)); + world.alfredpay.onrampStatus = AlfredpayOnrampStatus.FAILED; + world.alfredpay.onrampStatusMetadata = { failureReason: "scripted compliance rejection" }; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(submissionsOf(setup.signedTransfer)).toBe(0); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/fee-immutability.invariants.test.ts b/apps/api/src/tests/fee-immutability.invariants.test.ts new file mode 100644 index 000000000..7e2f1456f --- /dev/null +++ b/apps/api/src/tests/fee-immutability.invariants.test.ts @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import QuoteTicket from "../models/quoteTicket.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestTaxId, createTestUser } from "../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +const FEE_FIELDS = [ + "anchorFeeFiat", + "anchorFeeUsd", + "feeCurrency", + "networkFeeFiat", + "networkFeeUsd", + "partnerFeeFiat", + "partnerFeeUsd", + "processingFeeFiat", + "processingFeeUsd", + "totalFeeFiat", + "totalFeeUsd", + "vortexFeeFiat", + "vortexFeeUsd" +] as const; + +/** + * Fee immutability invariants (docs/security-spec/03-ramp-engine/ + * fee-integrity.md): fees are fixed at quote creation and no client-supplied + * fee field is ever accepted — not on the quote request, not in registration + * additionalData — and the status endpoint serves exactly the creation-time + * fee structure. + */ +describe("fee immutability invariants (BRL onramp)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + const DESTINATION = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; + const EPHEMERAL = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; + const TAX_ID = "12345678901"; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + function quoteBody(extra: Record = {}): string { + return JSON.stringify({ + from: "pix", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + rampType: RampDirection.BUY, + to: Networks.Base, + ...extra + }); + } + + async function createQuoteViaApi(extra: Record = {}): Promise> { + const response = await app.request("/v1/quotes", { + body: quoteBody(extra), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as Record; + } + + async function registerViaApi( + quoteId: string, + userId: string, + additionalData: Record + ): Promise { + return app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: DESTINATION, taxId: TAX_ID, ...additionalData }, + quoteId, + signingAccounts: [{ address: EPHEMERAL, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + } + + it("ignores client-supplied fee fields on quote creation", async () => { + const clean = await createQuoteViaApi(); + const tampered = await createQuoteViaApi({ + anchorFeeFiat: "0", + fee: { anchor: "0", displayFiat: { total: "0" }, total: "0", usd: { total: "0" } }, + metadata: { fees: { displayFiat: { total: "0" }, usd: { total: "0" } } }, + totalFeeFiat: "0", + totalFeeUsd: "0" + }); + + for (const field of FEE_FIELDS) { + expect(tampered[field], `quote field ${field} was influenced by the client`).toEqual(clean[field]); + } + }); + + it("serves creation-time fees on the status endpoint, unchanged by registration additionalData", async () => { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + + const quote = await createQuoteViaApi(); + const persistedAtCreation = await QuoteTicket.findByPk(quote.id as string); + const feesAtCreation = JSON.stringify(persistedAtCreation?.metadata.fees); + expect(persistedAtCreation?.metadata.fees).toBeDefined(); + + // Registration with fee fields smuggled into additionalData must succeed + // while leaving the persisted fee structure byte-identical. + const registerResponse = await registerViaApi(quote.id as string, user.id, { + anchorFeeFiat: "0", + fees: { displayFiat: { total: "0" }, usd: { total: "0" } }, + totalFeeFiat: "0" + }); + expect(registerResponse.status).toBe(201); + const ramp = (await registerResponse.json()) as { id: string }; + + const persistedAfterRegister = await QuoteTicket.findByPk(quote.id as string); + expect(JSON.stringify(persistedAfterRegister?.metadata.fees)).toBe(feesAtCreation); + + const statusResponse = await app.request(`/v1/ramp/${ramp.id}`, { + headers: { Authorization: `Bearer ${testUserToken(user.id)}` } + }); + expect(statusResponse.status).toBe(200); + const status = (await statusResponse.json()) as Record; + + for (const field of FEE_FIELDS) { + expect(status[field], `status fee field ${field} diverged from the quote`).toEqual(quote[field]); + } + + // The persisted structure is still exactly the creation-time one. + const persistedAfterStatus = await QuoteTicket.findByPk(quote.id as string); + expect(JSON.stringify(persistedAfterStatus?.metadata.fees)).toBe(feesAtCreation); + }); +}); diff --git a/apps/api/src/tests/harness.smoke.test.ts b/apps/api/src/tests/harness.smoke.test.ts new file mode 100644 index 000000000..857baec01 --- /dev/null +++ b/apps/api/src/tests/harness.smoke.test.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { installFakeWorld, type FakeWorld } from "../test-utils/fake-world"; +import { setupTestDatabase, truncateAllTables } from "../test-utils/db"; +import { createTestApiKey, createTestPartner, createTestQuote, createTestRampState, createTestUser } from "../test-utils/factories"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +describe("test harness smoke test", () => { + let world: FakeWorld; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + await setupTestDatabase(); + await truncateAllTables(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + world?.restore(); + }); + + it("boots the real Express app and serves a public endpoint", async () => { + const response = await app.request("/v1/supported-fiat-currencies"); + expect(response.status).toBe(200); + }); + + it("persists factory-built entities against the migrated schema", async () => { + const user = await createTestUser(); + const partner = await createTestPartner(); + const { record, plaintextKey } = await createTestApiKey({ partnerName: partner.name }); + const quote = await createTestQuote({ userId: user.id }); + const ramp = await createTestRampState({ quoteId: quote.id, userId: user.id }); + + expect(user.id).toBeTruthy(); + expect(record.keyPrefix).toBe(plaintextKey.slice(0, 8)); + expect(quote.status).toBe("pending"); + expect(ramp.currentPhase).toBe("initial"); + }); + + it("blocks un-faked external HTTP calls", async () => { + await expect(fetch("https://example.com")).rejects.toThrow(/Hermetic test violation/); + }); + + it("answers EVM balance reads from the in-memory ledger", async () => { + const { EvmClientManager, Networks } = await import("@vortexfi/shared"); + const holder = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; + const token = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + world.evm.setErc20Balance(Networks.Base, token, holder, 123n); + + const manager = EvmClientManager.getInstance(); + const balance = await manager.readContractWithRetry(Networks.Base, { + abi: [], + address: token, + args: [holder], + functionName: "balanceOf" + }); + expect(balance).toBe(123n); + }); +}); diff --git a/apps/api/src/tests/http-surface.invariants.test.ts b/apps/api/src/tests/http-surface.invariants.test.ts new file mode 100644 index 000000000..163533301 --- /dev/null +++ b/apps/api/src/tests/http-surface.invariants.test.ts @@ -0,0 +1,275 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { randomUUID } from "node:crypto"; +import type { StateMetadata } from "../api/services/phases/meta-state-types"; +import User from "../models/user.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestApiKey, createTestQuote, createTestRampState, createTestUser } from "../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { type FakeSupabaseAuth, installFakeSupabaseAuth, TEST_OTP_CODE, testUserToken } from "../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +/** + * HTTP-level coverage for the route groups no other suite drives end to end: + * the email/OTP auth flow, webhook registration/deletion, the ramp history + * endpoint, and the public information routes. What these protect is the HTTP + * contract — status codes, auth requirements, and response shapes. + */ +describe("HTTP surface: auth flow, webhooks, history, public routes", () => { + let world: FakeWorld; + let auth: FakeSupabaseAuth; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + async function requestJson( + path: string, + options: { method?: string; body?: unknown; headers?: Record } = {} + ): Promise<{ status: number; body: Record }> { + const response = await app.request(path, { + body: options.body === undefined ? undefined : JSON.stringify(options.body), + headers: { "Content-Type": "application/json", ...options.headers }, + method: options.method ?? "GET" + }); + return { body: (await response.json()) as Record, status: response.status }; + } + + describe("auth: email/OTP login flow", () => { + it("walks check-email → request-otp → verify-otp and mints tokens accepted by authed endpoints", async () => { + const email = "otp-user@example.com"; + + // A fresh email is a signup... + const fresh = await requestJson(`/v1/auth/check-email?email=${encodeURIComponent(email)}`); + expect(fresh.status).toBe(200); + expect(fresh.body).toEqual({ action: "signup", exists: false }); + + const otp = await requestJson("/v1/auth/request-otp", { body: { email }, method: "POST" }); + expect(otp.status).toBe(200); + expect(otp.body.success).toBe(true); + expect(auth.otpRequests).toContain(email); + + // ...a wrong code is rejected without a session... + const rejected = await requestJson("/v1/auth/verify-otp", { body: { email, token: "000000" }, method: "POST" }); + expect(rejected.status).toBe(400); + expect(rejected.body.error).toContain("Invalid OTP"); + + // ...and the right code returns tokens and syncs the local user row. + const verified = await requestJson("/v1/auth/verify-otp", { body: { email, token: TEST_OTP_CODE }, method: "POST" }); + expect(verified.status).toBe(200); + expect(verified.body.success).toBe(true); + expect(verified.body.access_token).toBeTruthy(); + expect(verified.body.refresh_token).toBeTruthy(); + const userId = verified.body.user_id as string; + expect(await User.findByPk(userId)).not.toBeNull(); + + const known = await requestJson(`/v1/auth/check-email?email=${encodeURIComponent(email)}`); + expect(known.body).toEqual({ action: "signin", exists: true }); + + // The minted access token is a real session: verify accepts it and an + // auth-guarded endpoint (ramp history) serves the user's (empty) history. + const verify = await requestJson("/v1/auth/verify", { + body: { access_token: verified.body.access_token }, + method: "POST" + }); + expect(verify.status).toBe(200); + expect(verify.body).toEqual({ user_id: userId, valid: true }); + + const history = await requestJson("/v1/ramp/history/0x1111111111111111111111111111111111111111", { + headers: { Authorization: `Bearer ${verified.body.access_token}` } + }); + expect(history.status).toBe(200); + expect(history.body).toEqual({ totalCount: 0, transactions: [] }); + }); + + it("refresh rotates a valid session and rejects a garbage refresh token with 401", async () => { + const email = "refresh-user@example.com"; + await requestJson("/v1/auth/request-otp", { body: { email }, method: "POST" }); + const verified = await requestJson("/v1/auth/verify-otp", { body: { email, token: TEST_OTP_CODE }, method: "POST" }); + + const refreshed = await requestJson("/v1/auth/refresh", { + body: { refresh_token: verified.body.refresh_token }, + method: "POST" + }); + expect(refreshed.status).toBe(200); + expect(refreshed.body.success).toBe(true); + expect(refreshed.body.access_token).toBeTruthy(); + + const rejected = await requestJson("/v1/auth/refresh", { body: { refresh_token: "garbage" }, method: "POST" }); + expect(rejected.status).toBe(401); + }); + + it("verify requires a token and rejects invalid ones", async () => { + const missing = await requestJson("/v1/auth/verify", { body: {}, method: "POST" }); + expect(missing.status).toBe(400); + + const invalid = await requestJson("/v1/auth/verify", { body: { access_token: "not-a-token" }, method: "POST" }); + expect(invalid.status).toBe(401); + expect(invalid.body.valid).toBe(false); + }); + }); + + describe("webhooks", () => { + async function apiKeyHeaders(): Promise> { + const user = await createTestUser(); + const { plaintextKey } = await createTestApiKey({ userId: user.id }); + return { "x-api-key": plaintextKey }; + } + + it("registration requires an API key", async () => { + const quote = await createTestQuote(); + const response = await requestJson("/v1/webhook", { + body: { quoteId: quote.id, url: "https://partner.example/hook" }, + method: "POST" + }); + expect(response.status).toBe(401); + }); + + it("registers a webhook for a quote and deletes it exactly once", async () => { + const headers = await apiKeyHeaders(); + const quote = await createTestQuote(); + + const created = await requestJson("/v1/webhook", { + body: { quoteId: quote.id, url: "https://partner.example/hook" }, + headers, + method: "POST" + }); + expect(created.status).toBe(201); + expect(created.body.id).toBeTruthy(); + expect(created.body.url).toBe("https://partner.example/hook"); + expect(created.body.quoteId).toBe(quote.id); + expect(created.body.isActive).toBe(true); + + const deleted = await requestJson(`/v1/webhook/${created.body.id}`, { headers, method: "DELETE" }); + expect(deleted.status).toBe(200); + expect(deleted.body.success).toBe(true); + + const again = await requestJson(`/v1/webhook/${created.body.id}`, { headers, method: "DELETE" }); + expect(again.status).toBe(404); + }); + + it("rejects non-HTTPS URLs, unknown quotes, and registrations without a quote or session", async () => { + const headers = await apiKeyHeaders(); + const quote = await createTestQuote(); + + const insecure = await requestJson("/v1/webhook", { + body: { quoteId: quote.id, url: "http://partner.example/hook" }, + headers, + method: "POST" + }); + expect(insecure.status).toBe(400); + + const unknownQuote = await requestJson("/v1/webhook", { + body: { quoteId: randomUUID(), url: "https://partner.example/hook" }, + headers, + method: "POST" + }); + expect(unknownQuote.status).toBe(404); + + const noTarget = await requestJson("/v1/webhook", { + body: { url: "https://partner.example/hook" }, + headers, + method: "POST" + }); + expect(noTarget.status).toBe(400); + }); + }); + + describe("ramp history", () => { + const WALLET = "0x2222222222222222222222222222222222222222"; + + it("serves only the caller's own non-initial ramps for the wallet", async () => { + const owner = await createTestUser(); + const stranger = await createTestUser(); + const quote = await createTestQuote(); + await createTestRampState({ + currentPhase: "complete", + quoteId: quote.id, + state: { destinationAddress: WALLET } as StateMetadata, + userId: owner.id + }); + // An initial-phase ramp must not appear in history. + await createTestRampState({ + currentPhase: "initial", + quoteId: (await createTestQuote()).id, + state: { destinationAddress: WALLET } as StateMetadata, + userId: owner.id + }); + + const ownHistory = await requestJson(`/v1/ramp/history/${WALLET}`, { + headers: { Authorization: `Bearer ${testUserToken(owner.id)}` } + }); + expect(ownHistory.status).toBe(200); + expect(ownHistory.body.totalCount).toBe(1); + const transactions = ownHistory.body.transactions as Array<{ id: string; status: string }>; + expect(transactions).toHaveLength(1); + + // Another user sees nothing for the same wallet (F-068 class). + const foreignHistory = await requestJson(`/v1/ramp/history/${WALLET}`, { + headers: { Authorization: `Bearer ${testUserToken(stranger.id)}` } + }); + expect(foreignHistory.status).toBe(200); + expect(foreignHistory.body).toEqual({ totalCount: 0, transactions: [] }); + }); + + it("requires authentication", async () => { + const response = await requestJson(`/v1/ramp/history/${WALLET}`); + expect(response.status).toBe(401); + }); + }); + + describe("public information routes", () => { + it("serves the supported fiat currencies, cryptocurrencies, countries, and payment methods", async () => { + const fiat = await requestJson("/v1/supported-fiat-currencies"); + expect(fiat.status).toBe(200); + const currencies = fiat.body.currencies as Array<{ symbol: string }>; + // Token exhaustiveness (see CLAUDE.md): all six fiat tokens stay listed. + // (FiatToken.EURC's wire value is "EUR".) + expect(currencies.map(currency => currency.symbol).sort()).toEqual(["ARS", "BRL", "COP", "EUR", "MXN", "USD"]); + + const crypto = await requestJson("/v1/supported-cryptocurrencies"); + expect(crypto.status).toBe(200); + + const countries = await requestJson("/v1/supported-countries"); + expect(countries.status).toBe(200); + + const methods = await requestJson("/v1/supported-payment-methods"); + expect(methods.status).toBe(200); + const sellMethods = methods.body.paymentMethods as Array<{ id: string; supportedFiats: Array<{ id: string }> }>; + expect(sellMethods.map(method => method.id).sort()).toEqual(["ach", "cbu", "pix", "sepa", "spei"]); + // Every fiat token is reachable through at least one sell payment method. + const sellFiats = sellMethods.flatMap(method => method.supportedFiats.map(fiat => fiat.id)); + expect([...new Set(sellFiats)].sort()).toEqual(["ARS", "BRL", "COP", "EUR", "MXN", "USD"]); + + const buyMethods = await requestJson("/v1/supported-payment-methods?type=buy"); + expect(buyMethods.status).toBe(200); + const buyIds = (buyMethods.body.paymentMethods as Array<{ id: string }>).map(method => method.id); + expect(buyIds.sort()).toEqual(["ach", "pix", "spei"]); + + const mxnMethods = await requestJson("/v1/supported-payment-methods?fiat=MXN"); + expect(mxnMethods.status).toBe(200); + expect((mxnMethods.body.paymentMethods as Array<{ id: string }>).map(method => method.id)).toEqual(["spei"]); + }); + + it("price endpoints validate their query parameters", async () => { + const missingEverything = await requestJson("/v1/prices"); + expect(missingEverything.status).toBe(400); + + const missingAmount = await requestJson("/v1/prices/all?direction=onramp&sourceCurrency=eur&targetCurrency=usdc&network=base"); + expect(missingAmount.status).toBe(400); + }); + }); +}); diff --git a/apps/api/src/tests/quote-consumption.invariants.test.ts b/apps/api/src/tests/quote-consumption.invariants.test.ts new file mode 100644 index 000000000..a150c9377 --- /dev/null +++ b/apps/api/src/tests/quote-consumption.invariants.test.ts @@ -0,0 +1,154 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import QuoteTicket from "../models/quoteTicket.model"; +import RampState from "../models/rampState.model"; +import { installFakeWorld, type FakeWorld } from "../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../test-utils/fake-world/fake-auth"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestTaxId, createTestUser } from "../test-utils/factories"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +/** + * Quote consumption invariants (docs/security-spec/03-ramp-engine/ + * quote-lifecycle.md): a quote is consumed exactly once, atomically with ramp + * registration. Exercised over the real HTTP API on the BRL→BRLA-on-Base + * direct corridor — the full quote pipeline and registration flow run against + * the fake external world. + */ +describe("quote consumption invariants (BRL onramp)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let app: TestApp; + + const DESTINATION = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; + const EPHEMERAL = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; + const TAX_ID = "12345678901"; + + beforeAll(async () => { + world = installFakeWorld(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + auth?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string; fee: unknown }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: "pix", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + rampType: RampDirection.BUY, + to: Networks.Base + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + return (await response.json()) as { id: string; outputAmount: string; fee: unknown }; + } + + async function registerViaApi(quoteId: string, userId: string): Promise { + return app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: DESTINATION, taxId: TAX_ID }, + quoteId, + signingAccounts: [{ address: EPHEMERAL, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(userId)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + } + + it("serves a BRL onramp quote hermetically through the full quote pipeline", async () => { + const quote = await createQuoteViaApi(); + expect(quote.id).toBeTruthy(); + expect(Number(quote.outputAmount)).toBeGreaterThan(0); + + const persisted = await QuoteTicket.findByPk(quote.id); + expect(persisted?.status).toBe("pending"); + expect(persisted?.metadata.fees?.usd).toBeDefined(); + expect(persisted?.metadata.fees?.displayFiat).toBeDefined(); + }); + + it("registers a ramp and consumes the quote exactly once", async () => { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + + const response = await registerViaApi(quote.id, user.id); + expect(response.status).toBe(201); + + const ramp = (await response.json()) as { id: string; unsignedTxs: Array<{ phase: string }> }; + expect(ramp.unsignedTxs.map(tx => tx.phase)).toContain("destinationTransfer"); + + const consumedQuote = await QuoteTicket.findByPk(quote.id); + expect(consumedQuote?.status).toBe("consumed"); + + const rampState = await RampState.findByPk(ramp.id); + expect(rampState?.quoteId).toBe(quote.id); + expect(rampState?.state.depositQrCode).toBeTruthy(); + }); + + it("allows exactly one of two concurrent registrations of the same quote", async () => { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + + const [first, second] = await Promise.all([registerViaApi(quote.id, user.id), registerViaApi(quote.id, user.id)]); + + const statuses = [first.status, second.status].sort(); + expect(statuses[0]).toBe(201); + expect(statuses[1]).toBeGreaterThanOrEqual(400); + + const ramps = await RampState.findAll({ where: { quoteId: quote.id } }); + expect(ramps.length).toBe(1); + }); + + // Pins the atomic-UPDATE backstop directly: even if the registration flow's + // row-locked pre-check were removed, consumeQuote must refuse a non-pending + // quote at the database level (WHERE status = 'pending'). + it("consumeQuote reports zero affected rows for an already-consumed quote", async () => { + const { BaseRampService } = await import("../api/services/ramp/base.service"); + class ConsumeQuoteProbe extends BaseRampService { + public consume(id: string) { + return this.consumeQuote(id); + } + } + const probe = new ConsumeQuoteProbe(); + const quote = await createQuoteViaApi(); + + const [firstAffected] = await probe.consume(quote.id); + expect(firstAffected).toBe(1); + + const [secondAffected] = await probe.consume(quote.id); + expect(secondAffected).toBe(0); + }); + + it("rejects a second registration after the quote is consumed", async () => { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + + const first = await registerViaApi(quote.id, user.id); + expect(first.status).toBe(201); + + const second = await registerViaApi(quote.id, user.id); + expect(second.status).toBe(400); + expect(await second.text()).toContain("consumed"); + }); +}); diff --git a/apps/api/src/tests/quote-pricing.golden.test.ts b/apps/api/src/tests/quote-pricing.golden.test.ts new file mode 100644 index 000000000..3b63e66c9 --- /dev/null +++ b/apps/api/src/tests/quote-pricing.golden.test.ts @@ -0,0 +1,252 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +/** + * Golden tests for the quote pricing math. Every external input is pinned: + * FakePrices rates (BRL 5/USD, USDC 1/USD), FakeBrla pay-in/pay-out rate 1, + * and a scripted Nabla quoter at 0.18 USDC per BRLA. Under those inputs the + * fee/output values below are pure functions of the pricing engines. + * + * A diff here means the pricing math changed. If that is intentional, update + * the goldens consciously and call out the fee impact in the PR description — + * never "fix" a golden to make CI pass. + */ +describe("quote pricing goldens (fixed input matrix)", () => { + let world: FakeWorld; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + await setupTestDatabase(); + await resetTestDatabase(); + app = await startTestApp(); + + // Deterministic Nabla swap quote: 18-decimal BRLA in → 6-decimal USDC out + // at a flat 0.18 USDC per BRLA. + world.evm.onReadContract = (_network, params) => { + if (params.functionName === "quoteSwapExactTokensForTokens") { + const amountIn = params.args?.[0] as bigint; + return (amountIn * 18n) / 100n / 10n ** 12n; + } + return undefined; + }; + }); + + afterAll(async () => { + await app?.close(); + world?.restore(); + }); + + const VOLATILE_FIELDS = ["id", "createdAt", "expiresAt"]; + + async function quoteViaApi(body: Record): Promise> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(201); + const quote = (await response.json()) as Record; + for (const field of VOLATILE_FIELDS) { + expect(quote[field]).toBeTruthy(); + delete quote[field]; + } + return quote; + } + + const GOLDENS: Array<{ name: string; request: Record; expected: Record }> = [ + { + expected: { + anchorFeeFiat: "0.1", + anchorFeeUsd: "0.02", + feeCurrency: "BRL", + from: "pix", + inputAmount: "100.00", + inputCurrency: "BRL", + network: "base", + networkFeeFiat: "0", + networkFeeUsd: "0", + outputAmount: "99.90", + outputCurrency: "BRLA", + partnerFeeFiat: "0", + partnerFeeUsd: "0", + paymentMethod: "pix", + processingFeeFiat: "0.1", + processingFeeUsd: "0.02", + rampType: "BUY", + to: "base", + totalFeeFiat: "0.10", + totalFeeUsd: "0.020000", + vortexFeeFiat: "0", + vortexFeeUsd: "0" + }, + name: "BUY 100 BRL → BRLA on Base (direct Avenia corridor)", + request: { + from: "pix", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + rampType: RampDirection.BUY, + to: Networks.Base + } + }, + { + expected: { + anchorFeeFiat: "0.1", + anchorFeeUsd: "0.02", + feeCurrency: "BRL", + from: "pix", + inputAmount: "250.50", + inputCurrency: "BRL", + network: "base", + networkFeeFiat: "0", + networkFeeUsd: "0", + outputAmount: "250.40", + outputCurrency: "BRLA", + partnerFeeFiat: "0", + partnerFeeUsd: "0", + paymentMethod: "pix", + processingFeeFiat: "0.1", + processingFeeUsd: "0.02", + rampType: "BUY", + to: "base", + totalFeeFiat: "0.10", + totalFeeUsd: "0.020000", + vortexFeeFiat: "0", + vortexFeeUsd: "0" + }, + name: "BUY 250.50 BRL → BRLA on Base (flat anchor fee, not proportional)", + request: { + from: "pix", + inputAmount: "250.50", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + rampType: RampDirection.BUY, + to: Networks.Base + } + }, + { + expected: { + anchorFeeFiat: "0.1", + anchorFeeUsd: "0.02", + discountCurrency: "BRL", + discountFiat: "10.09", + discountUsd: "2.018000", + feeCurrency: "BRL", + from: "pix", + inputAmount: "100.00", + inputCurrency: "BRL", + network: "base", + networkFeeFiat: "0", + networkFeeUsd: "0", + outputAmount: "20.00", + outputCurrency: "USDC", + partnerFeeFiat: "0", + partnerFeeUsd: "0", + paymentMethod: "pix", + processingFeeFiat: "0.1", + processingFeeUsd: "0.02", + rampType: "BUY", + to: "base", + totalFeeFiat: "0.10", + totalFeeUsd: "0.020000", + vortexFeeFiat: "0", + vortexFeeUsd: "0" + }, + name: "BUY 100 BRL → USDC on Base (Nabla swap at 0.18, subsidy applied)", + request: { + from: "pix", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base + } + }, + { + expected: { + anchorFeeFiat: "0", + anchorFeeUsd: "0", + feeCurrency: "BRL", + from: "base", + inputAmount: "100.00", + inputCurrency: "BRLA", + network: "base", + networkFeeFiat: "0", + networkFeeUsd: "0", + outputAmount: "500.00", + outputCurrency: "BRL", + partnerFeeFiat: "0", + partnerFeeUsd: "0", + paymentMethod: "pix", + processingFeeFiat: "0", + processingFeeUsd: "0", + rampType: "SELL", + to: "pix", + totalFeeFiat: "0.00", + totalFeeUsd: "0.000000", + vortexFeeFiat: "0", + vortexFeeUsd: "0" + }, + name: "SELL 100 BRLA on Base → BRL (direct Avenia payout, 5 BRL/USD feed)", + request: { + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.BRLA, + network: Networks.Base, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: "pix" + } + }, + { + expected: { + anchorFeeFiat: "0", + anchorFeeUsd: "0", + feeCurrency: "BRL", + from: "base", + inputAmount: "100.00", + inputCurrency: "USDC", + network: "base", + networkFeeFiat: "0", + networkFeeUsd: "0", + outputAmount: "500.00", + outputCurrency: "BRL", + partnerFeeFiat: "0", + partnerFeeUsd: "0", + paymentMethod: "pix", + processingFeeFiat: "0", + processingFeeUsd: "0", + rampType: "SELL", + to: "pix", + totalFeeFiat: "0.00", + totalFeeUsd: "0.000000", + vortexFeeFiat: "0", + vortexFeeUsd: "0" + }, + name: "SELL 100 USDC on Base → BRL (direct payout, 5 BRL/USD feed)", + request: { + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: "pix" + } + } + ]; + + for (const golden of GOLDENS) { + it(golden.name, async () => { + const quote = await quoteViaApi(golden.request); + expect(quote).toEqual(golden.expected); + }); + } +}); diff --git a/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts new file mode 100644 index 000000000..021a38234 --- /dev/null +++ b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts @@ -0,0 +1,391 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ERC20_TOKEN, + AlfredPayCountry, + AlfredpayFiatAccountType, + AlfredpayOfframpStatus, + EPaymentMethod, + EvmToken, + FiatToken, + type GetRampStatusResponse, + Networks, + RampDirection, + type UnsignedTx +} from "@vortexfi/shared"; +import { BaseError, ContractFunctionExecutionError, decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { VortexSdk } from "../../../../packages/sdk/src"; +import type AlfredPayCustomer from "../models/alfredPayCustomer.model"; +import QuoteTicket from "../models/quoteTicket.model"; +import RampState from "../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestAlfredpayCustomer, createTestApiKey, createTestUser } from "../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const POLYGON_CHAIN_ID_HEX = "0x89"; // 137 + +interface CurrencyCase { + fiat: FiatToken; + /** Alfredpay-side currency code expected on created orders. */ + alfredpayCurrency: string; + country: AlfredPayCountry; + /** Quote destination string (payment rail). */ + rail: EPaymentMethod; + /** USDT amount for the SELL quote (within the per-currency limits). */ + inputAmount: string; + /** Fiat the fake anchor pays out per USDT. */ + offrampRate: number; +} + +interface LifecycleCase extends CurrencyCase { + /** The user's payout account served by the fake anchor's listFiatAccounts. */ + fiatAccount: { + fiatAccountId: string; + type: AlfredpayFiatAccountType; + accountNumber: string; + accountType: string; + }; +} + +// The full lifecycle runs once per Alfredpay currency with its own SDK test. +// Rails per currency: USD and COP pay out over ach, ARS over cbu, MXN over +// spei (100 USDT * 20 = 2000 MXN, the same legible flat rate the MXN corridor +// scenario uses; the other rates mirror the FakePrices feeds). Each case +// seeds the payout-account shape the anchor serves for that country: US ACH +// bank accounts, MXN SPEI/CLABE, Colombian ACH accounts, Argentine +// COELSA/CBU. +const FULL_LIFECYCLE_CASES: LifecycleCase[] = [ + { + alfredpayCurrency: "USD", + country: AlfredPayCountry.US, + fiat: FiatToken.USD, + fiatAccount: { + accountNumber: "021000021000000", + accountType: "checking", + fiatAccountId: "fiat-account-usd-1", + type: AlfredpayFiatAccountType.ACH + }, + inputAmount: "5", + offrampRate: 1, + rail: EPaymentMethod.ACH + }, + { + alfredpayCurrency: "MXN", + country: AlfredPayCountry.MX, + fiat: FiatToken.MXN, + fiatAccount: { + accountNumber: "002010077777777771", // CLABE + accountType: "clabe", + fiatAccountId: "fiat-account-mxn-1", + type: AlfredpayFiatAccountType.SPEI + }, + inputAmount: "100", + offrampRate: 20, + rail: EPaymentMethod.SPEI + }, + { + alfredpayCurrency: "COP", + country: AlfredPayCountry.CO, + fiat: FiatToken.COP, + fiatAccount: { + accountNumber: "0110123456789", + accountType: "savings", + fiatAccountId: "fiat-account-cop-1", + type: AlfredpayFiatAccountType.ACH + }, + inputAmount: "100", + offrampRate: 4000, + rail: EPaymentMethod.ACH + }, + { + alfredpayCurrency: "ARS", + country: AlfredPayCountry.AR, + fiat: FiatToken.ARS, + fiatAccount: { + accountNumber: "2850590940090418135201", // CBU + accountType: "cbu", + fiatAccountId: "fiat-account-ars-1", + type: AlfredpayFiatAccountType.COELSA + }, + inputAmount: "100", + offrampRate: 1000, + rail: EPaymentMethod.CBU + } +]; + +/** + * Same shim as the other SDK contract tests: the SDK's viem wallet client + * issues one eth_chainId RPC before signing locally. The Alfredpay SELL + * corridor only ephemeral-signs on Polygon (the source-of-funds transfer is + * user-broadcast, never SDK-signed), so answer with the Polygon chain id. + */ +function installChainIdShim(): { restore: () => void } { + const guardedFetch = globalThis.fetch; + const shim = (async (input: Parameters[0], init?: Parameters[1]) => { + if (typeof init?.body === "string") { + try { + const payload = JSON.parse(init.body) as { id?: number; method?: string }; + if (payload.method === "eth_chainId") { + return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: POLYGON_CHAIN_ID_HEX }); + } + } catch { + // not JSON — let the guarded fetch decide + } + } + return guardedFetch(input, init); + }) as typeof fetch; + globalThis.fetch = Object.assign(shim, guardedFetch); + return { + restore: () => { + globalThis.fetch = guardedFetch; + } + }; +} + +/** + * SDK ↔ API contract tests for the Alfredpay SELL rail (USDT on Polygon → + * USD/MXN/COP/ARS bank payouts): the real SDK lists the user's registered + * fiat accounts, registers the offramp on the no-permit path (fiatAccountId + + * walletAddress), broadcasts the user's source-of-funds transfer via + * submitUserTransactions, and drives the ramp to completion — the full + * lifecycle runs once per currency: USD/ach, MXN/spei, COP/ach and ARS/cbu. + */ +describe("SDK ↔ API contract (Alfredpay offramps, USDT on Polygon → bank payout)", () => { + let world: FakeWorld; + let chainIdShim: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + chainIdShim = installChainIdShim(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + chainIdShim?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + // Fresh deposit address per test: the in-memory EVM ledger persists across + // tests, so a shared address would accumulate balances between scenarios. + world.alfredpay.offrampDepositAddress = privateKeyToAccount(generatePrivateKey()).address.toLowerCase(); + // Polygon USDT has no EIP-2612 support: the nonces() probe fails as a + // contract-call error, steering registration onto the no-permit path where + // the user broadcasts a plain transfer from their own wallet. + world.evm.onReadContract = (_network, params) => { + if (params.functionName === "nonces") { + throw new ContractFunctionExecutionError(new BaseError("nonces() reverted"), { + abi: erc20Abi, + contractAddress: params.address, + functionName: "nonces" + }); + } + return undefined; + }; + }); + + /** A user with a completed Alfredpay KYC profile and an SDK authenticated via their secret key. */ + async function createUserSdk(country: AlfredPayCountry): Promise<{ + sdk: VortexSdk; + userId: string; + customer: AlfredPayCustomer; + }> { + const user = await createTestUser(); + const customer = await createTestAlfredpayCustomer(user.id, { country }); + const { plaintextKey } = await createTestApiKey({ userId: user.id }); + const sdk = new VortexSdk({ apiBaseUrl: app.baseUrl, secretKey: plaintextKey, storeEphemeralKeys: false }); + return { customer, sdk, userId: user.id }; + } + + function quoteRequest(currency: CurrencyCase) { + return { + from: Networks.Polygon, + inputAmount: currency.inputAmount, + inputCurrency: EvmToken.USDT, + network: Networks.Polygon, + outputCurrency: currency.fiat, + rampType: RampDirection.SELL, + to: currency.rail + } as const; + } + + /** Funds the wallet's USDT so the SDK's offramp balance preflight passes against the fake ledger. */ + function fundWallet(walletAddress: string, inputAmount: string): void { + world.evm.setErc20Balance( + Networks.Polygon, + ALFREDPAY_ERC20_TOKEN, + walletAddress, + parseUnits(inputAmount, ALFREDPAY_ERC20_DECIMALS) + ); + } + + /** Broadcasts a user transaction from the wallet on its source chain, as a real wallet would. */ + function sendFromWallet(walletAddress: string) { + return async (txData: { to: string; data?: string; value?: string }, context: { unsignedTransaction: UnsignedTx }) => + world.evm.broadcastUserTransaction(context.unsignedTransaction.network, walletAddress, { + data: txData.data, + to: txData.to, + value: BigInt(txData.value ?? "0") + }); + } + + /** Scripts gas + the arrived user deposit on the ephemeral and applies raw ERC-20 transfers to the ledger. */ + async function scriptHappyWorld(rampId: string, quoteId: string): Promise<{ inputAmountRaw: bigint }> { + const state = await RampState.findByPk(rampId); + const quote = await QuoteTicket.findByPk(quoteId); + const ephemeralAddress = state?.state.evmEphemeralAddress as `0x${string}`; + expect(ephemeralAddress).toBeTruthy(); + const inputAmountRaw = BigInt(quote?.metadata.alfredpayOfframp?.inputAmountRaw ?? "0"); + expect(inputAmountRaw).toBeGreaterThan(0n); + + world.evm.setNativeBalance(Networks.Polygon, ephemeralAddress, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, ephemeralAddress, inputAmountRaw); + world.evm.onTransaction = tx => { + if (!tx.serialized) { + return; + } + const parsed = parseTransaction(tx.serialized as `0x${string}`); + if (!parsed.to || !parsed.data) { + return; + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + if (functionName !== "transfer") { + return; + } + const [recipient, amount] = args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + return { inputAmountRaw }; + } + + /** Polls getRampStatus (itself part of the contract) until the ramp completes. */ + async function waitForComplete(sdk: VortexSdk, rampId: string): Promise { + const deadline = Date.now() + 20_000; + for (;;) { + const status = await sdk.getRampStatus(rampId); + if (status.currentPhase === "complete") { + return status; + } + const state = await RampState.findByPk(rampId); + if (state?.currentPhase === "failed") { + throw new Error(`Ramp ${rampId} failed: ${JSON.stringify(state.errorLogs)}`); + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ramp ${rampId} to complete (phase: ${status.currentPhase})`); + } + await Bun.sleep(50); + } + } + + for (const currency of FULL_LIFECYCLE_CASES) { + it( + `drives the full ${currency.fiat}/${currency.rail} lifecycle: createQuote → listAlfredpayFiatAccounts → registerRamp → submitUserTransactions → startRamp → complete`, + async () => { + world.alfredpay.offrampRate = currency.offrampRate; + const { customer, sdk, userId } = await createUserSdk(currency.country); + + // The frontend-SDK flow picks the payout destination from the user's + // fiat accounts registered with the anchor. + world.alfredpay.fiatAccountsByCustomer.set(customer.alfredPayId, [ + { ...currency.fiatAccount, customerId: customer.alfredPayId } + ]); + const accounts = await sdk.listAlfredpayFiatAccounts(currency.country); + expect(accounts).toHaveLength(1); + expect(accounts[0].type).toBe(currency.fiatAccount.type); + const fiatAccountId = accounts[0].fiatAccountId; + expect(fiatAccountId).toBe(currency.fiatAccount.fiatAccountId); + + const wallet = privateKeyToAccount(generatePrivateKey()); + fundWallet(wallet.address, currency.inputAmount); + + // Quote contract: the rail and currency mapping the SDK promises. + const quote = await sdk.createQuote(quoteRequest(currency)); + expect(quote.id).toMatch(UUID_PATTERN); + expect(quote.rampType).toBe(RampDirection.SELL); + expect(quote.from).toBe(Networks.Polygon); + expect(quote.to).toBe(currency.rail); + expect(quote.network).toBe(Networks.Polygon); + expect(quote.inputCurrency).toBe(EvmToken.USDT); + expect(Number(quote.inputAmount)).toBe(Number(currency.inputAmount)); + expect(quote.outputCurrency).toBe(currency.fiat); + expect(Number(quote.outputAmount)).toBeGreaterThan(0); + expect(new Date(quote.expiresAt).getTime()).toBeGreaterThan(Date.now()); + + // registerRamp SELL contract on the no-permit path: exactly one + // user-wallet transaction comes back — the plain USDT transfer to the + // ephemeral — classified as a broadcastable EVM tx. + const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { + fiatAccountId, + walletAddress: wallet.address + }); + expect(rampProcess.id).toMatch(UUID_PATTERN); + expect(rampProcess.quoteId).toBe(quote.id); + expect(rampProcess.type).toBe(RampDirection.SELL); + expect(rampProcess.currentPhase).toBe("initial"); + expect(unsignedTransactions).toHaveLength(1); + const userTx = unsignedTransactions[0]; + expect(userTx.phase).toBe("squidRouterNoPermitTransfer"); + expect(userTx.network).toBe(Networks.Polygon); + expect(userTx.signer.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(sdk.getUserTransactionType(userTx)).toBe("evm-transaction"); + const broadcastable = sdk.getTransactionToBroadcast(userTx); + expect(broadcastable.to.toLowerCase()).toBe(ALFREDPAY_ERC20_TOKEN.toLowerCase()); + expect(broadcastable.data).toBeTruthy(); + + // The SDK already signed and stored the ephemeral's Polygon-side txs; + // registration created the anchor order on the no-permit path. + const stored = await RampState.findByPk(rampProcess.id); + expect(stored?.userId).toBe(userId); + expect(stored?.state.alfredpayTransactionId).toBeTruthy(); + expect(stored?.state.isNoPermitFallback).toBe(true); + const presignedPhases = (stored?.presignedTxs ?? []).map(tx => tx.phase); + expect(presignedPhases).toContain("alfredpayOfframpTransfer"); + // The user-wallet transfer must never be presigned by the ephemeral. + expect(presignedPhases).not.toContain("squidRouterNoPermitTransfer"); + + // submitUserTransactions broadcasts through the caller's wallet handler + // and reports the hash to the API. + const afterSubmit = await sdk.submitUserTransactions(rampProcess.id, unsignedTransactions, { + sendTransaction: sendFromWallet(wallet.address) + }); + expect(afterSubmit.id).toBe(rampProcess.id); + const withHash = await RampState.findByPk(rampProcess.id); + expect(withHash?.state.squidRouterNoPermitTransferHash).toBeTruthy(); + + const { inputAmountRaw } = await scriptHappyWorld(rampProcess.id, quote.id); + const depositAddress = world.alfredpay.offrampDepositAddress; + const started = await sdk.startRamp(rampProcess.id); + expect(started.id).toBe(rampProcess.id); + + const status = await waitForComplete(sdk, rampProcess.id); + expect(status.type).toBe(RampDirection.SELL); + expect(Number(status.inputAmount)).toBe(Number(quote.inputAmount)); + expect(Number(status.outputAmount)).toBe(Number(quote.outputAmount)); + + // End to end, the anchor's deposit address received the full USDT and + // the order maps USDT → this currency against the chosen fiat account. + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, depositAddress)).toBe(inputAmountRaw); + const order = world.alfredpay.offrampOrders[world.alfredpay.offrampOrders.length - 1]; + expect(order.fromCurrency).toBe("USDT" as never); + expect(order.toCurrency).toBe(currency.alfredpayCurrency as never); + expect(order.fiatAccountId).toBe(fiatAccountId); + }, + 30000 + ); + } +}); diff --git a/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts new file mode 100644 index 000000000..060b288d5 --- /dev/null +++ b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts @@ -0,0 +1,373 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ERC20_TOKEN, + AlfredPayCountry, + AlfredpayOnrampStatus, + EPaymentMethod, + EvmToken, + FiatToken, + type GetRampStatusResponse, + Networks, + RampDirection +} from "@vortexfi/shared"; +import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { VortexSdk } from "../../../../packages/sdk/src"; +import QuoteTicket from "../models/quoteTicket.model"; +import RampState from "../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestAlfredpayCustomer, createTestApiKey, createTestUser } from "../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const POLYGON_CHAIN_ID_HEX = "0x89"; // 137 + +interface CurrencyCase { + fiat: FiatToken; + /** Alfredpay-side currency code expected on created orders. */ + alfredpayCurrency: string; + /** KYC country the registration guard requires a completed profile for. */ + country: AlfredPayCountry; + /** Quote source string (payment rail). */ + rail: EPaymentMethod; + /** Fiat amount for the BUY quote (within the per-currency limits). */ + inputAmount: string; + /** USDT the fake anchor mints per unit of fiat. */ + onrampRate: number; + /** Rail-specific payment instructions registerRamp must surface as achPaymentData. */ + expectedInstructions: Record; +} + +// Rails per currency: MXN funds over spei, USD and COP over ach, ARS over cbu. +// The MXN rate is the same legible flat rate the MXN corridor scenario uses +// (2000 MXN * 0.05 = 100 USDT); the others mirror the FakePrices feeds. The +// expected instructions match FakeAlfredpay's rail-realistic per-currency +// defaults (fiatPaymentInstructionsByCurrency). +const FULL_LIFECYCLE_CASES: CurrencyCase[] = [ + { + alfredpayCurrency: "MXN", + country: AlfredPayCountry.MX, + expectedInstructions: { clabe: "646180157000000004", paymentType: "SPEI" }, + fiat: FiatToken.MXN, + inputAmount: "2000", + onrampRate: 0.05, + rail: EPaymentMethod.SPEI + }, + { + alfredpayCurrency: "USD", + country: AlfredPayCountry.US, + expectedInstructions: { + bankAccountNumber: "000123456789", + bankName: "Test Bank USA", + bankRoutingNumber: "021000021", + paymentType: "ACH" + }, + fiat: FiatToken.USD, + inputAmount: "20000", + onrampRate: 1, + rail: EPaymentMethod.ACH + }, + { + alfredpayCurrency: "COP", + country: AlfredPayCountry.CO, + expectedInstructions: { + bankAccountNumber: "01234567890", + bankName: "Bancolombia de Prueba", + bankRoutingNumber: "007", + paymentType: "ACH" + }, + fiat: FiatToken.COP, + inputAmount: "50000", + onrampRate: 1 / 4000, + rail: EPaymentMethod.ACH + }, + { + alfredpayCurrency: "ARS", + country: AlfredPayCountry.AR, + expectedInstructions: { + accountHolderName: "Vortex Test Account", + bankAccountNumber: "2850590940090418135201", + paymentType: "CBU" + }, + fiat: FiatToken.ARS, + inputAmount: "10000", + onrampRate: 1 / 1000, + rail: EPaymentMethod.CBU + } +]; + +/** + * Same shim as the other SDK contract tests: the SDK's viem wallet client + * issues one eth_chainId RPC before signing locally with the ephemeral key. + * The Alfredpay BUY corridors only ephemeral-sign on Polygon (the anchor mints + * USDT there), so answer with the Polygon chain id. + */ +function installChainIdShim(): { restore: () => void } { + const guardedFetch = globalThis.fetch; + const shim = (async (input: Parameters[0], init?: Parameters[1]) => { + if (typeof init?.body === "string") { + try { + const payload = JSON.parse(init.body) as { id?: number; method?: string }; + if (payload.method === "eth_chainId") { + return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: POLYGON_CHAIN_ID_HEX }); + } + } catch { + // not JSON — let the guarded fetch decide + } + } + return guardedFetch(input, init); + }) as typeof fetch; + globalThis.fetch = Object.assign(shim, guardedFetch); + return { + restore: () => { + globalThis.fetch = guardedFetch; + } + }; +} + +/** + * SDK ↔ API contract tests for the Alfredpay BUY rail (fiat → USDT on + * Polygon): the real @vortexfi/sdk drives the real in-process API. The full + * lifecycle runs once per currency (MXN/spei, USD/ach, COP/ach, ARS/cbu) with + * registerRamp's internal flow (ephemeral generation, /v1/ramp/register, + * signing the destinationTransfer + backups, and the /v1/ramp/update that + * creates the anchor order and returns the fiat payment details), then + * startRamp and getRampStatus polling to completion. + */ +describe("SDK ↔ API contract (Alfredpay onramps, fiat → USDT on Polygon)", () => { + let world: FakeWorld; + let chainIdShim: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + chainIdShim = installChainIdShim(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + chainIdShim?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.alfredpay.onrampRate = 1; + world.alfredpay.onCreateOnramp = undefined; + world.alfredpay.onrampStatus = AlfredpayOnrampStatus.TRADE_COMPLETED; + world.alfredpay.onrampStatusMetadata = null; + }); + + /** A user with a completed Alfredpay KYC profile and an SDK authenticated via their secret key. */ + async function createUserSdk(country: AlfredPayCountry): Promise<{ sdk: VortexSdk; userId: string }> { + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id, { country }); + const { plaintextKey } = await createTestApiKey({ userId: user.id }); + const sdk = new VortexSdk({ apiBaseUrl: app.baseUrl, secretKey: plaintextKey, storeEphemeralKeys: false }); + return { sdk, userId: user.id }; + } + + function quoteRequest(fiat: FiatToken, rail: EPaymentMethod, inputAmount: string) { + return { + from: rail, + inputAmount, + inputCurrency: fiat, + network: Networks.Polygon, + outputCurrency: EvmToken.USDT, + rampType: RampDirection.BUY, + to: Networks.Polygon + } as const; + } + + /** + * Scripts the fake world so every polling loop succeeds on its first check + * (same script as the corridor scenario tests): the Alfredpay mint has + * already credited the ephemeral's USDT, the ephemeral has Polygon gas, and + * submitted raw ERC-20 transfers are applied to the in-memory ledger. + */ + function scriptHappyWorld(ephemeralAddress: string, mintAmountRaw: bigint): void { + world.evm.setNativeBalance(Networks.Polygon, ephemeralAddress, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, ephemeralAddress, mintAmountRaw); + world.evm.onTransaction = tx => { + if (!tx.serialized) { + return; + } + const parsed = parseTransaction(tx.serialized as `0x${string}`); + if (!parsed.to || !parsed.data) { + return; + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + if (functionName !== "transfer") { + return; + } + const [recipient, amount] = args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + /** Polls getRampStatus (itself part of the contract) until the ramp completes. */ + async function waitForComplete(sdk: VortexSdk, rampId: string): Promise { + const deadline = Date.now() + 20_000; + for (;;) { + const status = await sdk.getRampStatus(rampId); + // Fail immediately (not via the poll timeout) if the status shape breaks. + expect(status.currentPhase).toBeDefined(); + if (status.currentPhase === "complete") { + return status; + } + // getRampStatus intentionally never reports "failed"; read the DB so a + // failed ramp aborts with its error logs instead of timing out. + const state = await RampState.findByPk(rampId); + if (state?.currentPhase === "failed") { + throw new Error(`Ramp ${rampId} failed: ${JSON.stringify(state.errorLogs)}`); + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ramp ${rampId} to complete (phase: ${status.currentPhase})`); + } + await Bun.sleep(50); + } + } + + for (const currency of FULL_LIFECYCLE_CASES) { + it( + `drives the full ${currency.fiat}/${currency.rail} lifecycle: createQuote → registerRamp → startRamp → getRampStatus`, + async () => { + world.alfredpay.onrampRate = currency.onrampRate; + const { sdk, userId } = await createUserSdk(currency.country); + const destination = privateKeyToAccount(generatePrivateKey()).address; + const ordersBefore = world.alfredpay.onrampOrders.length; + + const quote = await sdk.createQuote(quoteRequest(currency.fiat, currency.rail, currency.inputAmount)); + + // Quote contract: the fields the SDK's QuoteResponse type promises to integrators. + expect(quote.id).toMatch(UUID_PATTERN); + expect(quote.rampType).toBe(RampDirection.BUY); + expect(quote.from).toBe(currency.rail); + expect(quote.to).toBe(Networks.Polygon); + expect(quote.network).toBe(Networks.Polygon); + expect(Number(quote.inputAmount)).toBe(Number(currency.inputAmount)); + expect(quote.inputCurrency).toBe(currency.fiat); + expect(quote.outputCurrency).toBe(EvmToken.USDT); + expect(Number(quote.outputAmount)).toBeGreaterThan(0); + expect(new Date(quote.expiresAt).getTime()).toBeGreaterThan(Date.now()); + const feeFields = [ + quote.networkFeeFiat, + quote.anchorFeeFiat, + quote.vortexFeeFiat, + quote.partnerFeeFiat, + quote.totalFeeFiat, + quote.processingFeeFiat, + quote.networkFeeUsd, + quote.anchorFeeUsd, + quote.vortexFeeUsd, + quote.partnerFeeUsd, + quote.totalFeeUsd, + quote.processingFeeUsd + ]; + for (const fee of feeFields) { + expect(Number.isFinite(Number(fee))).toBe(true); + } + expect(quote.feeCurrency).toBeTruthy(); + + // registerRamp runs the SDK's full internal Alfredpay BUY flow: ephemeral + // generation (Substrate + EVM), /v1/ramp/register, signing the returned + // destinationTransfer (plus its backups), and the /v1/ramp/update that + // creates the anchor order — there is no separate user sign/update step. + const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { destinationAddress: destination }); + + // Alfredpay onramps settle fiat off-chain: no user-wallet transactions. + expect(unsignedTransactions).toEqual([]); + expect(rampProcess.id).toMatch(UUID_PATTERN); + expect(rampProcess.quoteId).toBe(quote.id); + expect(rampProcess.type).toBe(RampDirection.BUY); + expect(rampProcess.currentPhase).toBe("initial"); + expect(Number(rampProcess.inputAmount)).toBe(Number(quote.inputAmount)); + expect(Number(rampProcess.outputAmount)).toBe(Number(quote.outputAmount)); + + // The client-visible payment details: registerRamp surfaces the + // anchor's rail-specific fiatPaymentInstructions verbatim as + // achPaymentData (SPEI/CLABE for MXN, ACH bank fields for USD/COP, + // CBU for ARS). + expect(rampProcess.achPaymentData).toMatchObject(currency.expectedInstructions); + expect(rampProcess.achPaymentData?.reference).toBeTruthy(); + + // The ephemeral surface of the direct Alfredpay BUY route: the + // destination transfer plus the Polygon dust cleanup. + const unsigned = rampProcess.unsignedTxs ?? []; + expect(unsigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "polygonCleanup"]); + expect(unsigned.every(tx => tx.network === Networks.Polygon)).toBe(true); + const destinationTransferTx = unsigned.find(tx => tx.phase === "destinationTransfer"); + if (!destinationTransferTx) { + throw new Error("No destinationTransfer in unsignedTxs"); + } + const ephemeralAddress = destinationTransferTx.signer; + + // The SDK-signed transfer stored by /v1/ramp/update must pay the + // registered destination exactly the quoted USDT on Polygon — the core + // signing contract between SDK and backend. + const stored = await RampState.findByPk(rampProcess.id); + expect(stored?.userId).toBe(userId); + expect(stored?.state.alfredpayTransactionId).toBeTruthy(); + const presigned = stored?.presignedTxs ?? []; + expect(presigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "polygonCleanup"]); + const presignedTransfer = presigned.find(tx => tx.phase === "destinationTransfer"); + if (!presignedTransfer) { + throw new Error("No presigned destinationTransfer"); + } + expect(presignedTransfer.signer).toBe(ephemeralAddress); + const parsed = parseTransaction(presignedTransfer.txData as `0x${string}`); + expect(parsed.chainId).toBe(137); + expect(parsed.to?.toLowerCase()).toBe(ALFREDPAY_ERC20_TOKEN.toLowerCase()); + if (!parsed.data) { + throw new Error("Presigned destinationTransfer has no calldata"); + } + const decoded = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + expect(decoded.functionName).toBe("transfer"); + const amountRaw = parseUnits(quote.outputAmount, ALFREDPAY_ERC20_DECIMALS); + expect(decoded.args).toEqual([destination, amountRaw]); + + // Registration created exactly one anchor order: this currency's + // Alfredpay code in, USDT minted to the ephemeral. + expect(world.alfredpay.onrampOrders).toHaveLength(ordersBefore + 1); + const order = world.alfredpay.onrampOrders[world.alfredpay.onrampOrders.length - 1]; + expect(order.fromCurrency).toBe(currency.alfredpayCurrency as never); + expect(order.toCurrency).toBe("USDT" as never); + expect(Number(order.amount)).toBe(Number(currency.inputAmount)); + expect(order.depositAddress.toLowerCase()).toBe(ephemeralAddress.toLowerCase()); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const mintAmountRaw = BigInt(persistedQuote?.metadata.alfredpayMint?.outputAmountRaw ?? "0"); + expect(mintAmountRaw).toBeGreaterThan(0n); + + scriptHappyWorld(ephemeralAddress, mintAmountRaw); + const started = await sdk.startRamp(rampProcess.id); + expect(started.id).toBe(rampProcess.id); + expect(started.quoteId).toBe(quote.id); + + const status = await waitForComplete(sdk, rampProcess.id); + expect(status.id).toBe(rampProcess.id); + expect(status.quoteId).toBe(quote.id); + expect(status.type).toBe(RampDirection.BUY); + expect(status.from).toBe(currency.rail); + expect(status.to).toBe(Networks.Polygon); + expect(Number(status.inputAmount)).toBe(Number(quote.inputAmount)); + expect(Number(status.outputAmount)).toBe(Number(quote.outputAmount)); + + // End to end, the destination received the quoted amount in the fake ledger. + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, destination)).toBe(amountRaw); + }, + 30000 + ); + } +}); diff --git a/apps/api/src/tests/sdk-contract.offramp.test.ts b/apps/api/src/tests/sdk-contract.offramp.test.ts new file mode 100644 index 000000000..4477a4e93 --- /dev/null +++ b/apps/api/src/tests/sdk-contract.offramp.test.ts @@ -0,0 +1,340 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + AlfredPayCountry, + AlfredpayFiatAccountType, + AveniaTicketStatus, + EPaymentMethod, + EvmToken, + evmTokenConfig, + FiatToken, + type GetRampStatusResponse, + Networks, + type RampPhase, + RampDirection, + type UnsignedTx +} from "@vortexfi/shared"; +import { parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { VortexSdk } from "../../../../packages/sdk/src"; +import Partner from "../models/partner.model"; +import QuoteTicket from "../models/quoteTicket.model"; +import RampState from "../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestAlfredpayCustomer, createTestApiKey, createTestTaxId, createTestUser } from "../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +function requireToken(network: Networks.Base | Networks.Polygon, token: EvmToken) { + const details = evmTokenConfig[network][token]; + if (!details) { + throw new Error(`${token} token config missing for ${network}`); + } + return details; +} +const USDC_ON_BASE = requireToken(Networks.Base, EvmToken.USDC).erc20AddressSourceChain as `0x${string}`; +const USDC_ON_POLYGON = requireToken(Networks.Polygon, EvmToken.USDC).erc20AddressSourceChain as `0x${string}`; +const BRLA_ON_BASE = requireToken(Networks.Base, EvmToken.BRLA).erc20AddressSourceChain as `0x${string}`; + +const TAX_ID = "12345678901"; +const RECEIVER_TAX_ID = "12345678900"; +const PIX_KEY = "test-pix-key"; +const BASE_CHAIN_ID_HEX = "0x2105"; // 8453 + +/** + * Same shim as sdk-contract.test.ts: the SDK's viem wallet client issues one + * eth_chainId RPC before signing locally; the SELL corridor only ephemeral-signs + * on Base (the Polygon squid leg is user-broadcast, never SDK-signed). + */ +function installChainIdShim(): { restore: () => void } { + const guardedFetch = globalThis.fetch; + const shim = (async (input: Parameters[0], init?: Parameters[1]) => { + if (typeof init?.body === "string") { + try { + const payload = JSON.parse(init.body) as { id?: number; method?: string }; + if (payload.method === "eth_chainId") { + return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: BASE_CHAIN_ID_HEX }); + } + } catch { + // not JSON — let the guarded fetch decide + } + } + return guardedFetch(input, init); + }) as typeof fetch; + globalThis.fetch = Object.assign(shim, guardedFetch); + return { + restore: () => { + globalThis.fetch = guardedFetch; + } + }; +} + +/** + * SDK ↔ API contract tests for the SELL direction and the user-transaction + * surface: the real SDK registers a cross-chain BRL offramp (USDC on Polygon → + * pix), classifies and submits the user-wallet squid transactions + * (getUserTransactionType / getTransactionToBroadcast / submitUserTransactions + * / submitUserTxHash), reports hashes via the typed updateRamp, and drives the + * ramp to completion — plus getQuote and listAlfredpayFiatAccounts, which had + * no contract coverage. + */ +describe("SDK ↔ API contract (BRL offramp, USDC on Polygon → pix)", () => { + let world: FakeWorld; + let chainIdShim: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + chainIdShim = installChainIdShim(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + chainIdShim?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + await Partner.update( + { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, + { where: { name: "vortex", rampType: RampDirection.SELL } } + ); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.brla.onPixOutputTicket = undefined; + world.brla.accountBalances = { BRLA: 1_000_000, USDC: 0, USDM: 0, USDT: 0 }; + world.brla.payoutTicketStatus = AveniaTicketStatus.PAID; + world.brla.subaccountEvmWallet = privateKeyToAccount(generatePrivateKey()).address.toLowerCase(); + world.squidRouter.toTokenDecimals = 6; + world.evm.onReadContract = (_network, params) => { + if (params.functionName === "quoteSwapExactTokensForTokens") { + const amountIn = params.args?.[0] as bigint; + return amountIn * 5n * 10n ** 12n; + } + return undefined; + }; + }); + + /** A KYC'd user with a user-linked secret key, and an SDK authenticated as them. */ + async function createUserSdk(): Promise<{ sdk: VortexSdk; userId: string }> { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const { plaintextKey } = await createTestApiKey({ userId: user.id }); + const sdk = new VortexSdk({ apiBaseUrl: app.baseUrl, secretKey: plaintextKey, storeEphemeralKeys: false }); + return { sdk, userId: user.id }; + } + + function quoteRequest() { + return { + from: Networks.Polygon, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Polygon, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX + } as const; + } + + /** + * Registers a SELL ramp through the SDK for a wallet that holds enough USDC + * on Polygon (the SDK's preflight reads the fake ledger). + */ + async function registerOfframp(sdk: VortexSdk) { + const wallet = privateKeyToAccount(generatePrivateKey()); + world.evm.setErc20Balance(Networks.Polygon, USDC_ON_POLYGON, wallet.address, parseUnits("100", 6)); + + const quote = await sdk.createQuote(quoteRequest()); + const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { + pixDestination: PIX_KEY, + receiverTaxId: RECEIVER_TAX_ID, + walletAddress: wallet.address + }); + return { quote, rampProcess, unsignedTransactions, wallet }; + } + + /** Broadcasts a user transaction from the wallet on its source chain, as a real wallet would. */ + function sendFromWallet(walletAddress: string) { + return async (txData: { to: string; data?: string; value?: string }, context: { unsignedTransaction: UnsignedTx }) => + world.evm.broadcastUserTransaction(context.unsignedTransaction.network, walletAddress, { + data: txData.data, + to: txData.to, + value: BigInt(txData.value ?? "0") + }); + } + + /** Scripts gas + bridged USDC on Base and the swap/payout ledger effects for a registered ramp. */ + async function scriptHappyWorld(rampId: string, quoteId: string): Promise<{ swapOutputRaw: bigint }> { + const state = await RampState.findByPk(rampId); + const quote = await QuoteTicket.findByPk(quoteId); + const ephemeralAddress = state?.state.evmEphemeralAddress as `0x${string}`; + expect(ephemeralAddress).toBeTruthy(); + const swapInputRaw = BigInt(quote?.metadata.nablaSwapEvm?.inputAmountForSwapRaw ?? "0"); + const swapOutputRaw = BigInt(quote?.metadata.nablaSwapEvm?.outputAmountRaw ?? "0"); + expect(swapInputRaw).toBeGreaterThan(0n); + expect(swapOutputRaw).toBeGreaterThan(0n); + + const signedNablaSwap = state?.presignedTxs?.find(tx => tx.phase === "nablaSwap")?.txData as `0x${string}`; + const signedPayout = state?.presignedTxs?.find(tx => tx.phase === "brlaPayoutOnBase")?.txData as `0x${string}`; + expect(signedNablaSwap).toBeTruthy(); + expect(signedPayout).toBeTruthy(); + + world.evm.setNativeBalance(Networks.Base, ephemeralAddress, parseUnits("2", 18)); + world.evm.setErc20Balance(Networks.Base, USDC_ON_BASE, ephemeralAddress, swapInputRaw); + world.evm.onTransaction = tx => { + if (tx.serialized === signedNablaSwap) { + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, ephemeralAddress, swapOutputRaw); + return; + } + if (tx.serialized === signedPayout) { + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet, swapOutputRaw); + } + }; + return { swapOutputRaw }; + } + + /** Polls getRampStatus (itself part of the contract) until the ramp completes. */ + async function waitForComplete(sdk: VortexSdk, rampId: string): Promise { + const deadline = Date.now() + 20_000; + for (;;) { + const status = await sdk.getRampStatus(rampId); + if (status.currentPhase === "complete") { + return status; + } + const state = await RampState.findByPk(rampId); + if (state?.currentPhase === "failed") { + throw new Error(`Ramp ${rampId} failed: ${JSON.stringify(state.errorLogs)}`); + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ramp ${rampId} to complete (phase: ${status.currentPhase})`); + } + await Bun.sleep(50); + } + } + + it( + "drives the full offramp lifecycle: createQuote → getQuote → registerRamp → submitUserTransactions → startRamp → complete", + async () => { + const { sdk, userId } = await createUserSdk(); + const { quote, rampProcess, unsignedTransactions, wallet } = await registerOfframp(sdk); + + // getQuote contract: fetching the quote by id returns the created quote. + const fetched = await sdk.getQuote(quote.id); + expect(fetched.id).toBe(quote.id); + expect(fetched.rampType).toBe(RampDirection.SELL); + expect(Number(fetched.inputAmount)).toBe(Number(quote.inputAmount)); + expect(Number(fetched.outputAmount)).toBe(Number(quote.outputAmount)); + expect(fetched.outputCurrency).toBe(FiatToken.BRL); + + // registerRamp SELL contract: the user-wallet squid transactions come + // back for the caller's wallet, classified as broadcastable EVM txs. + expect(rampProcess.type).toBe(RampDirection.SELL); + expect(rampProcess.currentPhase).toBe("initial"); + expect(unsignedTransactions).toHaveLength(2); + const phases = unsignedTransactions.map(tx => tx.phase).sort(); + expect(phases).toEqual(["squidRouterApprove", "squidRouterSwap"] as RampPhase[]); + for (const tx of unsignedTransactions) { + expect(tx.network).toBe(Networks.Polygon); + expect(tx.signer.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(sdk.getUserTransactionType(tx)).toBe("evm-transaction"); + const broadcastable = sdk.getTransactionToBroadcast(tx); + expect(broadcastable.to).toBeTruthy(); + expect(broadcastable.data).toBeTruthy(); + } + + // The SDK already signed and stored the ephemeral's Base-side txs. + const stored = await RampState.findByPk(rampProcess.id); + expect(stored?.userId).toBe(userId); + const presignedPhases = (stored?.presignedTxs ?? []).map(tx => tx.phase); + for (const phase of ["nablaApprove", "nablaSwap", "brlaPayoutOnBase"] as RampPhase[]) { + expect(presignedPhases).toContain(phase); + } + // User-wallet phases must never be presigned (the API rejects them). + expect(presignedPhases).not.toContain("squidRouterApprove"); + expect(presignedPhases).not.toContain("squidRouterSwap"); + + // submitUserTransactions broadcasts through the caller's wallet handler + // and reports each hash to the API. + const afterSubmit = await sdk.submitUserTransactions(rampProcess.id, unsignedTransactions, { + sendTransaction: sendFromWallet(wallet.address) + }); + expect(afterSubmit.id).toBe(rampProcess.id); + + const withHashes = await RampState.findByPk(rampProcess.id); + expect(withHashes?.state.squidRouterApproveHash).toBeTruthy(); + expect(withHashes?.state.squidRouterSwapHash).toBeTruthy(); + + const { swapOutputRaw } = await scriptHappyWorld(rampProcess.id, quote.id); + const started = await sdk.startRamp(rampProcess.id); + expect(started.id).toBe(rampProcess.id); + + const status = await waitForComplete(sdk, rampProcess.id); + expect(status.type).toBe(RampDirection.SELL); + expect(Number(status.inputAmount)).toBe(Number(quote.inputAmount)); + expect(Number(status.outputAmount)).toBe(Number(quote.outputAmount)); + + // End to end, the Avenia subaccount received the swap output and a pix + // payout ticket was created. + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(swapOutputRaw); + expect(world.brla.pixOutputTickets.length).toBe(1); + }, + 30000 + ); + + it( + "updateRamp (typed SELL update) records user-reported squid hashes against the ramp", + async () => { + const { sdk } = await createUserSdk(); + const { quote, rampProcess, unsignedTransactions, wallet } = await registerOfframp(sdk); + + // The integrator broadcasts outside the SDK and reports the hashes + // through the typed updateRamp call instead of submitUserTxHash. + const byPhase = Object.fromEntries(unsignedTransactions.map(tx => [tx.phase, tx])); + const broadcast = sendFromWallet(wallet.address); + const approveHash = await broadcast(byPhase.squidRouterApprove.txData as { to: string; data?: string; value?: string }, { + unsignedTransaction: byPhase.squidRouterApprove + }); + const swapHash = await broadcast(byPhase.squidRouterSwap.txData as { to: string; data?: string; value?: string }, { + unsignedTransaction: byPhase.squidRouterSwap + }); + + const updated = await sdk.updateRamp(quote, rampProcess.id, { + squidRouterApproveHash: approveHash, + squidRouterSwapHash: swapHash + }); + expect(updated.id).toBe(rampProcess.id); + + const state = await RampState.findByPk(rampProcess.id); + expect(state?.state.squidRouterApproveHash).toBe(approveHash); + expect(state?.state.squidRouterSwapHash).toBe(swapHash); + }, + 30000 + ); + + it( + "listAlfredpayFiatAccounts returns the caller's registered fiat accounts", + async () => { + const user = await createTestUser(); + const { plaintextKey } = await createTestApiKey({ userId: user.id }); + const customer = await createTestAlfredpayCustomer(user.id, { country: AlfredPayCountry.MX }); + world.alfredpay.fiatAccountsByCustomer.set(customer.alfredPayId, [ + { + accountNumber: "646180157000000004", + accountType: "checking", + customerId: customer.alfredPayId, + fiatAccountId: "fiat-account-1", + type: AlfredpayFiatAccountType.SPEI + } + ]); + + const sdk = new VortexSdk({ apiBaseUrl: app.baseUrl, secretKey: plaintextKey, storeEphemeralKeys: false }); + const accounts = await sdk.listAlfredpayFiatAccounts(AlfredPayCountry.MX); + expect(accounts).toHaveLength(1); + expect(accounts[0].fiatAccountId).toBe("fiat-account-1"); + expect(accounts[0].type).toBe(AlfredpayFiatAccountType.SPEI); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/sdk-contract.test.ts b/apps/api/src/tests/sdk-contract.test.ts new file mode 100644 index 000000000..38c301b60 --- /dev/null +++ b/apps/api/src/tests/sdk-contract.test.ts @@ -0,0 +1,327 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + EPaymentMethod, + EvmToken, + evmTokenConfig, + FiatToken, + type GetRampStatusResponse, + Networks, + RampDirection +} from "@vortexfi/shared"; +import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { VortexSdk, VortexSdkError } from "../../../../packages/sdk/src"; +import RampState from "../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestApiKey, createTestTaxId, createTestUser } from "../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +function requireBrlaOnBase() { + const details = evmTokenConfig[Networks.Base][EvmToken.BRLA]; + if (!details) { + throw new Error("BRLA token config missing for Base"); + } + return details; +} +const brlaTokenDetails = requireBrlaOnBase(); +const BRLA_ON_BASE = brlaTokenDetails.erc20AddressSourceChain as `0x${string}`; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const BASE_CHAIN_ID_HEX = "0x2105"; // 8453 + +/** + * The SDK signs EVM transactions through a real viem wallet client, and viem's + * signTransaction issues a single eth_chainId RPC before signing locally with + * the ephemeral key. Answer that one call in-memory (the corridor only signs on + * Base) and let every other request fall through to the fetch guard, so any + * genuine network use still fails loudly. The SDK's NetworkManager itself is + * inert here: it only opens chain WebSockets when signing Pendulum, Moonbeam, + * or Hydration transactions, and the direct BRL→BRLA-on-Base corridor produces + * a single Base EVM transaction. + */ +function installChainIdShim(): { restore: () => void } { + const guardedFetch = globalThis.fetch; + const shim = (async (input: Parameters[0], init?: Parameters[1]) => { + if (typeof init?.body === "string") { + try { + const payload = JSON.parse(init.body) as { id?: number; method?: string }; + if (payload.method === "eth_chainId") { + return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: BASE_CHAIN_ID_HEX }); + } + } catch { + // not JSON — let the guarded fetch decide + } + } + return guardedFetch(input, init); + }) as typeof fetch; + globalThis.fetch = Object.assign(shim, guardedFetch); + return { + restore: () => { + globalThis.fetch = guardedFetch; + } + }; +} + +/** + * SDK ↔ API contract tests: the real @vortexfi/sdk (imported from source) + * drives the real in-process API over HTTP on the BRL onramp corridor + * (pix → BRLA on Base; EUR is kill-switched at registerRamp). A response-shape + * change that would break SDK integrators fails here. + */ +describe("SDK ↔ API contract (BRL onramp, pix → BRLA on Base)", () => { + let world: FakeWorld; + let chainIdShim: { restore: () => void }; + let app: TestApp; + + beforeAll(async () => { + world = installFakeWorld(); + chainIdShim = installChainIdShim(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + chainIdShim?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + world.evm.failNextSends = 0; + world.evm.onTransaction = undefined; + world.brla.onPixOutputTicket = undefined; + world.brla.accountBalances = { BRLA: 0, USDC: 0, USDM: 0, USDT: 0 }; + }); + + /** A KYC'd user with a user-linked secret key, and an SDK authenticated as them. */ + async function createUserSdk(): Promise<{ sdk: VortexSdk; userId: string }> { + const user = await createTestUser(); + await createTestTaxId(user.id); + const { plaintextKey } = await createTestApiKey({ userId: user.id }); + // storeEphemeralKeys: false keeps the SDK from writing ephemerals_.json to disk. + const sdk = new VortexSdk({ apiBaseUrl: app.baseUrl, secretKey: plaintextKey, storeEphemeralKeys: false }); + return { sdk, userId: user.id }; + } + + function quoteRequest() { + return { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + rampType: RampDirection.BUY, + to: Networks.Base + } as const; + } + + /** + * Scripts the fake world so every polling loop in the corridor succeeds on + * its first check (same script as the corridor scenario tests): minted BRL is + * already on the Avenia subaccount, the ephemeral has Base gas, the + * Avenia→Base transfer credits BRLA instantly, and submitted raw ERC-20 + * transfers are applied to the in-memory ledger. + */ + function scriptHappyWorld(ephemeralAddress: string): void { + world.brla.accountBalances.BRLA = 1_000_000; + world.evm.setNativeBalance(Networks.Base, ephemeralAddress, parseUnits("0.001", 18)); + world.brla.onPixOutputTicket = ({ walletAddress }) => { + if (walletAddress) { + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, walletAddress, parseUnits("1000000", 18)); + } + }; + world.evm.onTransaction = tx => { + if (!tx.serialized) { + return; + } + const parsed = parseTransaction(tx.serialized as `0x${string}`); + if (!parsed.to || !parsed.data) { + return; + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + if (functionName !== "transfer") { + return; + } + const [recipient, amount] = args as [`0x${string}`, bigint]; + world.evm.setErc20Balance( + tx.network, + parsed.to, + recipient, + world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount + ); + }; + } + + /** Polls getRampStatus (itself part of the contract) until the ramp completes. */ + async function waitForComplete(sdk: VortexSdk, rampId: string): Promise { + const deadline = Date.now() + 20_000; + for (;;) { + const status = await sdk.getRampStatus(rampId); + // Fail immediately (not via the poll timeout) if the status shape breaks. + expect(status.currentPhase).toBeDefined(); + if (status.currentPhase === "complete") { + return status; + } + // getRampStatus intentionally never reports "failed"; read the DB so a + // failed ramp aborts with its error logs instead of timing out. + const state = await RampState.findByPk(rampId); + if (state?.currentPhase === "failed") { + throw new Error(`Ramp ${rampId} failed: ${JSON.stringify(state.errorLogs)}`); + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ramp ${rampId} to complete (phase: ${status.currentPhase})`); + } + await Bun.sleep(50); + } + } + + it( + "drives the full onramp lifecycle: createQuote → registerRamp → startRamp → getRampStatus", + async () => { + const { sdk, userId } = await createUserSdk(); + const destination = privateKeyToAccount(generatePrivateKey()).address; + + const quote = await sdk.createQuote(quoteRequest()); + + // Quote contract: the fields the SDK's QuoteResponse type promises to integrators. + expect(quote.id).toMatch(UUID_PATTERN); + expect(quote.rampType).toBe(RampDirection.BUY); + expect(quote.from).toBe(EPaymentMethod.PIX); + expect(quote.to).toBe(Networks.Base); + expect(quote.network).toBe(Networks.Base); + // The API normalizes fiat amounts to two decimals ("100" -> "100.00"). + expect(Number(quote.inputAmount)).toBe(100); + expect(quote.inputCurrency).toBe(FiatToken.BRL); + expect(quote.outputCurrency).toBe(EvmToken.BRLA); + expect(Number(quote.outputAmount)).toBeGreaterThan(0); + expect(new Date(quote.expiresAt).getTime()).toBeGreaterThan(Date.now()); + const feeFields = [ + quote.networkFeeFiat, + quote.anchorFeeFiat, + quote.vortexFeeFiat, + quote.partnerFeeFiat, + quote.totalFeeFiat, + quote.processingFeeFiat, + quote.networkFeeUsd, + quote.anchorFeeUsd, + quote.vortexFeeUsd, + quote.partnerFeeUsd, + quote.totalFeeUsd, + quote.processingFeeUsd + ]; + for (const fee of feeFields) { + expect(Number.isFinite(Number(fee))).toBe(true); + } + expect(quote.feeCurrency).toBeTruthy(); + + // registerRamp runs the SDK's full internal flow: BRL limit pre-flight, + // ephemeral generation (Substrate + EVM), /v1/ramp/register, signing the + // returned unsignedTxs, and /v1/ramp/update with the presigned txs. + const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { destinationAddress: destination }); + + // Onramps carry no user-wallet transactions. + expect(unsignedTransactions).toEqual([]); + expect(rampProcess.id).toMatch(UUID_PATTERN); + expect(rampProcess.quoteId).toBe(quote.id); + expect(rampProcess.type).toBe(RampDirection.BUY); + expect(rampProcess.currentPhase).toBe("initial"); + // Amount decimal padding differs between endpoints; the numeric value is the contract. + expect(Number(rampProcess.inputAmount)).toBe(Number(quote.inputAmount)); + expect(Number(rampProcess.outputAmount)).toBe(Number(quote.outputAmount)); + const unsigned = rampProcess.unsignedTxs ?? []; + expect(unsigned).toHaveLength(1); + expect(unsigned[0].phase).toBe("destinationTransfer"); + expect(unsigned[0].network).toBe(Networks.Base); + const ephemeralAddress = unsigned[0].signer; + + // The SDK-signed transfer stored by /v1/ramp/update must pay the + // registered destination exactly the quoted BRLA on Base — the core + // signing contract between SDK and backend. + const stored = await RampState.findByPk(rampProcess.id); + expect(stored?.userId).toBe(userId); + const presigned = stored?.presignedTxs ?? []; + expect(presigned).toHaveLength(1); + expect(presigned[0].phase).toBe("destinationTransfer"); + expect(presigned[0].signer).toBe(ephemeralAddress); + const parsed = parseTransaction(presigned[0].txData as `0x${string}`); + expect(parsed.chainId).toBe(8453); + expect(parsed.to?.toLowerCase()).toBe(BRLA_ON_BASE.toLowerCase()); + if (!parsed.data) { + throw new Error("Presigned destinationTransfer has no calldata"); + } + const decoded = decodeFunctionData({ abi: erc20Abi, data: parsed.data }); + expect(decoded.functionName).toBe("transfer"); + const amountRaw = parseUnits(quote.outputAmount, brlaTokenDetails.decimals); + expect(decoded.args).toEqual([destination, amountRaw]); + + // startRamp re-validates the SDK's presigned txs server-side (including + // backup-transaction requirements) and kicks off processing. + scriptHappyWorld(ephemeralAddress); + const started = await sdk.startRamp(rampProcess.id); + expect(started.id).toBe(rampProcess.id); + expect(started.quoteId).toBe(quote.id); + + const status = await waitForComplete(sdk, rampProcess.id); + expect(status.id).toBe(rampProcess.id); + expect(status.quoteId).toBe(quote.id); + expect(status.type).toBe(RampDirection.BUY); + expect(status.from).toBe(EPaymentMethod.PIX); + expect(status.to).toBe(Networks.Base); + expect(Number(status.inputAmount)).toBe(Number(quote.inputAmount)); + expect(Number(status.outputAmount)).toBe(Number(quote.outputAmount)); + expect(status.transactionHash).toBeTruthy(); + + // End to end, the destination received the quoted amount in the fake ledger. + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, destination)).toBe(amountRaw); + }, + 30000 + ); + + it( + "registerRamp without a secretKey fails fast in the SDK, before any registration call", + async () => { + const anonymous = new VortexSdk({ apiBaseUrl: app.baseUrl, storeEphemeralKeys: false }); + // Quotes stay anonymous-eligible (rate discovery); only registration requires the key. + const quote = await anonymous.createQuote(quoteRequest()); + const destination = privateKeyToAccount(generatePrivateKey()).address; + + await expect(anonymous.registerRamp(quote, { destinationAddress: destination })).rejects.toThrow( + /requires a user-linked secretKey/ + ); + }, + 30000 + ); + + it( + "a foreign user's ramp surfaces as a typed VortexSdkError with status 403", + async () => { + const owner = await createUserSdk(); + const stranger = await createUserSdk(); + const destination = privateKeyToAccount(generatePrivateKey()).address; + + const quote = await owner.sdk.createQuote(quoteRequest()); + const { rampProcess } = await owner.sdk.registerRamp(quote, { destinationAddress: destination }); + + const statusError = await stranger.sdk.getRampStatus(rampProcess.id).then( + () => null, + error => error + ); + expect(statusError).toBeInstanceOf(VortexSdkError); + expect((statusError as VortexSdkError).status).toBe(403); + + const startError = await stranger.sdk.startRamp(rampProcess.id).then( + () => null, + error => error + ); + expect(startError).toBeInstanceOf(VortexSdkError); + expect((startError as VortexSdkError).status).toBe(403); + + // The owner is unaffected. + const status = await owner.sdk.getRampStatus(rampProcess.id); + expect(status.id).toBe(rampProcess.id); + }, + 30000 + ); +}); diff --git a/apps/api/src/tests/subsidy-recording.invariants.test.ts b/apps/api/src/tests/subsidy-recording.invariants.test.ts new file mode 100644 index 000000000..4c20807da --- /dev/null +++ b/apps/api/src/tests/subsidy-recording.invariants.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { RampPhase } from "@vortexfi/shared"; +import { BasePhaseHandler } from "../api/services/phases/base-phase-handler"; +import RampState from "../models/rampState.model"; +import Subsidy from "../models/subsidy.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestRampState } from "../test-utils/factories"; + +/** + * Regression for the subsidy-recording gap fixed by migration 037 + * (docs/security-spec/06-cross-chain/fund-routing.md): finalSettlementSubsidy + * records the assetSymbol from the dynamic SquidRouter token registry, whose + * value set is open-ended (WETH, USDC.e, BNB, ...). While subsidies.token was + * a Postgres enum, any symbol outside it made Subsidy.create throw — and + * createSubsidy swallows insert errors, so the subsidy was paid on-chain but + * never recorded. The column is now a VARCHAR; these symbols must round-trip. + */ + +// Symbols reachable in production that the pre-037 enum rejected: USDC.e is in +// the static EVM config (Polygon/Arbitrum/Avalanche), WETH comes from the +// dynamic registry, BNB/AVAX are the handler's native symbols for BSC/Avalanche. +const NON_ENUM_SYMBOLS = ["USDC.e", "WETH", "BNB", "AVAX"]; + +class TestSubsidyHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "finalSettlementSubsidy"; + } + + protected async executePhase(state: RampState): Promise { + return state; + } + + public recordSubsidy(state: RampState, token: string): Promise { + return this.createSubsidy(state, 1.23, token, "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3", "0xdeadbeef"); + } +} + +describe("subsidy recording accepts dynamic-registry token symbols", () => { + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + it.each(NON_ENUM_SYMBOLS)("round-trips a '%s' subsidy row", async token => { + const state = await createTestRampState(); + + await Subsidy.create({ + amount: 1.23, + payerAccount: "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3", + paymentDate: new Date(), + phase: "finalSettlementSubsidy", + rampId: state.id, + token, + transactionHash: "0xdeadbeef" + }); + + const stored = await Subsidy.findOne({ where: { rampId: state.id } }); + expect(stored?.token).toBe(token); + }); + + // The full seam: createSubsidy swallows insert errors by design, so a + // rejected token surfaced as *no row* rather than a failed phase. Assert the + // row actually lands when the symbol comes through the handler path. + it("createSubsidy persists a row for a symbol outside the legacy enum", async () => { + const state = await createTestRampState(); + + await new TestSubsidyHandler().recordSubsidy(state, "USDC.e"); + + const stored = await Subsidy.findOne({ where: { rampId: state.id } }); + expect(stored).not.toBeNull(); + expect(stored?.token).toBe("USDC.e"); + expect(stored?.phase).toBe("finalSettlementSubsidy"); + }); +}); diff --git a/apps/frontend/e2e/offramp-alfredpay-journeys.spec.ts b/apps/frontend/e2e/offramp-alfredpay-journeys.spec.ts new file mode 100644 index 000000000..5c35024c5 --- /dev/null +++ b/apps/frontend/e2e/offramp-alfredpay-journeys.spec.ts @@ -0,0 +1,285 @@ +import { expect, test } from "@playwright/test"; +import { buildQuoteResponse, buildRampProcess, E2E_RAMP_ID, mockBackend } from "./support/mockBackend"; +import { injectMockWallet, MOCK_WALLET_ADDRESS } from "./support/mockWallet"; + +const POLYGON_USDT = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"; +const MOCK_WALLET_TX_HASH = `0x${"cd".repeat(32)}`; + +// Critical journey 7: full SELL (offramp) journeys over the Alfredpay rail — the +// money-OUT counterpart to the BRL offramp. Unlike the Avenia path there is no CPF/Pix +// eligibility form: the payout destination is a fiat account registered with Alfredpay, +// selected on the payment summary, and its fiatAccountId travels in the registration's +// additionalData. The UI flow is identical for all four currencies (quote with USDT on +// Polygon -> email/OTP auth -> wallet-ownership details step -> Alfredpay KYC gate with +// an existing verified customer -> payment summary with the registered payout account -> +// ramp registration -> ephemeral presigning posted to /ramp/update -> USER WALLET +// broadcast of the squidRouterNoPermitTransfer with its hash reported in a second +// /ramp/update -> automatic /ramp/start -> progress -> success once polling reports +// COMPLETE), so the journey is parameterized per currency. What differs — and what each +// case pins down — is the KYC-gate country, the corridor's payout destination, and the +// registered fiat-account type (constants/fiatAccountMethods.ts): +// USD -> BANK_USA bank account (displayed as "WIRE") +// MXN -> SPEI account holding an 18-digit CLABE +// COP -> ACH account (Colombian accounts display as "ACH_COL", carry document metadata) +// ARS -> COELSA account holding a 22-digit CBU + +interface SellJourneyCase { + fiat: "USD" | "MXN" | "COP" | "ARS"; + /** Country the Alfredpay KYC gate and fiat-account listing must be queried for. */ + country: string; + /** The corridor's payout destination (mapFiatToDestination in shared). */ + to: string; + outputAmount: string; + /** The registered payout account served by GET /v1/alfredpay/fiatAccounts (AlfredpayFiatAccount). */ + fiatAccount: Record & { accountName: string; fiatAccountId: string }; + /** The success page's per-token arrival text (pages.success.arrivalText.sell in en.json). */ + arrivalText: string; +} + +function buildFiatAccount>(fields: T) { + return { + createdAt: new Date().toISOString(), + customerId: "alfred-customer-e2e-1", + ...fields + }; +} + +const CASES: SellJourneyCase[] = [ + { + arrivalText: "Your funds will arrive in your bank account in a few minutes.", + country: "US", + fiat: "USD", + // US accounts are stored as BANK_USA and displayed as "WIRE". + fiatAccount: buildFiatAccount({ + accountName: "Vortex E2E Checking", + accountNumber: "000123456789", + accountType: "CHECKING", + fiatAccountId: "fiat-account-e2e-us", + metadata: { accountHolderName: "Vortex E2E" }, + routingNumber: "026009593", + type: "BANK_USA" + }) as SellJourneyCase["fiatAccount"], + outputAmount: "99", + to: "ach" + }, + { + arrivalText: "Your funds will arrive in your bank account via SPEI in a few minutes.", + country: "MX", + fiat: "MXN", + // Mexican accounts are SPEI accounts holding an 18-digit CLABE. + fiatAccount: buildFiatAccount({ + accountName: "Vortex E2E CLABE", + accountNumber: "646180157000000004", + accountType: "CLABE", + fiatAccountId: "fiat-account-e2e-mx", + metadata: { accountHolderName: "Vortex E2E" }, + type: "SPEI" + }) as SellJourneyCase["fiatAccount"], + outputAmount: "1900", + to: "spei" + }, + { + arrivalText: "Your funds will arrive in your bank account in a few minutes.", + country: "CO", + fiat: "COP", + // Colombian accounts are stored as ACH (displayed as "ACH_COL") and carry the + // holder's document metadata from the ACH_COL registration form. + fiatAccount: buildFiatAccount({ + accountName: "Vortex E2E Ahorros", + accountNumber: "123456789012", + accountType: "AHORRO", + fiatAccountId: "fiat-account-e2e-co", + metadata: { accountHolderName: "Vortex E2E", documentNumber: "1234567890", documentType: "CC" }, + type: "ACH" + }) as SellJourneyCase["fiatAccount"], + outputAmount: "400000", + to: "ach" + }, + { + arrivalText: "Your funds will arrive in your bank account in a few minutes.", + country: "AR", + fiat: "ARS", + // Argentine accounts are COELSA accounts holding a 22-digit CBU/CVU. + fiatAccount: buildFiatAccount({ + accountName: "Vortex E2E CBU", + accountNumber: "2850590940090418135201", + accountType: "CBU", + fiatAccountId: "fiat-account-e2e-ar", + metadata: { accountHolderName: "Vortex E2E" }, + type: "COELSA" + }) as SellJourneyCase["fiatAccount"], + outputAmount: "130000", + to: "cbu" + } +]; + +// The quote form displays amounts with locale grouping ("1900" renders as "1,900.00"). +const displayedAmount = (amount: string) => new RegExp(Number(amount).toLocaleString("en-US")); + +// Mirrors the API's evm-to-alfredpay offramp preparation for the direct +// Polygon-USDT no-permit path: the USER wallet signs a single +// squidRouterNoPermitTransfer to the EVM ephemeral, and the ephemeral signs the +// Alfredpay deposit transfer, its fallback (both nonce 0 — only one executes), and +// the axlUSDC cleanup approval. +function buildSellUnsignedTxs(evmEphemeral: string) { + const evmTx = (signer: string, nonce: number, phase: string) => ({ + meta: {}, + network: "polygon", + nonce, + phase, + signer, + txData: { + data: `0xa9059cbb${"00".repeat(12)}${evmEphemeral.slice(2).toLowerCase()}${"00".repeat(30)}04c4`, + gas: "150000", + maxFeePerGas: "5000000000", + maxPriorityFeePerGas: "5000000000", + nonce, + to: POLYGON_USDT, + value: "0" + } + }); + return [ + evmTx(MOCK_WALLET_ADDRESS, 0, "squidRouterNoPermitTransfer"), + evmTx(evmEphemeral, 0, "alfredpayOfframpTransfer"), + evmTx(evmEphemeral, 0, "alfredpayOfframpTransferFallback"), + evmTx(evmEphemeral, 1, "polygonCleanupAxlUsdc") + ]; +} + +for (const journey of CASES) { + test(`SELL ${journey.fiat} journey: quote, auth, Alfredpay KYC gate, fiat account, registration, wallet signing, progress, success`, async ({ + page + }) => { + const rampFields = { + depositQrCode: undefined, + from: "polygon", + inputAmount: "100", + inputCurrency: "USDT", + outputAmount: journey.outputAmount, + outputCurrency: journey.fiat, + paymentMethod: journey.to, + to: journey.to, + type: "SELL" + }; + + // The real API keeps returning the ramp's unsignedTxs on /ramp/update; the signing + // step reads the user-wallet transaction from that response. + let unsignedTxs: unknown[] = []; + const backend = await mockBackend(page, { + fiatAccounts: () => [journey.fiatAccount], + quotes: body => + ({ + body: buildQuoteResponse({ + ...rampFields, + // Alfredpay quotes carry the resolved stablecoin input limits; the quote form + // validates the USDT inputAmount against them (not the legacy fiat sell limits). + alfredpayInputLimits: { max: "10000", min: "10" }, + feeCurrency: journey.fiat, + inputAmount: body.inputAmount, + rampType: "SELL" + }), + status: 200 + }) as { status: number; body: unknown }, + rampStatusOverrides: () => rampFields, + register: body => { + const signingAccounts = (body.signingAccounts ?? []) as Array<{ address: string; type: string }>; + const evmEphemeral = signingAccounts.find(account => account.type === "EVM")?.address ?? POLYGON_USDT; + unsignedTxs = buildSellUnsignedTxs(evmEphemeral); + return buildRampProcess({ ...rampFields, unsignedTxs }); + }, + update: () => buildRampProcess({ ...rampFields, unsignedTxs }) + }); + // The whole journey lives on Polygon, so the mock wallet connects on chain 137. + await injectMockWallet(page, { chainIdHex: "0x89" }); + // Preselect Polygon via the persisted network choice instead of the `network` URL + // param: passing network+cryptoLocked in the URL makes the widget create the quote + // itself and skip the quote form, and stage 1 (form + wallet gate + balance check) + // is part of this journey. + await page.addInitScript(() => localStorage.setItem("SELECTED_NETWORK", "polygon")); + + await page.goto(`/widget?rampType=SELL&fiat=${journey.fiat}&cryptoLocked=USDT&inputAmount=100`); + + // Stage 1: the quote form fetched a SELL USDT->fiat quote; the wallet gate is already + // passed by the injected mock wallet, and the balance check (mocked Alchemy data API, + // which holds USDT on Polygon) enables Sell. + await expect(page.locator('input[name="outputAmount"]')).toHaveValue(displayedAmount(journey.outputAmount), { + timeout: 20_000 + }); + const sellButton = page.locator("form").getByRole("button", { name: "Sell" }); + await expect(sellButton).toBeEnabled({ timeout: 20_000 }); + await sellButton.click(); + + // Stage 2 + 3: email/OTP auth gate. + await expect(page.getByRole("heading", { name: "Verify Your Email" })).toBeVisible({ timeout: 20_000 }); + await page.locator("#email").fill("e2e@vortexfinance.co"); + await page.locator("#terms").check(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("heading", { name: "Enter Verification Code" })).toBeVisible({ timeout: 20_000 }); + await page.locator('input[autocomplete="one-time-code"]').pressSequentially("123456"); + + // Stage 4: Alfredpay offramp details — only wallet ownership, no CPF/Pix fields (the + // payout destination is the Alfredpay fiat account picked later on the summary). + await expect(page.getByText("Verify you are the owner of the wallet")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator("#taxId")).toHaveCount(0); + await expect(page.locator("#pixId")).toHaveCount(0); + await expect(page.getByRole("button", { name: /0xf39F/ })).toBeVisible(); + await page.getByRole("button", { name: "Verify Wallet" }).click(); + + // Stage 5: the Alfredpay KYC gate queried the customer status for the case's country + // and, since the customer is verified (SUCCESS), the flow lands on the payment + // summary, where the registered payout account is listed and preselected. + await expect(page.getByRole("heading", { name: "Payment Summary" })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(journey.fiatAccount.accountName)).toBeVisible({ timeout: 20_000 }); + expect(backend.alfredpayStatusRequests).toContain(journey.country); + expect(backend.fiatAccountsRequests).toContain(journey.country); + + // Stage 6: confirming registers the ramp; the fiat account travels as additionalData. + await page.getByRole("button", { name: "Confirm" }).click(); + + // Stage 7: the ephemeral transactions are signed in-page and posted to /ramp/update, + // then the USER WALLET broadcasts the source-of-funds transfer and its hash is + // reported in a second update; the offramp starts automatically and (once polling + // reports COMPLETE) lands on the SELL success screen. + await expect(page.getByRole("heading", { name: "All set! The withdrawal has been sent to your bank." })).toBeVisible({ + timeout: 45_000 + }); + await expect(page.getByText(journey.arrivalText)).toBeVisible(); + + expect(backend.registerRequests).toHaveLength(1); + const registerBody = backend.registerRequests[0] as { + quoteId: string; + signingAccounts: Array<{ type: string }>; + additionalData?: { fiatAccountId?: string; pixDestination?: string; taxId?: string; walletAddress?: string }; + }; + expect(registerBody.quoteId).toBe("quote-e2e-1"); + expect(registerBody.signingAccounts.map(account => account.type).sort()).toEqual(["EVM", "Substrate"]); + expect(registerBody.additionalData?.fiatAccountId).toBe(journey.fiatAccount.fiatAccountId); + expect(registerBody.additionalData?.walletAddress?.toLowerCase()).toBe(MOCK_WALLET_ADDRESS.toLowerCase()); + // The Avenia-only fields never travel on the Alfredpay rail. + expect(registerBody.additionalData?.pixDestination).toBeUndefined(); + expect(registerBody.additionalData?.taxId).toBeUndefined(); + + // Update #1: the three ephemeral transactions, locally signed (raw EIP-1559 txs). The + // user-wallet transfer must NOT be among them. + expect(backend.updateRequests.length).toBeGreaterThanOrEqual(2); + const ephemeralUpdate = backend.updateRequests[0] as { presignedTxs: Array<{ txData: unknown; phase: string }> }; + expect(ephemeralUpdate.presignedTxs.map(tx => tx.phase)).not.toContain("squidRouterNoPermitTransfer"); + expect(ephemeralUpdate.presignedTxs.map(tx => tx.phase).sort()).toEqual([ + "alfredpayOfframpTransfer", + "alfredpayOfframpTransferFallback", + "polygonCleanupAxlUsdc" + ]); + for (const tx of ephemeralUpdate.presignedTxs) { + expect(typeof tx.txData).toBe("string"); + expect(tx.txData as string).toMatch(/^0x02/); + } + + // Update #2: the hash of the wallet-broadcast transfer, reported as additionalData. + const signingUpdate = backend.updateRequests[1] as { additionalData?: Record }; + expect(signingUpdate.additionalData?.squidRouterNoPermitTransferHash).toBe(MOCK_WALLET_TX_HASH); + + // The offramp started without a manual payment-confirmation step. + expect(backend.startRequests).toHaveLength(1); + expect(backend.startRequests[0]).toMatchObject({ rampId: E2E_RAMP_ID }); + }); +} diff --git a/apps/frontend/e2e/offramp-brl-journey.spec.ts b/apps/frontend/e2e/offramp-brl-journey.spec.ts new file mode 100644 index 000000000..72ac39d9d --- /dev/null +++ b/apps/frontend/e2e/offramp-brl-journey.spec.ts @@ -0,0 +1,163 @@ +import { expect, test } from "@playwright/test"; +import { buildQuoteResponse, buildRampProcess, E2E_RAMP_ID, mockBackend } from "./support/mockBackend"; +import { injectMockWallet, MOCK_WALLET_ADDRESS } from "./support/mockWallet"; + +// Structurally valid CPF (passes the checksum in isValidCpf). +const VALID_CPF = "529.982.247-25"; +const PIX_KEY = "e2e-pix-key@vortexfinance.co"; +const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const MOCK_WALLET_TX_HASH = `0x${"cd".repeat(32)}`; + +// Critical journey 5: a full SELL (offramp) BRL ramp against the mocked backend — the +// money-OUT path, where the user's connected wallet (not an ephemeral) signs and +// broadcasts the source-of-funds transaction. +// +// quote -> Sell (wallet gate passed by the injected mock wallet; the Sell balance check +// reads the mocked Base RPC) -> email/OTP auth -> Avenia offramp eligibility (CPF + Pix +// key) -> KYC gate (existing CONFIRMED user) -> payment summary -> ramp registration -> +// ephemeral presigning posted to /ramp/update -> USER WALLET broadcast of the +// squidRouterNoPermitTransfer (eth_sendTransaction on the mock wallet) with its hash +// reported in a second /ramp/update -> automatic /ramp/start -> progress -> success. + +const SELL_RAMP_FIELDS = { + depositQrCode: undefined, + from: "base", + inputAmount: "100", + inputCurrency: "USDC", + outputAmount: "500", + outputCurrency: "BRL", + to: "pix", + type: "SELL" +}; + +// The ephemeral's Base-side transactions plus the user-wallet source-of-funds transfer, +// mirroring the API's evm-to-brl-base offramp preparation for the Base+USDC direct path. +function buildSellUnsignedTxs(evmEphemeral: string) { + const evmTx = (signer: string, nonce: number, phase: string) => ({ + meta: {}, + network: "base", + nonce, + phase, + signer, + txData: { + data: `0xa9059cbb${"00".repeat(12)}${evmEphemeral.slice(2).toLowerCase()}${"00".repeat(30)}04c4`, + gas: "150000", + maxFeePerGas: "2000000000", + maxPriorityFeePerGas: "1000000000", + nonce, + to: BASE_USDC, + value: "0" + } + }); + return [ + evmTx(MOCK_WALLET_ADDRESS, 0, "squidRouterNoPermitTransfer"), + evmTx(evmEphemeral, 0, "distributeFees"), + evmTx(evmEphemeral, 1, "nablaApprove"), + evmTx(evmEphemeral, 2, "nablaSwap"), + evmTx(evmEphemeral, 3, "brlaPayoutOnBase"), + evmTx(evmEphemeral, 4, "baseCleanupUsdc"), + evmTx(evmEphemeral, 5, "baseCleanupBrla") + ]; +} + +test("SELL BRL journey: quote, auth, CPF+Pix eligibility, registration, wallet signing, progress, success", async ({ + page +}) => { + // The real API keeps returning the ramp's unsignedTxs on /ramp/update; the signing + // step reads the user-wallet transactions from that response. + let unsignedTxs: unknown[] = []; + const backend = await mockBackend(page, { + quotes: body => + ({ + body: buildQuoteResponse({ + ...SELL_RAMP_FIELDS, + feeCurrency: "BRL", + inputAmount: body.inputAmount, + rampType: "SELL" + }), + status: 200 + }) as { status: number; body: unknown }, + rampStatusOverrides: () => SELL_RAMP_FIELDS, + register: body => { + const signingAccounts = (body.signingAccounts ?? []) as Array<{ address: string; type: string }>; + const evmEphemeral = signingAccounts.find(account => account.type === "EVM")?.address ?? BASE_USDC; + unsignedTxs = buildSellUnsignedTxs(evmEphemeral); + return buildRampProcess({ ...SELL_RAMP_FIELDS, unsignedTxs }); + }, + update: () => buildRampProcess({ ...SELL_RAMP_FIELDS, unsignedTxs }) + }); + await injectMockWallet(page); + + await page.goto("/widget?rampType=SELL&fiat=BRL&inputAmount=100"); + + // Stage 1: the quote form fetched a SELL quote; the wallet gate is already passed by + // the injected mock wallet, and the balance check (mocked Base RPC) enables Sell. + await expect(page.locator('input[name="outputAmount"]')).toHaveValue(/500/, { timeout: 20_000 }); + const sellButton = page.locator("form").getByRole("button", { name: "Sell" }); + await expect(sellButton).toBeEnabled({ timeout: 20_000 }); + await sellButton.click(); + + // Stage 2 + 3: email/OTP auth gate. + await expect(page.getByRole("heading", { name: "Verify Your Email" })).toBeVisible({ timeout: 20_000 }); + await page.locator("#email").fill("e2e@vortexfinance.co"); + await page.locator("#terms").check(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("heading", { name: "Enter Verification Code" })).toBeVisible({ timeout: 20_000 }); + await page.locator('input[autocomplete="one-time-code"]').pressSequentially("123456"); + + // Stage 4: Avenia OFFRAMP eligibility details — CPF and Pix key (no wallet field: + // the destination is a Brazilian bank account). The offramp confirm button is + // labelled "Verify Wallet" in this state. + await expect(page.locator("#taxId")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator("#pixId")).toBeVisible(); + await page.locator("#taxId").fill(VALID_CPF); + await page.locator("#pixId").fill(PIX_KEY); + await page.getByRole("button", { name: "Verify Wallet" }).click(); + + // Stage 5: KYC gate happy path (existing CONFIRMED user) lands on the payment summary. + await expect(page.getByRole("heading", { name: "Payment Summary" })).toBeVisible({ timeout: 20_000 }); + expect(backend.brlaGetUserRequests).toContain(VALID_CPF); + + // Stage 6: confirming registers the ramp; the CPF and Pix key travel as additionalData. + await page.getByRole("button", { name: "Confirm" }).click(); + + // Stage 7: the ephemeral transactions are signed in-page and posted to /ramp/update, + // then the USER WALLET broadcasts the source-of-funds transfer and its hash is + // reported in a second update; the offramp starts automatically and (once polling + // reports COMPLETE) lands on the SELL success screen. + await expect(page.getByRole("heading", { name: "All set! The withdrawal has been sent to your bank." })).toBeVisible({ + timeout: 45_000 + }); + await expect(page.getByText("Your funds were sent via PIX and are now in your bank account.")).toBeVisible(); + + expect(backend.registerRequests).toHaveLength(1); + const registerBody = backend.registerRequests[0] as { + quoteId: string; + signingAccounts: Array<{ type: string }>; + additionalData?: { pixDestination?: string; taxId?: string; walletAddress?: string }; + }; + expect(registerBody.quoteId).toBe("quote-e2e-1"); + expect(registerBody.signingAccounts.map(account => account.type).sort()).toEqual(["EVM", "Substrate"]); + expect(registerBody.additionalData?.pixDestination).toBe(PIX_KEY); + expect(registerBody.additionalData?.taxId).toBe(VALID_CPF); + expect(registerBody.additionalData?.walletAddress?.toLowerCase()).toBe(MOCK_WALLET_ADDRESS.toLowerCase()); + + // Update #1: the six ephemeral transactions, locally signed (raw EIP-1559 txs). The + // user-wallet transfer must NOT be among them. + expect(backend.updateRequests.length).toBeGreaterThanOrEqual(2); + const ephemeralUpdate = backend.updateRequests[0] as { presignedTxs: Array<{ txData: unknown; phase: string }> }; + expect(ephemeralUpdate.presignedTxs.map(tx => tx.phase)).not.toContain("squidRouterNoPermitTransfer"); + expect(ephemeralUpdate.presignedTxs).toHaveLength(6); + for (const tx of ephemeralUpdate.presignedTxs) { + expect(typeof tx.txData).toBe("string"); + expect(tx.txData as string).toMatch(/^0x02/); + } + + // Update #2: the hash of the wallet-broadcast transfer, reported as additionalData. + const signingUpdate = backend.updateRequests[1] as { additionalData?: Record }; + expect(signingUpdate.additionalData?.squidRouterNoPermitTransferHash).toBe(MOCK_WALLET_TX_HASH); + + // The offramp started without a manual payment-confirmation step. + expect(backend.startRequests).toHaveLength(1); + expect(backend.startRequests[0]).toMatchObject({ rampId: E2E_RAMP_ID }); +}); diff --git a/apps/frontend/e2e/onramp-alfredpay-journeys.spec.ts b/apps/frontend/e2e/onramp-alfredpay-journeys.spec.ts new file mode 100644 index 000000000..dc11c299a --- /dev/null +++ b/apps/frontend/e2e/onramp-alfredpay-journeys.spec.ts @@ -0,0 +1,243 @@ +import { expect, Page, test } from "@playwright/test"; +import { buildQuoteResponse, buildRampProcess, E2E_RAMP_ID, mockBackend } from "./support/mockBackend"; +import { injectMockWallet, MOCK_WALLET_ADDRESS } from "./support/mockWallet"; + +const POLYGON_USDT = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"; + +// Critical journey 6: full BUY (onramp) journeys over the Alfredpay rail — the corridor +// family (MXN/USD/COP/ARS) the BRL journey never touches. The UI flow is identical for +// all four currencies (quote -> Buy -> email/OTP auth -> wallet ownership -> Alfredpay +// KYC gate on its happy path, an existing verified customer with alfredpayStatus=SUCCESS +// -> summary -> registration -> in-page ephemeral signing on POLYGON posted to +// /ramp/update -> "I have made the payment" -> /ramp/start -> progress -> success), so +// the journey is parameterized per currency. What differs — and what each case pins down +// — is the corridor's payment-method destination, the KYC-gate country, and the +// payment-instruction rendering (ONRAMP_DETAILS_BY_FIAT in +// src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx): +// MXN -> SPEI CLABE (MXNOnrampDetails) +// USD -> ACH bank-detail rows (USOnrampDetails) +// COP -> bank-transfer account details (COPOnrampDetails) +// ARS -> COELSA CVU + alias (ARSOnrampDetails) + +interface BuyJourneyCase { + fiat: "MXN" | "USD" | "COP" | "ARS"; + /** Country the Alfredpay KYC gate must be queried for (ALFREDPAY_FIAT_TOKEN_TO_COUNTRY). */ + country: string; + /** The corridor's payment-method destination (mapFiatToDestination in shared). */ + from: string; + inputAmount: string; + outputAmount: string; + /** The anchor's fiat payment instructions returned on /ramp/update (AlfredpayFiatPaymentInstructions). */ + achPaymentData: Record; + /** Asserts the corridor's payment-instruction rendering after registration + signing. */ + assertPaymentInstructions: (page: Page) => Promise; +} + +const CASES: BuyJourneyCase[] = [ + { + achPaymentData: { + accountHolderName: "Vortex E2E", + bankName: "STP", + clabe: "646180157000000004", + paymentType: "SPEI", + reference: "VORTEX-E2E-REF-MX" + }, + assertPaymentInstructions: async page => { + // MXNOnrampDetails: the SPEI CLABE plus the payment reference. + await expect(page.getByText("646180157000000004").first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("VORTEX-E2E-REF-MX")).toBeVisible(); + }, + country: "MX", + fiat: "MXN", + from: "spei", + inputAmount: "2000", + outputAmount: "100" + }, + { + achPaymentData: { + bankAccountNumber: "000123456789", + bankBeneficiaryName: "Alfred Securities LLC", + bankRoutingNumber: "026009593", + paymentDescription: "Deposit the payment with the following reference number: VORTEXE2EREFUS01", + paymentType: "ACH" + }, + assertPaymentInstructions: async page => { + // USOnrampDetails: ACH bank-detail rows, with the reference number extracted from + // the anchor's paymentDescription sentence. + await expect(page.getByText("000123456789").first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("026009593")).toBeVisible(); + await expect(page.getByText("Alfred Securities LLC")).toBeVisible(); + await expect(page.getByText("VORTEXE2EREFUS01")).toBeVisible(); + }, + country: "US", + fiat: "USD", + from: "ach", + inputAmount: "2000", + outputAmount: "1990" + }, + { + achPaymentData: { + accountHolderName: "Alfred Colombia SAS", + bankAccountNumber: "123456789012", + bankName: "Bancolombia", + paymentType: "ACH", + reference: "VORTEX-E2E-REF-CO" + }, + assertPaymentInstructions: async page => { + // COPOnrampDetails: destination bank account, bank name, and reference. + await expect(page.getByText("123456789012").first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Bancolombia")).toBeVisible(); + await expect(page.getByText("VORTEX-E2E-REF-CO")).toBeVisible(); + }, + country: "CO", + fiat: "COP", + from: "ach", + inputAmount: "500000", + outputAmount: "100" + }, + { + achPaymentData: { + alias: "vortex.e2e.alias", + cvu: "0000003100064567890123", + paymentType: "COELSA", + reference: "VORTEX-E2E-REF-AR" + }, + assertPaymentInstructions: async page => { + // ARSOnrampDetails: the COELSA CVU, alias, and reference. + await expect(page.getByText("0000003100064567890123").first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("vortex.e2e.alias").first()).toBeVisible(); + await expect(page.getByText("VORTEX-E2E-REF-AR")).toBeVisible(); + }, + country: "AR", + fiat: "ARS", + from: "cbu", + inputAmount: "150000", + outputAmount: "100" + } +]; + +// The quote form displays amounts with locale grouping ("1990" renders as "1,990.00"). +const displayedAmount = (amount: string) => new RegExp(Number(amount).toLocaleString("en-US")); + +// The Alfredpay onramp's Polygon-side transactions signed by the EVM ephemeral +// (destinationTransfer + cleanup), mirroring alfredpay-to-evm.ts' direct-token path. +function buildBuyUnsignedTxs(evmEphemeral: string) { + const evmTx = (nonce: number, phase: string) => ({ + meta: {}, + network: "polygon", + nonce, + phase, + signer: evmEphemeral, + txData: { + data: `0xa9059cbb${"00".repeat(12)}${evmEphemeral.slice(2).toLowerCase()}${"00".repeat(30)}04c4`, + gas: "150000", + maxFeePerGas: "5000000000", + maxPriorityFeePerGas: "5000000000", + nonce, + to: POLYGON_USDT, + value: "0" + } + }); + return [evmTx(0, "destinationTransfer"), evmTx(1, "polygonCleanup")]; +} + +for (const journey of CASES) { + test(`BUY ${journey.fiat} journey: quote, auth, Alfredpay KYC gate, registration, signing, payment details, progress, success`, async ({ + page + }) => { + const rampFields = { + depositQrCode: undefined, + from: journey.from, + inputAmount: journey.inputAmount, + inputCurrency: journey.fiat, + outputAmount: journey.outputAmount, + outputCurrency: "USDT", + to: "polygon", + type: "BUY" + }; + + let unsignedTxs: unknown[] = []; + const backend = await mockBackend(page, { + quotes: body => + ({ + body: buildQuoteResponse({ + ...rampFields, + feeCurrency: journey.fiat, + inputAmount: body.inputAmount, + rampType: "BUY" + }), + status: 200 + }) as { status: number; body: unknown }, + rampStatusOverrides: () => rampFields, + register: body => { + const signingAccounts = (body.signingAccounts ?? []) as Array<{ address: string; type: string }>; + const evmEphemeral = signingAccounts.find(account => account.type === "EVM")?.address ?? POLYGON_USDT; + unsignedTxs = buildBuyUnsignedTxs(evmEphemeral); + return buildRampProcess({ ...rampFields, unsignedTxs }); + }, + update: () => buildRampProcess({ ...rampFields, achPaymentData: journey.achPaymentData, unsignedTxs }) + }); + await injectMockWallet(page); + + await page.goto(`/widget?rampType=BUY&fiat=${journey.fiat}&inputAmount=${journey.inputAmount}`); + + // Stage 1: the quote form fetched a BUY quote for the case's fiat currency. + await expect(page.locator('input[name="outputAmount"]')).toHaveValue(displayedAmount(journey.outputAmount), { + timeout: 20_000 + }); + await page.locator("form").getByRole("button", { name: "Buy" }).click(); + + // Stage 2 + 3: email/OTP auth gate. + await expect(page.getByRole("heading", { name: "Verify Your Email" })).toBeVisible({ timeout: 20_000 }); + await page.locator("#email").fill("e2e@vortexfinance.co"); + await page.locator("#terms").check(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("heading", { name: "Enter Verification Code" })).toBeVisible({ timeout: 20_000 }); + await page.locator('input[autocomplete="one-time-code"]').pressSequentially("123456"); + + // Stage 4: wallet ownership step — the connected mock wallet is shown; confirming + // sends CONFIRM with the wallet as the destination. + await expect(page.getByText("Verify you are the owner of the wallet")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("button", { name: /0xf39F/ })).toBeVisible(); + await page.getByRole("button", { name: "Verify Wallet" }).click(); + + // Stage 5: the Alfredpay KYC gate queried the customer status for the case's country + // and, since the customer is verified (SUCCESS), the flow lands on the payment summary. + await expect(page.getByRole("heading", { name: "Payment Summary" })).toBeVisible({ timeout: 20_000 }); + expect(backend.alfredpayStatusRequests).toContain(journey.country); + + // Stage 6: confirming registers the ramp and signs the Polygon transactions in-page; + // the corridor's payment instructions from the update response are then displayed. + await page.getByRole("button", { name: "Confirm" }).click(); + await journey.assertPaymentInstructions(page); + + expect(backend.registerRequests).toHaveLength(1); + const registerBody = backend.registerRequests[0] as { + quoteId: string; + signingAccounts: Array<{ type: string }>; + additionalData?: { destinationAddress?: string; walletAddress?: string }; + }; + expect(registerBody.quoteId).toBe("quote-e2e-1"); + expect(registerBody.additionalData?.destinationAddress?.toLowerCase()).toBe(MOCK_WALLET_ADDRESS.toLowerCase()); + expect(registerBody.additionalData?.walletAddress?.toLowerCase()).toBe(MOCK_WALLET_ADDRESS.toLowerCase()); + + // Every unsigned Polygon transaction came back locally signed (raw EIP-1559 txs). + expect(backend.updateRequests.length).toBeGreaterThanOrEqual(1); + const presignedTxs = (backend.updateRequests[0] as { presignedTxs: Array<{ txData: unknown; phase: string }> }) + .presignedTxs; + expect(presignedTxs.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "polygonCleanup"]); + for (const tx of presignedTxs) { + expect(typeof tx.txData).toBe("string"); + expect(tx.txData as string).toMatch(/^0x02/); + } + + // Stage 7: confirming the payment starts the ramp; once polling reports COMPLETE the + // BUY success screen appears. + await page.getByRole("button", { name: "I have made the payment" }).click(); + await expect(page.getByRole("heading", { name: "All set! Your tokens are on their way." })).toBeVisible({ + timeout: 45_000 + }); + expect(backend.startRequests).toHaveLength(1); + expect(backend.startRequests[0]).toMatchObject({ rampId: E2E_RAMP_ID }); + }); +} diff --git a/apps/frontend/e2e/onramp-brl-journey.spec.ts b/apps/frontend/e2e/onramp-brl-journey.spec.ts new file mode 100644 index 000000000..ba2553108 --- /dev/null +++ b/apps/frontend/e2e/onramp-brl-journey.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; +import { E2E_DEPOSIT_QR_CODE, E2E_RAMP_ID, mockBackend } from "./support/mockBackend"; +import { injectMockWallet, MOCK_WALLET_ADDRESS } from "./support/mockWallet"; + +// Structurally valid CPF (passes the checksum in isValidCpf). +const VALID_CPF = "529.982.247-25"; + +// Critical journey 4: a full BUY (onramp) BRL ramp against the mocked backend. +// +// quote -> Buy -> email/OTP auth -> Avenia eligibility details (CPF + wallet) -> KYC gate +// (existing CONFIRMED user, so no full Avenia KYC flow) -> payment summary -> ramp +// registration -> local ephemeral-key signing + /ramp/update -> Pix deposit QR -> "I have +// made the payment" -> /ramp/start -> progress page -> success page once polling reports +// COMPLETE. +// +// Notes on coverage limits: +// - A BRL onramp has no user wallet signature step: every unsigned transaction returned by +// /ramp/register is signed in-page with the throwaway ephemeral keys (viem/polkadot, fully +// local; only eth_chainId RPCs leave the page and are answered by mockBackend). We assert +// that signing happened via the presignedTxs posted to /ramp/update. The injected mock +// wallet still drives the connected-account state and would answer eth_sendTransaction / +// eth_signTypedData_v4 if a journey required it (offramps do). +// - The full Avenia KYC flow (document upload + selfie liveness) is not exercised: the +// liveness check opens an external Avenia-hosted URL in a separate window, which cannot be +// completed hermetically. The mocked /brla/getUser instead reports an existing +// KYC-confirmed user, which is the KYC gate's happy path. +test("BUY BRL journey: quote, auth, KYC gate, registration, signing, Pix QR, progress, success", async ({ page }) => { + const backend = await mockBackend(page); + await injectMockWallet(page); + + await page.goto("/widget?rampType=BUY&fiat=BRL&inputAmount=100"); + + // Stage 1: the quote form fetched a quote; Buy becomes actionable. + await expect(page.locator('input[name="outputAmount"]')).toHaveValue(/25\.5/, { timeout: 20_000 }); + await page.locator("form").getByRole("button", { name: "Buy" }).click(); + + // Stage 2: auth gate - email step. + await expect(page.getByRole("heading", { name: "Verify Your Email" })).toBeVisible({ timeout: 20_000 }); + await page.locator("#email").fill("e2e@vortexfinance.co"); + await page.locator("#terms").check(); + await page.getByRole("button", { name: "Continue" }).click(); + + // Stage 3: auth gate - OTP step; entering the 6th digit submits automatically. + await expect(page.getByRole("heading", { name: "Enter Verification Code" })).toBeVisible({ timeout: 20_000 }); + await page.locator('input[autocomplete="one-time-code"]').pressSequentially("123456"); + + // Stage 4: Avenia eligibility details (CPF + wallet address). The wallet field is + // auto-filled from the injected mock wallet once wagmi reconnects to it. + await expect(page.locator("#taxId")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator("#walletAddress")).toHaveValue(MOCK_WALLET_ADDRESS, { timeout: 20_000 }); + await page.locator("#taxId").fill(VALID_CPF); + await page.locator("form").getByRole("button", { name: "Continue" }).click(); + + // Stage 5: the KYC gate queried the backend for the CPF and, since the user is + // CONFIRMED, the flow lands on the payment summary. + await expect(page.getByRole("heading", { name: "Payment Summary" })).toBeVisible({ timeout: 20_000 }); + expect(backend.brlaGetUserRequests).toContain(VALID_CPF); + + // Stage 6: confirming registers the ramp, signs the returned transactions with the + // ephemeral keys, updates the ramp, and surfaces the Pix payment instructions. + await page.getByRole("button", { name: "Confirm" }).click(); + await expect(page.getByText("Pay with Pix")).toBeVisible({ timeout: 30_000 }); + + // The registration carried the quote and both ephemeral signing accounts... + expect(backend.registerRequests).toHaveLength(1); + const registerBody = backend.registerRequests[0] as { + quoteId: string; + signingAccounts: Array<{ type: string }>; + additionalData?: { destinationAddress?: string; taxId?: string }; + }; + expect(registerBody.quoteId).toBe("quote-e2e-1"); + expect(registerBody.signingAccounts.map(account => account.type).sort()).toEqual(["EVM", "Substrate"]); + + // ...and every unsigned transaction came back locally signed (serialized raw txs) in /ramp/update. + expect(backend.updateRequests).toHaveLength(1); + const presignedTxs = (backend.updateRequests[0] as { presignedTxs: Array<{ txData: unknown; phase: string }> }).presignedTxs; + expect(presignedTxs.length).toBe(6); + for (const tx of presignedTxs) { + expect(typeof tx.txData).toBe("string"); + expect(tx.txData as string).toMatch(/^0x02/); // EIP-1559 raw transaction + } + + // The Pix BR code is shown as copyable text alongside the QR, and the submit slot now + // asks for payment confirmation. + await expect(page.getByText(E2E_DEPOSIT_QR_CODE)).toBeVisible(); + const paymentButton = page.getByRole("button", { name: "I have made the payment" }); + await expect(paymentButton).toBeEnabled(); + + // Stage 7: confirming the payment starts the ramp and shows the progress screen. + await paymentButton.click(); + await expect(page.getByText("Your payment is being processed. This can take up to 5 minutes.")).toBeVisible({ + timeout: 20_000 + }); + expect(backend.startRequests).toHaveLength(1); + expect(backend.startRequests[0]).toMatchObject({ rampId: E2E_RAMP_ID }); + + // Stage 8: once status polling reports COMPLETE, the success screen appears. + await expect(page.getByRole("heading", { name: "All set! Your tokens are on their way." })).toBeVisible({ + timeout: 30_000 + }); +}); diff --git a/apps/frontend/e2e/quote-form.spec.ts b/apps/frontend/e2e/quote-form.spec.ts new file mode 100644 index 000000000..14593b0cf --- /dev/null +++ b/apps/frontend/e2e/quote-form.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; + +// Critical journey 1: the quote form requests a quote from the API and displays it. +test("onramp quote form fetches and displays a quote", async ({ page }) => { + const { quoteRequests } = await mockBackend(page); + + await page.goto("/widget?rampType=BUY&fiat=BRL&inputAmount=100"); + + await expect(page.getByText("You pay")).toBeVisible(); + await expect(page.getByText("You receive")).toBeVisible(); + + // The app requests a BUY quote for the amount from the URL... + await expect.poll(() => quoteRequests.length, { timeout: 20_000 }).toBeGreaterThan(0); + expect(quoteRequests[0]).toMatchObject({ + inputAmount: "100", + inputCurrency: "BRL", + rampType: "BUY" + }); + + // ...and displays the quoted output amount in the read-only receive field. + await expect(page.locator('input[name="outputAmount"]')).toHaveValue(/25\.5/, { timeout: 20_000 }); + + // A fresh quote enables the submit button (the toggle also says "Buy", so scope to the form). + await expect(page.locator("form").getByRole("button", { name: "Buy" })).toBeEnabled(); +}); + +// Critical journey 2: a quote rejection from the API surfaces as a readable error. +test("quote errors from the API are shown to the user", async ({ page }) => { + await mockBackend(page, { + quotes: () => ({ body: { error: "Input amount too low to cover fees" }, status: 400 }) + }); + + await page.goto("/widget?rampType=BUY&fiat=BRL&inputAmount=1"); + + await expect(page.getByText("Input amount too low. Please try a larger amount.")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator("form").getByRole("button", { name: "Buy" })).toBeDisabled(); +}); diff --git a/apps/frontend/e2e/support/mockBackend.ts b/apps/frontend/e2e/support/mockBackend.ts new file mode 100644 index 000000000..e241f442c --- /dev/null +++ b/apps/frontend/e2e/support/mockBackend.ts @@ -0,0 +1,400 @@ +import { Page } from "@playwright/test"; + +// Mirrors the QuoteResponse shape served by the API (see src/test/fixtures.ts). +export function buildQuoteResponse(overrides: Record = {}) { + return { + anchorFeeFiat: "0.5", + anchorFeeUsd: "0.1", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + feeCurrency: "BRL", + from: "pix", + id: "quote-e2e-1", + inputAmount: "100", + inputCurrency: "BRL", + network: "base", + networkFeeFiat: "0.2", + networkFeeUsd: "0.04", + outputAmount: "25.5", + outputCurrency: "USDC", + partnerFeeFiat: "0", + partnerFeeUsd: "0", + paymentMethod: "pix", + processingFeeFiat: "0.5", + processingFeeUsd: "0.1", + rampType: "BUY", + to: "base", + totalFeeFiat: "0.7", + totalFeeUsd: "0.14", + vortexFeeFiat: "0", + vortexFeeUsd: "0", + ...overrides + }; +} + +export const E2E_RAMP_ID = "ramp-e2e-1"; +export const E2E_USER_ID = "user-e2e-1"; +// Plausible static Pix "copia e cola" BR Code, as returned by the API in RampProcess.depositQrCode. +export const E2E_DEPOSIT_QR_CODE = + "00020126390014br.gov.bcb.pix0117vortex@example.com5204000053039865406100.005802BR5906VORTEX6009SAO PAULO62140510ramp2e2e0163049B2D"; + +const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + +// One unsigned EVM transaction as produced by the API's avenia-to-evm-base onramp route +// (see apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts): +// EvmTransactionData on Base, signed by the EVM ephemeral account. +function buildUnsignedEvmTx(signer: string, nonce: number, phase: string) { + return { + meta: {}, + network: "base", + nonce, + phase, + signer, + txData: { + data: `0xa9059cbb${"00".repeat(12)}${signer.slice(2).toLowerCase()}${"00".repeat(30)}04c4`, + gas: "150000", + maxFeePerGas: "2000000000", + maxPriorityFeePerGas: "1000000000", + nonce, + to: BASE_USDC, + value: "0" + } + }; +} + +// Mirrors RampProcess (packages/shared/src/endpoints/ramp.endpoints.ts) for a BUY BRL -> Base USDC ramp. +export function buildRampProcess(overrides: Record = {}) { + return { + createdAt: new Date().toISOString(), + currentPhase: "initial", + depositQrCode: E2E_DEPOSIT_QR_CODE, + from: "pix", + id: E2E_RAMP_ID, + inputAmount: "100", + inputCurrency: "BRL", + outputAmount: "25.5", + outputCurrency: "USDC", + paymentMethod: "pix", + quoteId: "quote-e2e-1", + to: "base", + type: "BUY", + unsignedTxs: [], + updatedAt: new Date().toISOString(), + ...overrides + }; +} + +// Phases mirror the real BRL -> USDC-on-Base onramp preparation (all Base txs signed by the EVM ephemeral). +const ONRAMP_TX_PHASES = [ + "nablaApprove", + "nablaSwap", + "distributeFees", + "destinationTransfer", + "baseCleanupBrla", + "baseCleanupUsdc" +]; + +interface MockBackendOptions { + // Called for POST /v1/quotes; return a JSON body + status. Defaults to echoing the + // request into a successful quote. + quotes?: (requestBody: Record) => { status: number; body: unknown }; + // How many GET /v1/ramp/:id polls report an in-progress ramp before flipping to COMPLETE. + // Default 1: the progress page's immediate poll sees PENDING, the next one (after ~5s) sees COMPLETE. + pendingStatusPolls?: number; + // Full response body for POST /v1/ramp/register. Default: the BUY BRL onramp response. + register?: (requestBody: Record) => unknown; + // Full response body for POST /v1/ramp/update. Default: a bare RampProcess. + update?: (requestBody: Record) => unknown; + // Extra fields merged into every GET /v1/ramp/:id status response (e.g. SELL currencies). + rampStatusOverrides?: (complete: boolean) => Record; + // Status served on GET /v1/alfredpay/alfredpayStatus (Alfredpay KYC gate). Default: SUCCESS. + alfredpayStatus?: string; + // Accounts served on GET /v1/alfredpay/fiatAccounts (AlfredpayListFiatAccountsResponse). + // Alfredpay offramps require one: the summary's Confirm button stays disabled without a + // selectable fiat account. Default: none configured (the route 404s like any unmatched path). + fiatAccounts?: (country: string) => unknown; +} + +// Intercepts all traffic to the API origin (http://localhost:3000) so journeys run +// without a backend, and blocks third-party endpoints that would make runs +// non-deterministic (token lists, walletconnect telemetry). The app has graceful +// fallbacks for all of them. +export async function mockBackend(page: Page, options: MockBackendOptions = {}) { + const quoteRequests: Array> = []; + const brlaGetUserRequests: string[] = []; + const alfredpayStatusRequests: string[] = []; + const fiatAccountsRequests: string[] = []; + const registerRequests: Array> = []; + const updateRequests: Array> = []; + const startRequests: Array> = []; + + // The last successfully created quote, served back on GET /v1/quotes/:id. + let lastQuote: Record | undefined; + let statusPollCount = 0; + + await page.route("http://localhost:3000/**", async route => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + const method = request.method(); + + const fulfillJson = (body: unknown, status = 200) => route.fulfill({ json: body as object, status }); + + if (path === "/v1/quotes" && method === "POST") { + const body = request.postDataJSON() as Record; + quoteRequests.push(body); + const result = options.quotes?.(body) ?? { + body: buildQuoteResponse({ + inputAmount: body.inputAmount, + inputCurrency: body.inputCurrency, + outputCurrency: body.outputCurrency, + rampType: body.rampType + }), + status: 200 + }; + if (result.status === 200) { + lastQuote = result.body as Record; + } + await fulfillJson(result.body, result.status); + return; + } + + if (path.startsWith("/v1/quotes/") && method === "GET") { + const quoteId = path.split("/").pop() as string; + await fulfillJson(lastQuote?.id === quoteId ? lastQuote : buildQuoteResponse({ id: quoteId })); + return; + } + + // Auth: email/OTP login (shapes mirror apps/api/src/api/controllers/auth.controller.ts). + if (path === "/v1/auth/check-email" && method === "GET") { + await fulfillJson({ action: "signup", exists: false }); + return; + } + if (path === "/v1/auth/request-otp" && method === "POST") { + await fulfillJson({ message: "OTP sent" }); + return; + } + if (path === "/v1/auth/verify-otp" && method === "POST") { + await fulfillJson({ + access_token: "e2e-access-token", + refresh_token: "e2e-refresh-token", + success: true, + user_id: E2E_USER_ID + }); + return; + } + if (path === "/v1/auth/verify" && method === "POST") { + await fulfillJson({ user_id: E2E_USER_ID, valid: true }); + return; + } + if (path === "/v1/auth/refresh" && method === "POST") { + await fulfillJson({ access_token: "e2e-access-token", refresh_token: "e2e-refresh-token", success: true }); + return; + } + + // Avenia/BRLA KYC gate: an existing, KYC-confirmed user (BrlaGetUserResponse shape), + // so validateKyc reports kycNeeded=false and the ramp can proceed to the summary. + if (path === "/v1/brla/getUser" && method === "GET") { + brlaGetUserRequests.push(url.searchParams.get("taxId") ?? ""); + await fulfillJson({ + evmAddress: "0x9d1B0C3A79cB3F44a03cC7C39a54Db19E22C6A9E", + identityStatus: "CONFIRMED", + kycLevel: 1, + subAccountId: "subaccount-e2e-1" + }); + return; + } + if (path === "/v1/brla/getUserRemainingLimit" && method === "GET") { + await fulfillJson({ remainingLimit: 100000 }); + return; + } + + // Alfredpay KYC gate: the alfredpayKyc machine's CheckingStatus step. SUCCESS means an + // existing verified customer, so the KYC child completes immediately (the gate's happy path). + if (path === "/v1/alfredpay/alfredpayStatus" && method === "GET") { + alfredpayStatusRequests.push(url.searchParams.get("country") ?? ""); + await fulfillJson({ status: options.alfredpayStatus ?? "SUCCESS" }); + return; + } + + // Alfredpay fiat accounts: the payout bank accounts registered with the anchor. On + // offramps the summary's FiatAccountSelector lists these and registration sends the + // chosen fiatAccountId. + if (path === "/v1/alfredpay/fiatAccounts" && method === "GET" && options.fiatAccounts) { + const country = url.searchParams.get("country") ?? ""; + fiatAccountsRequests.push(country); + await fulfillJson(options.fiatAccounts(country)); + return; + } + + // Ramp lifecycle (RampProcess shapes from packages/shared/src/endpoints/ramp.endpoints.ts). + if (path === "/v1/ramp/register" && method === "POST") { + const body = request.postDataJSON() as { + signingAccounts?: Array<{ address: string; type: string }>; + } & Record; + registerRequests.push(body); + if (options.register) { + await fulfillJson(options.register(body)); + return; + } + const evmEphemeral = body.signingAccounts?.find(account => account.type === "EVM")?.address ?? BASE_USDC; + await fulfillJson( + buildRampProcess({ + unsignedTxs: ONRAMP_TX_PHASES.map((phase, index) => buildUnsignedEvmTx(evmEphemeral, index, phase)) + }) + ); + return; + } + if (path === "/v1/ramp/update" && method === "POST") { + const body = request.postDataJSON() as Record; + updateRequests.push(body); + await fulfillJson(options.update ? options.update(body) : buildRampProcess()); + return; + } + if (path === "/v1/ramp/start" && method === "POST") { + startRequests.push(request.postDataJSON() as Record); + await fulfillJson(buildRampProcess({ currentPhase: "brlaOnrampMint", status: "PENDING" })); + return; + } + if (path === `/v1/ramp/${E2E_RAMP_ID}` && method === "GET") { + statusPollCount++; + const complete = statusPollCount > (options.pendingStatusPolls ?? 1); + // GetRampStatusResponse = RampProcess + fee breakdown fields. + await fulfillJson( + buildRampProcess({ + anchorFeeFiat: "0.5", + anchorFeeUsd: "0.1", + currentPhase: complete ? "complete" : "brlaOnrampMint", + feeCurrency: "BRL", + networkFeeFiat: "0.2", + networkFeeUsd: "0.04", + partnerFeeFiat: "0", + partnerFeeUsd: "0", + processingFeeFiat: "0.5", + processingFeeUsd: "0.1", + status: complete ? "COMPLETE" : "PENDING", + totalFeeFiat: "0.7", + totalFeeUsd: "0.14", + vortexFeeFiat: "0", + vortexFeeUsd: "0", + ...options.rampStatusOverrides?.(complete) + }) + ); + return; + } + + await route.fulfill({ json: {}, status: 404 }); + }); + + // The app reads chain state through the networks' RPC endpoints (ephemeral signing issues + // eth_chainId; offramps also read the user's token balance and wait for the receipt of the + // user-broadcast transaction). Answer those calls hermetically per chain. + type RpcRequest = { id?: number; method?: string; params?: unknown[] }; + const answerRpc = (chainIdHex: string) => (body: RpcRequest | RpcRequest[]) => { + const answerOne = (req: RpcRequest) => { + const hash = (req.params?.[0] as string) ?? `0x${"cd".repeat(32)}`; + let result: unknown = null; + switch (req.method) { + case "eth_chainId": + result = chainIdHex; + break; + // Token balance reads (balanceOf & friends): a comfortably large uint256. + case "eth_call": + result = `0x${(10n ** 24n).toString(16).padStart(64, "0")}`; + break; + case "eth_getBalance": + result = "0xde0b6b3a7640000"; // 1 native token + break; + case "eth_blockNumber": + result = "0x1"; + break; + case "eth_getTransactionCount": + result = "0x0"; + break; + case "eth_estimateGas": + result = "0x5208"; + break; + case "eth_gasPrice": + case "eth_maxPriorityFeePerGas": + result = "0x3b9aca00"; + break; + case "eth_getBlockByNumber": + result = { baseFeePerGas: "0x1", number: "0x1" }; + break; + // Answering the tx lookup marks user-broadcast hashes as regular (non-Safe) txs. + case "eth_getTransactionByHash": + result = { blockHash: `0x${"ef".repeat(32)}`, blockNumber: "0x1", from: null, hash, input: "0x", value: "0x0" }; + break; + case "eth_getTransactionReceipt": + result = { + blockHash: `0x${"ef".repeat(32)}`, + blockNumber: "0x1", + contractAddress: null, + cumulativeGasUsed: "0x5208", + effectiveGasPrice: "0x3b9aca00", + gasUsed: "0x5208", + logs: [], + logsBloom: `0x${"00".repeat(256)}`, + status: "0x1", + transactionHash: hash, + transactionIndex: "0x0", + type: "0x2" + }; + break; + } + return { id: req.id ?? 1, jsonrpc: "2.0", result }; + }; + return Array.isArray(body) ? body.map(answerOne) : answerOne(body); + }; + const rpcEndpoints: Array<{ chainIdHex: string; pattern: string }> = [ + { chainIdHex: "0x2105", pattern: "https://base-mainnet.g.alchemy.com/**" }, + { chainIdHex: "0x2105", pattern: "https://mainnet.base.org/**" }, + { chainIdHex: "0x89", pattern: "https://polygon-mainnet.g.alchemy.com/**" }, + { chainIdHex: "0x89", pattern: "https://polygon-rpc.com/**" } + ]; + for (const { chainIdHex, pattern } of rpcEndpoints) { + const answer = answerRpc(chainIdHex); + await page.route(pattern, async route => { + const body = route.request().postDataJSON() as Parameters[0]; + await route.fulfill({ json: answer(body) }); + }); + } + // Safe Wallet transaction service — never reached (eth_getTransactionByHash answers first), + // but blocked so a code change cannot silently make runs non-hermetic. + await page.route("https://safe-transaction-*.safe.global/**", route => route.abort()); + + // Alchemy Data API (token balances by address): the wallet holds plenty of USDC on Base + // and USDT on Polygon, so offramp balance gates pass. The balance fetcher keys results + // by tokenAddress per queried network; entries not configured for a network are ignored. + await page.route("https://api.g.alchemy.com/**", async route => { + await route.fulfill({ + json: { + data: { + tokens: [ + { tokenAddress: BASE_USDC, tokenBalance: `0x${(1_000_000_000_000n).toString(16)}` }, + { + tokenAddress: "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + tokenBalance: `0x${(1_000_000_000_000n).toString(16)}` + } + ] + } + } + }); + }); + + // SquidRouter token list: the app falls back to its static token config on failure. + await page.route("https://v2.api.squidrouter.com/**", route => route.abort()); + // WalletConnect/AppKit remote config and telemetry. + await page.route("https://api.web3modal.org/**", route => route.abort()); + await page.route("https://pulse.walletconnect.org/**", route => route.abort()); + + return { + alfredpayStatusRequests, + brlaGetUserRequests, + fiatAccountsRequests, + quoteRequests, + registerRequests, + startRequests, + updateRequests + }; +} diff --git a/apps/frontend/e2e/support/mockWallet.ts b/apps/frontend/e2e/support/mockWallet.ts new file mode 100644 index 000000000..25944fb19 --- /dev/null +++ b/apps/frontend/e2e/support/mockWallet.ts @@ -0,0 +1,87 @@ +import { Page } from "@playwright/test"; + +export const MOCK_WALLET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +export const MOCK_WALLET_NAME = "E2E Mock Wallet"; + +// Injects a minimal EIP-1193 provider announced via EIP-6963 before the app loads. +// AppKit is configured with enableEIP6963, so the provider shows up in the connect +// modal as an installed browser wallet — no app code changes needed. +// The wallet's chain defaults to Base; journeys that live on another network (e.g. +// Polygon offramps) pass the matching chainIdHex so no mid-flow chain switch is needed. +export async function injectMockWallet(page: Page, options: { chainIdHex?: string } = {}) { + await page.addInitScript( + ({ address, name, chainIdHex }) => { + // biome-ignore lint/suspicious/noExplicitAny: minimal EIP-1193 stub + const listeners: Record void>> = {}; + const provider = { + isMetaMask: false, + // biome-ignore lint/suspicious/noExplicitAny: minimal EIP-1193 stub + on: (event: string, cb: (...args: any[]) => void) => { + (listeners[event] ??= []).push(cb); + }, + // biome-ignore lint/suspicious/noExplicitAny: minimal EIP-1193 stub + removeListener: (event: string, cb: (...args: any[]) => void) => { + listeners[event] = (listeners[event] ?? []).filter(l => l !== cb); + }, + request: async ({ method }: { method: string }) => { + switch (method) { + case "eth_requestAccounts": + case "eth_accounts": + return [address]; + case "eth_chainId": + return chainIdHex; + case "net_version": + return String(Number(chainIdHex)); + case "wallet_switchEthereumChain": + case "wallet_addEthereumChain": + return null; + case "wallet_requestPermissions": + return [{ parentCapability: "eth_accounts" }]; + case "personal_sign": + case "eth_signTypedData_v4": + return `0x${"ab".repeat(65)}`; + // Journeys that submit user transactions (e.g. offramp squidRouter steps) + // get a plausible transaction hash back without touching a chain. + case "eth_sendTransaction": + return `0x${"cd".repeat(32)}`; + case "eth_getTransactionReceipt": + return { + blockHash: `0x${"ef".repeat(32)}`, + blockNumber: "0x1", + status: "0x1", + transactionHash: `0x${"cd".repeat(32)}` + }; + case "eth_estimateGas": + return "0x5208"; + case "eth_gasPrice": + return "0x3b9aca00"; + case "eth_getTransactionCount": + return "0x0"; + case "eth_getBalance": + return "0xde0b6b3a7640000"; // 1 ETH + case "eth_blockNumber": + return "0x1"; + default: + return null; + } + } + }; + + const info = Object.freeze({ + icon: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIvPg==", + name, + rdns: "co.vortexfinance.e2e", + uuid: "e2e00000-0000-4000-8000-000000000001" + }); + const announce = () => + window.dispatchEvent(new CustomEvent("eip6963:announceProvider", { detail: Object.freeze({ info, provider }) })); + window.addEventListener("eip6963:requestProvider", announce); + announce(); + + // Also expose as the legacy injected provider for connectors that look for it. + // biome-ignore lint/suspicious/noExplicitAny: window.ethereum has no typed slot here + (window as any).ethereum = provider; + }, + { address: MOCK_WALLET_ADDRESS, chainIdHex: options.chainIdHex ?? "0x2105", name: MOCK_WALLET_NAME } + ); +} diff --git a/apps/frontend/e2e/wallet-connect.spec.ts b/apps/frontend/e2e/wallet-connect.spec.ts new file mode 100644 index 000000000..df0ef5c0c --- /dev/null +++ b/apps/frontend/e2e/wallet-connect.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { injectMockWallet } from "./support/mockWallet"; + +// Critical journey 3: an offramp requires a connected wallet. +// Without one, the submit slot asks to connect; with the injected mock wallet +// (announced via EIP-6963, auto-connected by wagmi), the app shows the account +// and the Sell action instead. + +test("offramp without a wallet asks to connect instead of offering Sell", async ({ page }) => { + await mockBackend(page); + + await page.goto("/widget?rampType=SELL&fiat=BRL&inputAmount=100"); + + await expect(page.getByRole("button", { name: /Connect/ }).first()).toBeVisible({ timeout: 20_000 }); + await expect(page.locator("form").getByRole("button", { name: "Sell" })).toHaveCount(0); +}); + +test("offramp with the injected mock wallet connects and shows the Sell action", async ({ page }) => { + await mockBackend(page); + await injectMockWallet(page); + + await page.goto("/widget?rampType=SELL&fiat=BRL&inputAmount=100"); + + // The connected account (0xf39F...b92266) appears in the navbar. + await expect(page.getByRole("button", { name: /0xf39F/ })).toBeVisible({ timeout: 20_000 }); + + // The submit slot offers Sell instead of the connect prompt. (It may still be + // disabled — the mock wallet holds no USDC — but the wallet gate is passed.) + await expect(page.locator("form").getByRole("button", { name: "Sell" })).toBeVisible(); + await expect(page.getByRole("button", { name: /Connect/ })).toHaveCount(0); +}); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3711431ac..699998297 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -80,6 +80,7 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-typescript": "^7.18.6", "@pendulum-chain/types": "catalog:", + "@playwright/test": "^1.61.1", "@polkadot/types-augment": "catalog:", "@polkadot/types-codec": "catalog:", "@polkadot/types-create": "catalog:", @@ -87,6 +88,10 @@ "@storybook/react-vite": "^9.1.4", "@tanstack/react-query-devtools": "^5.91.1", "@tanstack/router-plugin": "^1.136.8", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/big.js": "catalog:", "@types/bn.js": "^5", "@types/node": "catalog:", @@ -94,6 +99,7 @@ "@types/react-dom": "^19.0.3", "@typescript-eslint/eslint-plugin": "^5.53.0", "@typescript-eslint/parser": "^5.53.0", + "@vitest/coverage-v8": "3.2.4", "babel-preset-vite": "^1.1.3", "daisyui": "^5.5.5", "esbuild": "^0.25.9", @@ -102,7 +108,9 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^9.1.4", "husky": ">=6", + "jsdom": "26", "lint-staged": ">=10", + "msw": "^2.14.6", "prettier": "catalog:", "storybook": "^9.1.4", "ts-node": "^10.9.1", @@ -126,6 +134,8 @@ "preview": "bun x --bun vite preview", "storybook": "storybook dev -p 6006", "test": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", "verify": "bun test" }, "type": "module", diff --git a/apps/frontend/playwright.config.ts b/apps/frontend/playwright.config.ts new file mode 100644 index 000000000..186bf6fce --- /dev/null +++ b/apps/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +// E2E journeys are non-PR-blocking (see docs/testing-strategy.md): they run nightly in CI +// and locally via `bun test:e2e`. The backend is mocked per-test with page.route, so no +// API server, database, or chain access is needed — only the Vite dev server. +export default defineConfig({ + forbidOnly: !!process.env.CI, + fullyParallel: true, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + reporter: process.env.CI ? [["list"], ["github"]] : [["list"]], + retries: process.env.CI ? 2 : 0, + testDir: "./e2e", + timeout: 60_000, + use: { + baseURL: "http://127.0.0.1:5173", + trace: "on-first-retry" + }, + webServer: { + command: "bun x --bun vite --port 5173 --strictPort", + // A placeholder Alchemy key so balance fetching runs at all; every Alchemy + // endpoint is intercepted per-test in e2e/support/mockBackend.ts. + env: { VITE_ALCHEMY_API_KEY: "e2e-mock-key" }, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + url: "http://127.0.0.1:5173" + } +}); diff --git a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/AveniaKycEligibilityFields.test.tsx b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/AveniaKycEligibilityFields.test.tsx new file mode 100644 index 000000000..d15e3a058 --- /dev/null +++ b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/AveniaKycEligibilityFields.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import { FormProvider } from "react-hook-form"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RampFormValues } from "../../../hooks/ramp/schema"; +import { useRampForm } from "../../../hooks/ramp/useRampForm"; +import { useQuoteStore } from "../../../stores/quote/useQuoteStore"; +import { useRampDirectionStore } from "../../../stores/rampDirectionStore"; +import { buildQuoteResponse } from "../../../test/fixtures"; +import "../../../test/i18n"; +import { AveniaKycEligibilityFields } from "./index"; + +const VALID_CPF = "529.982.247-25"; +const VALID_EVM_ADDRESS = "0x1111111111111111111111111111111111111111"; + +function Harness({ onValid }: { onValid: (values: RampFormValues) => void }) { + const { form } = useRampForm({ fiatToken: FiatToken.BRL }); + + return ( + +
+ + + +
+ ); +} + +describe("AveniaKycEligibilityFields (BRL details form)", () => { + beforeEach(() => { + localStorage.clear(); + useQuoteStore.setState({ quote: undefined }); + useRampDirectionStore.setState({ activeDirection: RampDirection.SELL }); + }); + + describe("offramp (SELL)", () => { + it("renders CPF/CNPJ and Pix key fields", () => { + render(); + + expect(screen.getByLabelText("CPF or CNPJ")).toBeInTheDocument(); + expect(screen.getByLabelText("Pix key")).toBeInTheDocument(); + }); + + it("shows required errors and does not submit when both fields are empty", async () => { + const onValid = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Confirm" })); + + expect(await screen.findByText("CPF or CNPJ is required when transferring BRL")).toBeInTheDocument(); + expect(screen.getByText("PIX key is required when transferring BRL")).toBeInTheDocument(); + expect(onValid).not.toHaveBeenCalled(); + }); + + it("rejects a malformed CPF", async () => { + const onValid = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("CPF or CNPJ"), "12345"); + await user.type(screen.getByLabelText("Pix key"), "user@example.com"); + await user.click(screen.getByRole("button", { name: "Confirm" })); + + expect(await screen.findByText("Invalid CPF or CNPJ format")).toBeInTheDocument(); + expect(onValid).not.toHaveBeenCalled(); + }); + + it("rejects a Pix key that matches no valid format", async () => { + const onValid = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("CPF or CNPJ"), VALID_CPF); + await user.type(screen.getByLabelText("Pix key"), "not-a-pix-key"); + await user.click(screen.getByRole("button", { name: "Confirm" })); + + expect(await screen.findByText("PIX key does not match any of the valid formats")).toBeInTheDocument(); + expect(onValid).not.toHaveBeenCalled(); + }); + + it("submits with a valid CPF and an email Pix key", async () => { + const onValid = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("CPF or CNPJ"), VALID_CPF); + await user.type(screen.getByLabelText("Pix key"), "user@example.com"); + await user.click(screen.getByRole("button", { name: "Confirm" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect(onValid.mock.calls[0][0]).toMatchObject({ + fiatToken: FiatToken.BRL, + pixId: "user@example.com", + taxId: VALID_CPF + }); + }); + }); + + describe("onramp (BUY)", () => { + beforeEach(() => { + useRampDirectionStore.setState({ activeDirection: RampDirection.BUY }); + // An EVM-destination BUY quote makes the schema require a valid EVM wallet address. + useQuoteStore.setState({ quote: buildQuoteResponse({ rampType: RampDirection.BUY, to: Networks.Base }) }); + }); + + it("renders a wallet address field instead of the Pix key and rejects invalid EVM addresses", async () => { + const onValid = vi.fn(); + const user = userEvent.setup(); + render(); + + expect(screen.queryByLabelText("Pix key")).not.toBeInTheDocument(); + + await user.type(screen.getByLabelText("CPF or CNPJ"), VALID_CPF); + await user.type(screen.getByLabelText("Wallet Address"), "0x123-not-an-address"); + await user.click(screen.getByRole("button", { name: "Confirm" })); + + expect(await screen.findByText("Invalid EVM wallet address")).toBeInTheDocument(); + expect(onValid).not.toHaveBeenCalled(); + }); + + it("submits with a valid CPF and EVM wallet address", async () => { + const onValid = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("CPF or CNPJ"), VALID_CPF); + await user.type(screen.getByLabelText("Wallet Address"), VALID_EVM_ADDRESS); + await user.click(screen.getByRole("button", { name: "Confirm" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect(onValid.mock.calls[0][0]).toMatchObject({ + taxId: VALID_CPF, + walletAddress: VALID_EVM_ADDRESS + }); + }); + }); +}); diff --git a/apps/frontend/src/components/Ramp/Offramp/Offramp.test.tsx b/apps/frontend/src/components/Ramp/Offramp/Offramp.test.tsx new file mode 100644 index 000000000..84016614a --- /dev/null +++ b/apps/frontend/src/components/Ramp/Offramp/Offramp.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from "@testing-library/react"; +import { EvmToken, FiatToken, RampDirection } from "@vortexfi/shared"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { usePartnerStore } from "../../../stores/partnerStore"; +import { useQuoteFormStore } from "../../../stores/quote/useQuoteFormStore"; +import { useQuoteStore } from "../../../stores/quote/useQuoteStore"; +import { useRampDirectionStore } from "../../../stores/rampDirectionStore"; +import { createFakeRampActor } from "../../../test/fakeRampActor"; +import { buildQuoteResponse } from "../../../test/fixtures"; +import "../../../test/i18n"; +import { API_BASE_URL, server } from "../../../test/msw-server"; + +// Same module-level fakes as Onramp.test.tsx: no wallet connected, no router mounted. +// All mocked hooks return module-level singletons — a fresh object per render would +// re-trigger effects that depend on them (infinite quote re-fetch). +const stubs = { + account: { address: undefined, chain: undefined, chainId: undefined }, + appKit: { close: vi.fn(), open: vi.fn() }, + appKitAccount: { address: undefined, isConnected: false }, + appKitNetwork: { caipNetwork: undefined, chainId: undefined, switchNetwork: vi.fn() }, + events: { trackEvent: vi.fn() }, + network: { + networkSelectorDisabled: false, + selectedNetwork: "base", + setNetworkSelectorDisabled: vi.fn(), + setSelectedNetwork: vi.fn() + }, + polkadotWallet: { walletAccount: undefined }, + router: { navigate: vi.fn() }, + signMessage: { signMessageAsync: vi.fn() }, + switchChain: { switchChainAsync: vi.fn() } +}; + +vi.mock("wagmi", () => ({ + useAccount: () => stubs.account, + useSignMessage: () => stubs.signMessage, + useSwitchChain: () => stubs.switchChain +})); + +vi.mock("../../../wagmiConfig", () => ({ wagmiConfig: { chains: [] } })); + +vi.mock("@reown/appkit/react", () => ({ + useAppKit: () => stubs.appKit, + useAppKitAccount: () => stubs.appKitAccount, + useAppKitNetwork: () => stubs.appKitNetwork +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children }: { children?: React.ReactNode }) => {children}, + useParams: () => ({}), + useRouter: () => stubs.router, + useSearch: () => ({}) +})); + +const fakeRampActor = createFakeRampActor(); +vi.mock("../../../contexts/rampState", () => ({ + useRampActor: () => fakeRampActor +})); + +vi.mock("../../../contexts/network", () => ({ + useNetwork: () => stubs.network +})); + +vi.mock("../../../contexts/events", () => ({ + useEventsContext: () => stubs.events +})); + +vi.mock("../../../contexts/polkadotWallet", () => ({ + usePolkadotWalletState: () => stubs.polkadotWallet +})); + +import { Offramp } from "./index"; + +const quoteRequests: Array> = []; + +describe("Offramp quote form (SELL)", () => { + beforeEach(() => { + localStorage.clear(); + quoteRequests.length = 0; + useRampDirectionStore.setState({ activeDirection: RampDirection.SELL }); + useQuoteFormStore.setState({ + fiatToken: FiatToken.BRL, + inputAmount: "100", + lastConstraintDirection: RampDirection.SELL, + onChainToken: EvmToken.USDC + }); + useQuoteStore.setState({ error: null, exchangeRate: 0, loading: false, outputAmount: undefined, quote: undefined }); + usePartnerStore.setState({ apiKey: null, partnerId: null }); + }); + + it("requests a SELL quote and shows the fiat output; asks to connect a wallet before selling", async () => { + server.use( + http.post(`${API_BASE_URL}/quotes`, async ({ request }) => { + const body = (await request.json()) as Record; + quoteRequests.push(body); + return HttpResponse.json( + buildQuoteResponse({ + inputAmount: body.inputAmount as string, + inputCurrency: body.inputCurrency as never, + outputAmount: "480.7", + outputCurrency: body.outputCurrency as never, + rampType: body.rampType as RampDirection + }) + ); + }) + ); + render(); + + expect(screen.getByText("You sell")).toBeInTheDocument(); + expect(screen.getByText("You receive")).toBeInTheDocument(); + + await waitFor(() => expect(quoteRequests.length).toBeGreaterThanOrEqual(1)); + expect(quoteRequests[0]).toMatchObject({ + inputAmount: "100", + inputCurrency: EvmToken.USDC, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL + }); + + await waitFor(() => + expect((document.querySelector('input[name="outputAmount"]') as HTMLInputElement).value).toContain("480.7") + ); + + // No wallet is connected, so the submit slot shows the connect-wallet button instead of "Sell". + expect(screen.getByText("Connect")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Sell" })).not.toBeInTheDocument(); + }); + + it("rewrites a backend minimum-limit rejection into a readable message with the backend's limit", async () => { + server.use( + http.post(`${API_BASE_URL}/quotes`, () => + HttpResponse.json({ error: "Output amount below minimum SELL limit of 25.00 BRL" }, { status: 400 }) + ) + ); + render(); + + expect(await screen.findByText("Minimum sell amount is 25 BRL.")).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/components/Ramp/Onramp/Onramp.test.tsx b/apps/frontend/src/components/Ramp/Onramp/Onramp.test.tsx new file mode 100644 index 000000000..261183b9f --- /dev/null +++ b/apps/frontend/src/components/Ramp/Onramp/Onramp.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FiatToken, QuoteError, RampDirection } from "@vortexfi/shared"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { usePartnerStore } from "../../../stores/partnerStore"; +import { useQuoteFormStore } from "../../../stores/quote/useQuoteFormStore"; +import { useQuoteStore } from "../../../stores/quote/useQuoteStore"; +import { useRampDirectionStore } from "../../../stores/rampDirectionStore"; +import { createFakeRampActor } from "../../../test/fakeRampActor"; +import { buildQuoteResponse } from "../../../test/fixtures"; +import "../../../test/i18n"; +import { API_BASE_URL, server } from "../../../test/msw-server"; + +// The quote form pulls in wallet, router, and app-level contexts. They are irrelevant +// to the quote flow itself, so they are faked at module level: no wallet is connected +// and no router is mounted. All mocked hooks return module-level singletons — hooks +// like useQuoteService put e.g. trackEvent in effect dependency arrays, so a fresh +// object per render causes an infinite re-fetch loop. +const stubs = { + account: { address: undefined, chain: undefined, chainId: undefined }, + appKit: { close: vi.fn(), open: vi.fn() }, + appKitAccount: { address: undefined, isConnected: false }, + appKitNetwork: { caipNetwork: undefined, chainId: undefined, switchNetwork: vi.fn() }, + events: { trackEvent: vi.fn() }, + network: { + networkSelectorDisabled: false, + selectedNetwork: "base", + setNetworkSelectorDisabled: vi.fn(), + setSelectedNetwork: vi.fn() + }, + polkadotWallet: { walletAccount: undefined }, + router: { navigate: vi.fn() }, + signMessage: { signMessageAsync: vi.fn() }, + switchChain: { switchChainAsync: vi.fn() } +}; + +vi.mock("wagmi", () => ({ + useAccount: () => stubs.account, + useSignMessage: () => stubs.signMessage, + useSwitchChain: () => stubs.switchChain +})); + +// wagmiConfig runs createAppKit() at import time; keep it out of the test module graph. +vi.mock("../../../wagmiConfig", () => ({ wagmiConfig: { chains: [] } })); + +vi.mock("@reown/appkit/react", () => ({ + useAppKit: () => stubs.appKit, + useAppKitAccount: () => stubs.appKitAccount, + useAppKitNetwork: () => stubs.appKitNetwork +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children }: { children?: React.ReactNode }) => {children}, + useParams: () => ({}), + useRouter: () => stubs.router, + useSearch: () => ({}) +})); + +const fakeRampActor = createFakeRampActor(); +vi.mock("../../../contexts/rampState", () => ({ + useRampActor: () => fakeRampActor +})); + +vi.mock("../../../contexts/network", () => ({ + useNetwork: () => stubs.network +})); + +vi.mock("../../../contexts/events", () => ({ + useEventsContext: () => stubs.events +})); + +vi.mock("../../../contexts/polkadotWallet", () => ({ + usePolkadotWalletState: () => stubs.polkadotWallet +})); + +import { Onramp } from "./index"; + +const quoteRequests: Array> = []; + +function mockQuoteEndpoint() { + server.use( + http.post(`${API_BASE_URL}/quotes`, async ({ request }) => { + const body = (await request.json()) as Record; + quoteRequests.push(body); + return HttpResponse.json( + buildQuoteResponse({ + inputAmount: body.inputAmount as string, + inputCurrency: body.inputCurrency as never, + outputAmount: "25.5", + outputCurrency: body.outputCurrency as never, + rampType: body.rampType as RampDirection + }) + ); + }) + ); +} + +const getInputAmountField = () => document.querySelector('input[name="inputAmount"]') as HTMLInputElement; +const getOutputAmountField = () => document.querySelector('input[name="outputAmount"]') as HTMLInputElement; + +describe("Onramp quote form (BUY)", () => { + beforeEach(() => { + localStorage.clear(); + quoteRequests.length = 0; + useRampDirectionStore.setState({ activeDirection: RampDirection.BUY }); + useQuoteFormStore.setState({ + fiatToken: FiatToken.BRL, + inputAmount: "100", + lastConstraintDirection: RampDirection.BUY + }); + useQuoteStore.setState({ error: null, exchangeRate: 0, loading: false, outputAmount: undefined, quote: undefined }); + // null (as opposed to undefined) means "resolved from the URL: no partner" — quotes may be fetched. + usePartnerStore.setState({ apiKey: null, partnerId: null }); + }); + + it("requests a quote for the current amount and displays the received output amount", async () => { + mockQuoteEndpoint(); + render(); + + expect(screen.getByText("You pay")).toBeInTheDocument(); + expect(screen.getByText("You receive")).toBeInTheDocument(); + + await waitFor(() => expect(quoteRequests.length).toBeGreaterThanOrEqual(1)); + expect(quoteRequests[0]).toMatchObject({ + inputAmount: "100", + inputCurrency: FiatToken.BRL, + rampType: RampDirection.BUY + }); + + await waitFor(() => expect(getOutputAmountField().value).toContain("25.5")); + + // A fresh, matching quote enables the submit button. + await waitFor(() => expect(screen.getByRole("button", { name: "Buy" })).toBeEnabled()); + }); + + it("re-requests a quote with the new amount after the user edits the input (debounced)", async () => { + mockQuoteEndpoint(); + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(quoteRequests.length).toBeGreaterThanOrEqual(1)); + + const input = getInputAmountField(); + await user.clear(input); + await user.type(input, "250"); + + await waitFor(() => expect(quoteRequests.some(r => r.inputAmount === "250")).toBe(true), { timeout: 4000 }); + }); + + it("shows the backend quote error and keeps the submit button disabled", async () => { + server.use( + http.post(`${API_BASE_URL}/quotes`, () => + HttpResponse.json({ error: QuoteError.InputAmountTooLowToCoverFees }, { status: 400 }) + ) + ); + render(); + + expect(await screen.findByText("Input amount too low. Please try a larger amount.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Buy" })).toBeDisabled(); + }); +}); diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index 2b8b71816..6f1e14c7f 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -25,6 +25,7 @@ import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { MykoboService } from "../../../services/api/mykobo.service"; import { RampExecutionInput } from "../../../types/phases"; +import { ARSOnrampDetails } from "./ARSOnrampDetails"; import { AssetDisplay } from "./AssetDisplay"; import { BRLOnrampDetails } from "./BRLOnrampDetails"; import { COPOnrampDetails } from "./COPOnrampDetails"; @@ -34,7 +35,7 @@ import { MXNOnrampDetails } from "./MXNOnrampDetails"; import { USOnrampDetails } from "./USOnrampDetails"; const ONRAMP_DETAILS_BY_FIAT: Record = { - [FiatToken.ARS]: null, + [FiatToken.ARS]: ARSOnrampDetails, [FiatToken.BRL]: BRLOnrampDetails, [FiatToken.COP]: COPOnrampDetails, [FiatToken.EURC]: EUROnrampDetails, diff --git a/apps/frontend/src/config/supabase.ts b/apps/frontend/src/config/supabase.ts index 7bfa762c6..609691faf 100644 --- a/apps/frontend/src/config/supabase.ts +++ b/apps/frontend/src/config/supabase.ts @@ -9,8 +9,10 @@ if (!supabaseUrl || !supabaseAnonKey) { export const supabase = createClient(supabaseUrl, supabaseAnonKey, { auth: { - autoRefreshToken: true, - detectSessionInUrl: true, - persistSession: true + // The app owns token storage and refresh (backend /auth/refresh + a single scheduler). + // Keep the client passive so it doesn't run a competing background refresh. + autoRefreshToken: false, + detectSessionInUrl: false, + persistSession: false } }); diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 871441332..e1f818737 100644 --- a/apps/frontend/src/contexts/rampState.tsx +++ b/apps/frontend/src/contexts/rampState.tsx @@ -12,11 +12,14 @@ import { SelectedAveniaData, SelectedMykoboData } from "../machines/types"; +import { AuthService } from "../services/auth"; import { RampExecutionInput } from "../types/phases"; const RAMP_STATE_STORAGE_KEY = "rampState"; const RAMP_EPHEMERALS_STORAGE_KEY = "rampEphemerals"; const MAX_RAMP_EPHEMERALS = 50; +const TOKEN_REFRESH_SKEW_MS = 60 * 1000; // refresh 60s before expiry +const TOKEN_REFRESH_RETRY_MS = 30 * 1000; // retry after a transient failure type RampEphemeralEntry = { substrateEphemeral: EphemeralAccount; @@ -136,10 +139,70 @@ const PersistenceEffect = () => { return null; }; +// Single app-wide token refresher: schedules a refresh just before the access token's real +// expiry (decoded from the JWT), reschedules off each new token, and retries transient +// failures without dropping the session. +const TokenRefreshEffect = () => { + const rampActor = useRampActor(); + const isAuthenticated = useSelector(rampActor, state => state?.context.isAuthenticated ?? false); + + useEffect(() => { + if (!isAuthenticated) { + return; + } + + let cancelled = false; + let timer: ReturnType | undefined; + + const scheduleNext = () => { + if (cancelled) return; + const expiryMs = AuthService.getAccessTokenExpiryMs(); + // If the expiry can't be decoded, don't kill the loop permanently — retry shortly so a + // later (decodable) token re-establishes the schedule. + const delay = expiryMs === null ? TOKEN_REFRESH_RETRY_MS : Math.max(expiryMs - Date.now() - TOKEN_REFRESH_SKEW_MS, 0); + if (expiryMs === null) { + timer = setTimeout(scheduleNext, delay); + return; + } + timer = setTimeout(async () => { + if (cancelled) return; + try { + const refreshed = await AuthService.refreshAccessToken(); + if (cancelled) return; + if (refreshed) { + // refreshAccessToken() has already persisted the new token, so this reads the fresh expiry. + scheduleNext(); + } else { + // Refresh token confirmed invalid: the session is over. + rampActor.send({ type: "LOGOUT" }); + } + } catch { + // Transient failure: retry soon without touching the session. + if (!cancelled) { + timer = setTimeout(scheduleNext, TOKEN_REFRESH_RETRY_MS); + } + } + }, delay); + }; + + scheduleNext(); + + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [isAuthenticated, rampActor]); + + return null; +}; + export const PersistentRampStateProvider: React.FC = ({ children }) => { return ( + {children} ); diff --git a/apps/frontend/src/helpers/sentry.ts b/apps/frontend/src/helpers/sentry.ts new file mode 100644 index 000000000..8620eb6f6 --- /dev/null +++ b/apps/frontend/src/helpers/sentry.ts @@ -0,0 +1,73 @@ +import type { ErrorEvent, EventHint } from "@sentry/react"; +import { type DomainError, isApiError } from "../services/api/api-client"; + +// Expected, unactionable noise we never want to report: wallet user-rejections, +// browser-extension internals, benign ResizeObserver warnings, and cancelled requests. +// Note: TimeoutError is intentionally NOT ignored — a request timeout can signal a slow backend. +export const SENTRY_IGNORE_ERRORS: (string | RegExp)[] = [ + "User rejected the request", + "User denied", + "User rejected", + "Rejected by user", + /ResizeObserver loop completed/, + /ResizeObserver loop limit exceeded/, + "Extension context invalidated", + "AbortError", + "The user aborted a request" +]; + +// Drop events whose top frame comes from a browser-extension injected script. +export const SENTRY_DENY_URLS: RegExp[] = [ + /^chrome-extension:\/\//, + /^moz-extension:\/\//, + /^safari-extension:\/\//, + /^chrome:\/\// +]; + +// Query strings can carry PII (session ids, tax ids, wallet addresses, callback urls). +// The path alone is enough for debugging, so redact everything after "?". +function stripQueryString(url: string): string { + const queryIndex = url.indexOf("?"); + return queryIndex === -1 ? url : `${url.slice(0, queryIndex)}?[Filtered]`; +} + +// Client errors that are user-driven or expected (auth, rate-limit, not-found) rather than bugs. +// 400/422 are kept — they usually mean we sent a bad request, which is worth reporting. +// 409 is kept too: ramp conflicts ("Quote already consumed", "Ramp is not in a state that allows +// updates") are genuine money-flow failures that captureActorError intentionally reports. +const EXPECTED_CLIENT_STATUSES = new Set([401, 403, 404, 429]); + +function hasDomain(error: unknown): error is DomainError { + return typeof error === "object" && error !== null && typeof (error as DomainError).domain === "string"; +} + +// Tag events by business domain (when the originating error carries one), drop expected +// client errors, and strip PII from URLs before the event leaves the browser. +export function sentryBeforeSend(event: ErrorEvent, hint?: EventHint): ErrorEvent | null { + const original = hint?.originalException; + + // Don't report expected client errors (auth/rate-limit/not-found) — user-driven, not bugs. + if (isApiError(original) && EXPECTED_CLIENT_STATUSES.has(original.status)) { + return null; + } + + if (hasDomain(original)) { + event.tags = { ...event.tags, domain: original.domain }; + } + + if (event.request?.url) { + event.request.url = stripQueryString(event.request.url); + } + if (event.request?.query_string) { + event.request.query_string = "[Filtered]"; + } + if (event.breadcrumbs) { + event.breadcrumbs = event.breadcrumbs.map(breadcrumb => + typeof breadcrumb.data?.url === "string" + ? { ...breadcrumb, data: { ...breadcrumb.data, url: stripQueryString(breadcrumb.data.url) } } + : breadcrumb + ); + } + + return event; +} diff --git a/apps/frontend/src/hooks/useAuthTokens.ts b/apps/frontend/src/hooks/useAuthTokens.ts index f09b8db23..17831fe66 100644 --- a/apps/frontend/src/hooks/useAuthTokens.ts +++ b/apps/frontend/src/hooks/useAuthTokens.ts @@ -49,12 +49,6 @@ export function useAuthTokens(actorRef: ActorRefFrom) { } }, [actorRef]); - // Setup auto-refresh on mount - useEffect(() => { - const cleanup = AuthService.setupAutoRefresh(); - return cleanup; - }, []); - // Restore session from localStorage on mount useEffect(() => { // Only restore once on initial mount to avoid infinite loops diff --git a/apps/frontend/src/machines/actors/register.actor.test.ts b/apps/frontend/src/machines/actors/register.actor.test.ts index 9fa8bf444..f4017881d 100644 --- a/apps/frontend/src/machines/actors/register.actor.test.ts +++ b/apps/frontend/src/machines/actors/register.actor.test.ts @@ -1,6 +1,38 @@ -import { FiatToken, Networks, RampDirection } from "@vortexfi/shared"; -import { describe, expect, it } from "vitest"; +// @vitest-environment jsdom +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Keep the real shared package but stub the pieces that would hit chains: the ephemeral +// signing helper and the polkadot ApiManager (none of these tests may open an RPC connection). +vi.mock("@vortexfi/shared", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + ApiManager: { + getInstance: () => ({ + getApi: vi.fn(async () => { + throw new Error("getApi must not be called in these tests"); + }) + }) + }, + signUnsignedTransactions: vi.fn() + }; +}); + +import { + FiatToken, + getAddressForFormat, + Networks, + RampDirection, + RegisterRampRequest, + signUnsignedTransactions, + UpdateRampRequest +} from "@vortexfi/shared"; +import { ApiError } from "../../services/api/api-client"; +import { buildQuoteResponse, buildRampProcess, buildUnsignedTx } from "../../test/fixtures"; +import { API_BASE_URL, server } from "../../test/msw-server"; import { RampContext } from "../types"; +import { registerRampActor } from "./register.actor"; import { buildRegisterRampAdditionalData, RegisterRampError, RegisterRampErrorType } from "./registerAdditionalData"; const baseContext = { @@ -41,3 +73,153 @@ describe("buildRegisterRampAdditionalData", () => { ).toThrow(new RegisterRampError("User email is required for Mykobo EUR offramp.", RegisterRampErrorType.InvalidInput)); }); }); + +const USER_ADDRESS = "0x1111111111111111111111111111111111111111"; +const EVM_EPHEMERAL_ADDRESS = "0x3333333333333333333333333333333333333333"; +// Well-known Alice dev account (ss58 format 42). +const SUBSTRATE_EPHEMERAL_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + +const quote = buildQuoteResponse({ rampType: RampDirection.SELL }); + +function buildExecutionContext(overrides: Partial = {}): RampContext { + return { + ...baseContext, + chainId: 1, + executionInput: { + ...baseContext.executionInput, + ephemerals: { + evmEphemeral: { address: EVM_EPHEMERAL_ADDRESS, secret: "0xsecret" }, + substrateEphemeral: { address: SUBSTRATE_EPHEMERAL_ADDRESS, secret: "seed" } + }, + quote + }, + quote, + userId: "user-1", + ...overrides + } as RampContext; +} + +// Serves POST /ramp/register and /ramp/update, recording the request bodies. +function mockRampEndpoints(unsignedTxs: ReturnType[]) { + const registerCalls: RegisterRampRequest[] = []; + const updateCalls: UpdateRampRequest[] = []; + server.use( + http.post(`${API_BASE_URL}/ramp/register`, async ({ request }) => { + registerCalls.push((await request.json()) as RegisterRampRequest); + return HttpResponse.json({ ...buildRampProcess("initial", { id: "ramp-registered" }), unsignedTxs }); + }), + http.post(`${API_BASE_URL}/ramp/update`, async ({ request }) => { + updateCalls.push((await request.json()) as UpdateRampRequest); + return HttpResponse.json(buildRampProcess("initial", { id: "ramp-updated" })); + }) + ); + return { registerCalls, updateCalls }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("registerRampActor", () => { + it.each([ + ["executionInput", { executionInput: undefined }], + ["quote", { quote: undefined }], + ["connectedWalletAddress", { connectedWalletAddress: undefined }], + ["chainId", { chainId: undefined }] + ])("throws an InvalidInput error when %s is missing", async (_field, override) => { + const context = buildExecutionContext(override as Partial); + + await expect(registerRampActor({ input: context })).rejects.toMatchObject({ + type: RegisterRampErrorType.InvalidInput + }); + }); + + it("registers the ramp, signs only the ephemeral transactions and submits them", async () => { + const userTx = buildUnsignedTx({ signer: USER_ADDRESS.toUpperCase().replace("0X", "0x") }); + const ephemeralTx = buildUnsignedTx({ phase: "squidRouterPay", signer: EVM_EPHEMERAL_ADDRESS }); + const { registerCalls, updateCalls } = mockRampEndpoints([userTx, ephemeralTx]); + const signedEphemeralTx = { ...ephemeralTx, txData: "0xsigned" }; + vi.mocked(signUnsignedTransactions).mockResolvedValue([signedEphemeralTx]); + + const result = await registerRampActor({ input: buildExecutionContext() }); + + expect(registerCalls).toHaveLength(1); + expect(registerCalls[0]).toMatchObject({ + additionalData: { + destinationAddress: "0x2222222222222222222222222222222222222222", + email: "user@example.com", + sessionId: "session-1", + walletAddress: USER_ADDRESS + }, + quoteId: "quote-1", + signingAccounts: [ + { address: EVM_EPHEMERAL_ADDRESS, type: "EVM" }, + { address: SUBSTRATE_EPHEMERAL_ADDRESS, type: "Substrate" } + ], + userId: "user-1" + }); + + // Only the ephemeral tx reaches the signer: the user-signed tx is matched case-insensitively. + expect(vi.mocked(signUnsignedTransactions).mock.calls[0][0]).toEqual([ephemeralTx]); + + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].rampId).toBe("ramp-registered"); + expect(updateCalls[0].presignedTxs).toEqual([signedEphemeralTx]); + + expect(result).toEqual({ + quote, + ramp: expect.objectContaining({ id: "ramp-updated" }), + requiredUserActionsCompleted: false, + signedTransactions: [signedEphemeralTx], + userSigningMeta: { + assethubToPendulumHash: undefined, + squidRouterApproveHash: undefined, + squidRouterSwapHash: undefined + } + }); + }); + + it("excludes the user's substrate transactions across ss58 formats on substrate chains", async () => { + const userSubstrateTx = buildUnsignedTx({ + network: Networks.Pendulum, + phase: "assethubToPendulum", + // Signer encoded with the Polkadot prefix while the wallet uses the generic prefix. + signer: getAddressForFormat(SUBSTRATE_EPHEMERAL_ADDRESS, 0), + txData: "0xdeadbeef" + }); + const ephemeralTx = buildUnsignedTx({ phase: "squidRouterPay", signer: EVM_EPHEMERAL_ADDRESS }); + mockRampEndpoints([userSubstrateTx, ephemeralTx]); + vi.mocked(signUnsignedTransactions).mockResolvedValue([ephemeralTx]); + + await registerRampActor({ + input: buildExecutionContext({ chainId: -1, connectedWalletAddress: SUBSTRATE_EPHEMERAL_ADDRESS }) + }); + + // If the Pendulum tx were not filtered out, the stubbed ApiManager.getApi would throw. + expect(vi.mocked(signUnsignedTransactions).mock.calls[0][0]).toEqual([ephemeralTx]); + }); + + it("propagates a registration API failure without signing or updating", async () => { + const { updateCalls } = mockRampEndpoints([]); + server.use( + http.post(`${API_BASE_URL}/ramp/register`, () => + HttpResponse.json({ error: "Quote expired" }, { status: 400 }) + ) + ); + + const promise = registerRampActor({ input: buildExecutionContext() }); + await expect(promise).rejects.toBeInstanceOf(ApiError); + await expect(promise).rejects.toMatchObject({ message: "Quote expired", status: 400 }); + expect(signUnsignedTransactions).not.toHaveBeenCalled(); + expect(updateCalls).toHaveLength(0); + }); + + it("propagates an update API failure after registration", async () => { + const ephemeralTx = buildUnsignedTx({ signer: EVM_EPHEMERAL_ADDRESS }); + mockRampEndpoints([ephemeralTx]); + server.use(http.post(`${API_BASE_URL}/ramp/update`, () => HttpResponse.json({ error: "boom" }, { status: 500 }))); + vi.mocked(signUnsignedTransactions).mockResolvedValue([ephemeralTx]); + + await expect(registerRampActor({ input: buildExecutionContext() })).rejects.toMatchObject({ status: 500 }); + }); +}); diff --git a/apps/frontend/src/machines/actors/sign.actor.test.ts b/apps/frontend/src/machines/actors/sign.actor.test.ts new file mode 100644 index 000000000..e59cec424 --- /dev/null +++ b/apps/frontend/src/machines/actors/sign.actor.test.ts @@ -0,0 +1,334 @@ +// @vitest-environment jsdom +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The real module imports wagmi/walletconnect config at module load; the actor tests only +// care about which signing helper is routed to, not the wallet plumbing itself. +vi.mock("../../services/transactions/userSigning", () => ({ + signAndSubmitEvmTransaction: vi.fn(), + signAndSubmitSubstrateTransaction: vi.fn(), + signMultipleTypedData: vi.fn() +})); + +import { WalletAccount } from "@talismn/connect-wallets"; +import { EvmToken, getAddressForFormat, Networks, UnsignedTx, UpdateRampRequest } from "@vortexfi/shared"; +import { buildQuoteResponse, buildRampProcess, buildSignedTypedData, buildUnsignedTx } from "../../test/fixtures"; +import { API_BASE_URL, server } from "../../test/msw-server"; +import { + signAndSubmitEvmTransaction, + signAndSubmitSubstrateTransaction, + signMultipleTypedData +} from "../../services/transactions/userSigning"; +import { RampExecutionInput, RampState } from "../../types/phases"; +import { RampContext, RampMachineActor } from "../types"; +import { SignRampError, SignRampErrorType, signTransactionsActor } from "./sign.actor"; + +const USER_ADDRESS = "0x1111111111111111111111111111111111111111"; +const OTHER_ADDRESS = "0x9999999999999999999999999999999999999999"; +// Well-known Alice dev account (ss58 format 42). +const ALICE_SUBSTRATE = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + +function buildRampState(unsignedTxs: UnsignedTx[]): RampState { + return { + quote: buildQuoteResponse(), + ramp: { ...buildRampProcess("initial"), unsignedTxs }, + requiredUserActionsCompleted: false, + signedTransactions: [], + userSigningMeta: undefined + }; +} + +function buildContext(unsignedTxs: UnsignedTx[], overrides: Partial = {}): RampContext { + return { + chainId: 1, + connectedWalletAddress: USER_ADDRESS, + rampState: buildRampState(unsignedTxs), + ...overrides + } as RampContext; +} + +function buildParent() { + const events: Array<{ type: string } & Record> = []; + const parent = { send: (event: { type: string }) => events.push(event) } as unknown as RampMachineActor; + return { events, parent }; +} + +// Serves POST /ramp/update and records the request bodies. +function mockUpdateEndpoint() { + const calls: UpdateRampRequest[] = []; + server.use( + http.post(`${API_BASE_URL}/ramp/update`, async ({ request }) => { + calls.push((await request.json()) as UpdateRampRequest); + return HttpResponse.json(buildRampProcess("initial", { id: "ramp-updated" })); + }) + ); + return calls; +} + +async function runActor(context: RampContext) { + const { events, parent } = buildParent(); + const result = await signTransactionsActor({ input: { context, parent } }); + return { events, result }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("signTransactionsActor", () => { + describe("input validation", () => { + it.each([ + ["rampState", { rampState: undefined }], + ["connectedWalletAddress", { connectedWalletAddress: undefined }], + ["chainId", { chainId: undefined }] + ])("throws an InvalidInput error when %s is missing", async (_field, override) => { + const context = buildContext([buildUnsignedTx()], override as Partial); + const { parent } = buildParent(); + + await expect(signTransactionsActor({ input: { context, parent } })).rejects.toMatchObject({ + type: SignRampErrorType.InvalidInput + }); + }); + }); + + describe("transaction filtering", () => { + it("returns the ramp state untouched when no transaction is signed by the connected wallet", async () => { + const updateCalls = mockUpdateEndpoint(); + const context = buildContext([buildUnsignedTx({ signer: OTHER_ADDRESS })]); + + const { result } = await runActor(context); + + expect(result).toBe(context.rampState); + expect(signAndSubmitEvmTransaction).not.toHaveBeenCalled(); + expect(updateCalls).toHaveLength(0); + }); + + it("signs only the user-wallet transactions of a SELL ramp, never the ephemeral's phases", async () => { + const updateCalls = mockUpdateEndpoint(); + vi.mocked(signAndSubmitEvmTransaction).mockResolvedValue("0xtransferhash"); + // A registered offramp carries both kinds: the user's source-of-funds + // transfer plus the ephemeral's presign blueprints (which would throw as + // "unknown phase" if they ever reached the user signing loop). + const context = buildContext([ + buildUnsignedTx({ nonce: 0, phase: "squidRouterNoPermitTransfer", signer: USER_ADDRESS }), + buildUnsignedTx({ nonce: 1, phase: "nablaApprove", signer: OTHER_ADDRESS }), + buildUnsignedTx({ nonce: 2, phase: "nablaSwap", signer: OTHER_ADDRESS }), + buildUnsignedTx({ nonce: 3, phase: "brlaPayoutOnBase", signer: OTHER_ADDRESS }) + ]); + + const { result } = await runActor(context); + + expect(signAndSubmitEvmTransaction).toHaveBeenCalledTimes(1); + expect(vi.mocked(signAndSubmitEvmTransaction).mock.calls[0][0].phase).toBe("squidRouterNoPermitTransfer"); + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].additionalData).toMatchObject({ squidRouterNoPermitTransferHash: "0xtransferhash" }); + expect(result.userSigningMeta).toBeDefined(); + }); + + it("matches EVM signers case-insensitively", async () => { + mockUpdateEndpoint(); + vi.mocked(signAndSubmitEvmTransaction).mockResolvedValue("0xhash"); + const context = buildContext([buildUnsignedTx({ signer: USER_ADDRESS.toUpperCase().replace("0X", "0x") })]); + + await runActor(context); + + expect(signAndSubmitEvmTransaction).toHaveBeenCalledTimes(1); + }); + + it("matches substrate signers across ss58 formats when on a substrate chain", async () => { + mockUpdateEndpoint(); + vi.mocked(signAndSubmitSubstrateTransaction).mockResolvedValue("0xsubstratehash"); + const walletAccount = { address: ALICE_SUBSTRATE } as WalletAccount; + // Signer encoded with the Polkadot ss58 prefix, wallet connected with the generic prefix. + const context = buildContext( + [buildUnsignedTx({ network: Networks.AssetHub, phase: "assethubToPendulum", signer: getAddressForFormat(ALICE_SUBSTRATE, 0), txData: "0xdeadbeef" })], + { chainId: -1, connectedWalletAddress: ALICE_SUBSTRATE, substrateWalletAccount: walletAccount } + ); + + await runActor(context); + + expect(signAndSubmitSubstrateTransaction).toHaveBeenCalledTimes(1); + }); + }); + + describe("EVM signing", () => { + it("signs squidRouter transactions in nonce order, reports progress and stores the hashes", async () => { + const updateCalls = mockUpdateEndpoint(); + vi.mocked(signAndSubmitEvmTransaction).mockResolvedValueOnce("0xapprovehash").mockResolvedValueOnce("0xswaphash"); + const swapTx = buildUnsignedTx({ nonce: 2, phase: "squidRouterSwap" }); + const approveTx = buildUnsignedTx({ nonce: 1, phase: "squidRouterApprove" }); + // Deliberately out of order to prove nonce sorting. + const context = buildContext([swapTx, approveTx]); + + const { events, result } = await runActor(context); + + expect(vi.mocked(signAndSubmitEvmTransaction).mock.calls.map(call => call[0].phase)).toEqual([ + "squidRouterApprove", + "squidRouterSwap" + ]); + expect(events).toEqual([ + { current: 1, max: 2, phase: "started", type: "SIGNING_UPDATE" }, + { current: 1, max: 2, phase: "signed", type: "SIGNING_UPDATE" }, + { current: 2, max: 2, phase: "finished", type: "SIGNING_UPDATE" } + ]); + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].additionalData).toMatchObject({ + squidRouterApproveHash: "0xapprovehash", + squidRouterSwapHash: "0xswaphash" + }); + expect(result.ramp?.id).toBe("ramp-updated"); + expect(result.userSigningMeta).toEqual({ + assethubToPendulumHash: undefined, + squidRouterApproveHash: "0xapprovehash", + squidRouterSwapHash: "0xswaphash" + }); + }); + + it("signs the no-permit squidRouter transaction sequence", async () => { + const updateCalls = mockUpdateEndpoint(); + vi.mocked(signAndSubmitEvmTransaction) + .mockResolvedValueOnce("0xtransferhash") + .mockResolvedValueOnce("0xnopermitapprovehash") + .mockResolvedValueOnce("0xnopermitswaphash"); + const context = buildContext([ + buildUnsignedTx({ nonce: 1, phase: "squidRouterNoPermitTransfer" }), + buildUnsignedTx({ nonce: 2, phase: "squidRouterNoPermitApprove" }), + buildUnsignedTx({ nonce: 3, phase: "squidRouterNoPermitSwap" }) + ]); + + const { events } = await runActor(context); + + expect(signAndSubmitEvmTransaction).toHaveBeenCalledTimes(3); + expect(events.map(event => event.phase)).toEqual(["started", "finished", "started", "signed", "finished"]); + expect(updateCalls[0].additionalData).toMatchObject({ + squidRouterNoPermitApproveHash: "0xnopermitapprovehash", + squidRouterNoPermitSwapHash: "0xnopermitswaphash", + squidRouterNoPermitTransferHash: "0xtransferhash" + }); + }); + + it("skips the squidRouterApprove step for native token transfers and reports the login phase", async () => { + const updateCalls = mockUpdateEndpoint(); + const context = buildContext([buildUnsignedTx({ phase: "squidRouterApprove" })], { + executionInput: { network: Networks.Ethereum, onChainToken: EvmToken.ETH } as RampExecutionInput + }); + + const { events } = await runActor(context); + + expect(signAndSubmitEvmTransaction).not.toHaveBeenCalled(); + expect(events).toEqual([{ current: 1, max: 1, phase: "login", type: "SIGNING_UPDATE" }]); + expect(updateCalls).toHaveLength(1); + }); + }); + + describe("typed-data signing", () => { + it("signs a single typed-data payload, replaces the txData and submits it as a presigned tx", async () => { + const updateCalls = mockUpdateEndpoint(); + const typedData = buildSignedTypedData(); + const signedTypedData = buildSignedTypedData({ signature: { deadline: 1, r: "0x01", s: "0x02", v: 27 } }); + vi.mocked(signMultipleTypedData).mockResolvedValue([signedTypedData]); + const context = buildContext([buildUnsignedTx({ phase: "squidRouterPermitExecute", txData: typedData })]); + + const { events } = await runActor(context); + + expect(signMultipleTypedData).toHaveBeenCalledWith([typedData]); + expect(events.map(event => event.phase)).toEqual(["started", "signed"]); + expect(updateCalls[0].presignedTxs).toHaveLength(1); + expect(updateCalls[0].presignedTxs[0].txData).toEqual(signedTypedData); + }); + + it("signs a typed-data array payload in one batch", async () => { + const updateCalls = mockUpdateEndpoint(); + const typedDataArray = [buildSignedTypedData(), buildSignedTypedData({ primaryType: "OrderWitness" })]; + const signedArray = typedDataArray.map(data => + buildSignedTypedData({ ...data, signature: { deadline: 1, r: "0x01", s: "0x02", v: 27 } }) + ); + vi.mocked(signMultipleTypedData).mockResolvedValue(signedArray); + const context = buildContext([buildUnsignedTx({ phase: "squidRouterPermitExecute", txData: typedDataArray })]); + + await runActor(context); + + expect(signMultipleTypedData).toHaveBeenCalledWith(typedDataArray); + expect(updateCalls[0].presignedTxs[0].txData).toEqual(signedArray); + }); + }); + + describe("substrate signing", () => { + it("routes assethubToPendulum transactions to the substrate signer with the wallet account", async () => { + const updateCalls = mockUpdateEndpoint(); + vi.mocked(signAndSubmitSubstrateTransaction).mockResolvedValue("0xassethubhash"); + const walletAccount = { address: ALICE_SUBSTRATE } as WalletAccount; + const tx = buildUnsignedTx({ + network: Networks.AssetHub, + phase: "assethubToPendulum", + signer: ALICE_SUBSTRATE, + txData: "0xdeadbeef" + }); + const context = buildContext([tx], { + chainId: -1, + connectedWalletAddress: ALICE_SUBSTRATE, + substrateWalletAccount: walletAccount + }); + + const { events, result } = await runActor(context); + + expect(signAndSubmitSubstrateTransaction).toHaveBeenCalledWith(tx, walletAccount); + expect(signAndSubmitEvmTransaction).not.toHaveBeenCalled(); + expect(events.map(event => event.phase)).toEqual(["started", "finished"]); + expect(updateCalls[0].additionalData).toMatchObject({ assethubToPendulumHash: "0xassethubhash" }); + expect(result.userSigningMeta?.assethubToPendulumHash).toBe("0xassethubhash"); + }); + + it("fails with an UnknownError when no substrate wallet account is connected", async () => { + const tx = buildUnsignedTx({ + network: Networks.AssetHub, + phase: "assethubToPendulum", + signer: ALICE_SUBSTRATE, + txData: "0xdeadbeef" + }); + const context = buildContext([tx], { chainId: -1, connectedWalletAddress: ALICE_SUBSTRATE }); + const { parent } = buildParent(); + + await expect(signTransactionsActor({ input: { context, parent } })).rejects.toMatchObject({ + type: SignRampErrorType.UnknownError + }); + expect(signAndSubmitSubstrateTransaction).not.toHaveBeenCalled(); + }); + }); + + describe("error propagation", () => { + it("maps a wallet rejection to a UserRejected error", async () => { + vi.mocked(signAndSubmitEvmTransaction).mockRejectedValue(new Error("User rejected the request.")); + const context = buildContext([buildUnsignedTx()]); + const { parent } = buildParent(); + + const promise = signTransactionsActor({ input: { context, parent } }); + await expect(promise).rejects.toBeInstanceOf(SignRampError); + await expect(promise).rejects.toMatchObject({ + message: "User rejected the signature request.", + type: SignRampErrorType.UserRejected + }); + }); + + it("maps any other signing failure to an UnknownError", async () => { + vi.mocked(signAndSubmitEvmTransaction).mockRejectedValue(new Error("insufficient funds for gas")); + const context = buildContext([buildUnsignedTx()]); + const { parent } = buildParent(); + + await expect(signTransactionsActor({ input: { context, parent } })).rejects.toMatchObject({ + message: "Error signing transaction", + type: SignRampErrorType.UnknownError + }); + }); + + it("rejects transactions with an unexpected phase", async () => { + const context = buildContext([buildUnsignedTx({ phase: "nablaSwap" })]); + const { parent } = buildParent(); + + await expect(signTransactionsActor({ input: { context, parent } })).rejects.toMatchObject({ + type: SignRampErrorType.UnknownError + }); + expect(signAndSubmitEvmTransaction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/machines/actors/sign.actor.ts b/apps/frontend/src/machines/actors/sign.actor.ts index f83df4b6b..cf03a2882 100644 --- a/apps/frontend/src/machines/actors/sign.actor.ts +++ b/apps/frontend/src/machines/actors/sign.actor.ts @@ -7,6 +7,7 @@ import { PresignedTx } from "@vortexfi/shared"; import { RampService } from "../../services/api"; +import { type DomainError, SentryDomain } from "../../services/api/api-client"; import { signAndSubmitEvmTransaction, signAndSubmitSubstrateTransaction, @@ -19,8 +20,10 @@ export enum SignRampErrorType { UserRejected = "USER_REJECTED", UnknownError = "UNKNOWN_ERROR" } -export class SignRampError extends Error { +export class SignRampError extends Error implements DomainError { type: SignRampErrorType; + // Tagged as the wallet business area in Sentry's beforeSend. + domain = SentryDomain.Wallet; constructor(message: string, type: SignRampErrorType) { super(message); this.type = type; diff --git a/apps/frontend/src/machines/actors/start.actor.test.ts b/apps/frontend/src/machines/actors/start.actor.test.ts new file mode 100644 index 000000000..f5d6b7257 --- /dev/null +++ b/apps/frontend/src/machines/actors/start.actor.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { StartRampRequest } from "@vortexfi/shared"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { ApiError } from "../../services/api/api-client"; +import { buildQuoteResponse, buildRampProcess } from "../../test/fixtures"; +import { API_BASE_URL, server } from "../../test/msw-server"; +import { RampContext } from "../types"; +import { startRampActor } from "./start.actor"; + +function buildContext(overrides: Partial = {}): RampContext { + return { + rampState: { + quote: buildQuoteResponse(), + ramp: buildRampProcess("initial", { id: "ramp-123" }), + requiredUserActionsCompleted: true, + signedTransactions: [], + userSigningMeta: undefined + }, + ...overrides + } as RampContext; +} + +describe("startRampActor", () => { + it("starts the ramp and returns the updated process", async () => { + const startCalls: StartRampRequest[] = []; + server.use( + http.post(`${API_BASE_URL}/ramp/start`, async ({ request }) => { + startCalls.push((await request.json()) as StartRampRequest); + return HttpResponse.json(buildRampProcess("squidRouterApprove", { id: "ramp-123" })); + }) + ); + + const result = await startRampActor({ input: buildContext() }); + + expect(startCalls).toEqual([{ rampId: "ramp-123" }]); + expect(result.currentPhase).toBe("squidRouterApprove"); + }); + + it.each([ + ["rampState", { rampState: undefined }], + ["ramp process", { rampState: { ramp: undefined } as RampContext["rampState"] }] + ])("throws when the %s is missing", async (_field, override) => { + await expect(startRampActor({ input: buildContext(override as Partial) })).rejects.toThrow( + "Ramp state or ramp process not found." + ); + }); + + it("propagates an API failure", async () => { + server.use(http.post(`${API_BASE_URL}/ramp/start`, () => HttpResponse.json({ error: "Ramp not ready" }, { status: 400 }))); + + const promise = startRampActor({ input: buildContext() }); + await expect(promise).rejects.toBeInstanceOf(ApiError); + await expect(promise).rejects.toMatchObject({ message: "Ramp not ready", status: 400 }); + }); +}); diff --git a/apps/frontend/src/machines/actors/validateKyc.actor.test.ts b/apps/frontend/src/machines/actors/validateKyc.actor.test.ts new file mode 100644 index 000000000..b8d1d8918 --- /dev/null +++ b/apps/frontend/src/machines/actors/validateKyc.actor.test.ts @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +import { FiatToken, RampDirection } from "@vortexfi/shared"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it, vi } from "vitest"; +import { buildQuoteResponse } from "../../test/fixtures"; +import { API_BASE_URL, server } from "../../test/msw-server"; +import { RampContext } from "../types"; +import { RampLimitExceededError, validateKycActor } from "./validateKyc.actor"; + +// Structurally valid, non-trivial CPF (11 digits). +const VALID_CPF = "52998224725"; +const INVALID_TAX_ID = "123"; + +function buildContext(fiatToken: FiatToken, overrides: Partial = {}): RampContext { + return { + executionInput: { + fiatToken, + quote: buildQuoteResponse({ outputAmount: "100", rampType: RampDirection.SELL }), + taxId: fiatToken === FiatToken.BRL ? VALID_CPF : undefined + }, + externalSessionId: "session-1", + quoteId: "quote-1", + rampDirection: RampDirection.SELL, + ...overrides + } as RampContext; +} + +function mockBrlaUser(user: { evmAddress: string; identityStatus: string }, remainingLimit: string) { + server.use( + http.get(`${API_BASE_URL}/brla/getUser`, () => HttpResponse.json({ ...user, subAccountId: "sub-1" })), + http.get(`${API_BASE_URL}/brla/getUserRemainingLimit`, () => HttpResponse.json({ remainingLimit })) + ); +} + +// Serves POST /brla/kyc/record-attempt and records the request bodies. +function mockRecordAttemptEndpoint() { + const calls: unknown[] = []; + server.use( + http.post(`${API_BASE_URL}/brla/kyc/record-attempt`, async ({ request }) => { + calls.push(await request.json()); + return HttpResponse.json({}); + }) + ); + return calls; +} + +describe("validateKycActor", () => { + it.each([ + ["executionInput", { executionInput: undefined }], + ["rampDirection", { rampDirection: undefined }], + ["quoteId", { quoteId: undefined }] + ])("throws when %s is missing", async (_field, override) => { + await expect(validateKycActor({ input: buildContext(FiatToken.EURC, override as Partial) })).rejects.toThrow( + /missing from ramp context/ + ); + }); + + it.each([ + ["EURC (Mykobo)", FiatToken.EURC], + ["ARS (Alfredpay)", FiatToken.ARS], + ["MXN (Alfredpay)", FiatToken.MXN], + ["USD (Alfredpay)", FiatToken.USD], + ["COP (Alfredpay)", FiatToken.COP] + ])("requires KYC for %s without calling the BRLA API", async (_label, fiatToken) => { + const result = await validateKycActor({ input: buildContext(fiatToken) }); + expect(result).toEqual({ kycNeeded: true }); + }); + + describe("BRL (Avenia)", () => { + it("throws when the tax ID is missing", async () => { + const context = buildContext(FiatToken.BRL); + (context.executionInput as { taxId?: string }).taxId = undefined; + + await expect(validateKycActor({ input: context })).rejects.toThrow( + "Tax ID must exist when validating KYC for BRL transactions" + ); + }); + + it("skips KYC for a confirmed user within their remaining limit", async () => { + mockBrlaUser({ evmAddress: "0xbrla", identityStatus: "CONFIRMED" }, "1000"); + + const result = await validateKycActor({ input: buildContext(FiatToken.BRL) }); + + expect(result).toEqual({ brlaEvmAddress: "0xbrla", kycNeeded: false }); + }); + + it("requires KYC when the user exists but their identity is not confirmed", async () => { + mockBrlaUser({ evmAddress: "0xbrla", identityStatus: "PENDING" }, "1000"); + + const result = await validateKycActor({ input: buildContext(FiatToken.BRL) }); + + expect(result).toEqual({ brlaEvmAddress: "0xbrla", kycNeeded: true }); + }); + + it("requires KYC and records the attempt when the user does not exist yet", async () => { + server.use(http.get(`${API_BASE_URL}/brla/getUser`, () => HttpResponse.json({ error: "Not found" }, { status: 404 }))); + const recordCalls = mockRecordAttemptEndpoint(); + + const result = await validateKycActor({ input: buildContext(FiatToken.BRL) }); + + expect(result).toEqual({ kycNeeded: true }); + // recordInitialKycAttempt is fire-and-forget; wait for the request to land. + await vi.waitFor(() => expect(recordCalls).toHaveLength(1)); + expect(recordCalls[0]).toEqual({ quoteId: "quote-1", sessionId: "session-1", taxId: VALID_CPF }); + }); + + it("throws a RampLimitExceededError when the amount exceeds the remaining limit, without recording a KYC attempt", async () => { + mockBrlaUser({ evmAddress: "0xbrla", identityStatus: "CONFIRMED" }, "10"); + const recordCalls = mockRecordAttemptEndpoint(); + + await expect(validateKycActor({ input: buildContext(FiatToken.BRL) })).rejects.toThrow(RampLimitExceededError); + + // An exceeded limit is not a KYC problem; give any stray fire-and-forget request a beat to land. + await new Promise(resolve => setTimeout(resolve, 25)); + expect(recordCalls).toHaveLength(0); + }); + + it("requires KYC re-verification when the API reports KYC invalid, without recording an initial attempt", async () => { + server.use( + http.get(`${API_BASE_URL}/brla/getUser`, () => + HttpResponse.json({ evmAddress: "0xbrla", identityStatus: "REJECTED", subAccountId: "sub-1" }) + ), + http.get(`${API_BASE_URL}/brla/getUserRemainingLimit`, () => + HttpResponse.json({ error: "KYC invalid" }, { status: 400 }) + ) + ); + const recordCalls = mockRecordAttemptEndpoint(); + + const result = await validateKycActor({ input: buildContext(FiatToken.BRL) }); + + expect(result).toEqual({ kycNeeded: true }); + // The user exists (valid CPF), so a wrong branch order would fire recordInitialKycAttempt; + // it is fire-and-forget, so give any stray request a beat to land before asserting absence. + await new Promise(resolve => setTimeout(resolve, 25)); + expect(recordCalls).toHaveLength(0); + }); + + it("checks the input amount against the limit for BUY ramps", async () => { + mockBrlaUser({ evmAddress: "0xbrla", identityStatus: "CONFIRMED" }, "200"); + const context = buildContext(FiatToken.BRL, { rampDirection: RampDirection.BUY }); + // inputAmount 150 (fixture default) <= 200, while outputAmount would also pass; the + // direction routing is covered by the SELL over-limit test above using outputAmount. + const result = await validateKycActor({ input: context }); + + expect(result).toEqual({ brlaEvmAddress: "0xbrla", kycNeeded: false }); + }); + + it("rethrows a user lookup failure when the tax ID is not a valid CPF or CNPJ", async () => { + server.use( + http.get(`${API_BASE_URL}/brla/getUser`, () => HttpResponse.json({ error: "Internal error" }, { status: 500 })) + ); + const context = buildContext(FiatToken.BRL); + (context.executionInput as { taxId?: string }).taxId = INVALID_TAX_ID; + + await expect(validateKycActor({ input: context })).rejects.toMatchObject({ status: 500 }); + }); + }); +}); diff --git a/apps/frontend/src/machines/actors/validateKyc.actor.ts b/apps/frontend/src/machines/actors/validateKyc.actor.ts index 547c293cf..f542e51b0 100644 --- a/apps/frontend/src/machines/actors/validateKyc.actor.ts +++ b/apps/frontend/src/machines/actors/validateKyc.actor.ts @@ -1,6 +1,6 @@ -import { BrlaErrorResponse, FiatToken, isAlfredpayToken, isValidCnpj, isValidCpf, RampDirection } from "@vortexfi/shared"; +import { FiatToken, isAlfredpayToken, isValidCnpj, isValidCpf, RampDirection } from "@vortexfi/shared"; -import { BrlaService } from "../../services/api"; +import { BrlaService, isApiError } from "../../services/api"; import { RampContext } from "../types"; interface ValidateKycResult { @@ -8,6 +8,13 @@ interface ValidateKycResult { brlaEvmAddress?: string; } +export class RampLimitExceededError extends Error { + constructor() { + super("Insufficient remaining limit for this transaction."); + this.name = "RampLimitExceededError"; + } +} + export const validateKycActor = async ({ input }: { input: RampContext }): Promise => { const { executionInput, rampDirection, quoteId, externalSessionId } = input; console.log("Validating KYC with input:", input); @@ -51,9 +58,8 @@ export const validateKycActor = async ({ input }: { input: RampContext }): Promi const remainingLimitNum = Number(remainingLimitResponse.remainingLimit); if (amountNum > remainingLimitNum) { - // Avenia-Migration: this must be changed. No more levels. TOAST? - // We don't know of a possibility to increase limits so far. - throw new Error("Insufficient remaining limit for this transaction."); + // We don't know of a possibility to increase Avenia limits so far. + throw new RampLimitExceededError(); } // Only skip KYC if identity is confirmed - handles case where user created subaccount but didn't complete KYC @@ -64,15 +70,24 @@ export const validateKycActor = async ({ input }: { input: RampContext }): Promi return { brlaEvmAddress, kycNeeded: false }; } catch (err) { - const errorResponse = err as BrlaErrorResponse; + // An exceeded limit is not a KYC problem — let it reach the machine's error path + // instead of falling into the valid-CPF "needs KYC" branch below. + if (err instanceof RampLimitExceededError) { + throw err; + } + + // "KYC invalid" comes from the remaining-limit endpoint for an existing user whose + // identity check failed, so it must win over the valid-CPF "user doesn't exist yet" + // branch below — that one would wrongly record an initial KYC attempt. + if (isApiError(err) && err.data.error?.includes("KYC invalid")) { + console.log("User KYC is invalid. Needs KYC."); + return { kycNeeded: true }; + } if (isValidCpf(taxId) || isValidCnpj(taxId)) { console.log("User doesn't exist yet. Needs KYC."); BrlaService.recordInitialKycAttempt(taxId, quoteId, externalSessionId); return { kycNeeded: true }; - } else if (errorResponse.error?.includes("KYC invalid")) { - console.log("User KYC is invalid. Needs KYC."); - return { kycNeeded: true }; } console.error("Error while fetching BRLA user in KYC check", err); throw err; diff --git a/apps/frontend/src/machines/alfredpayKyc.machine.test.ts b/apps/frontend/src/machines/alfredpayKyc.machine.test.ts new file mode 100644 index 000000000..4fc0d6e46 --- /dev/null +++ b/apps/frontend/src/machines/alfredpayKyc.machine.test.ts @@ -0,0 +1,433 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createActor, fromPromise, waitFor } from "xstate"; + +// The real module instantiates a Supabase client at import time, which fails in a node test environment. +vi.mock("../services/auth", () => ({ + AuthService: { + getTokens: vi.fn(), + refreshAccessToken: vi.fn(), + storeTokens: vi.fn() + } +})); + +import { + AlfredPayStatus, + AlfredpayCreateCustomerResponse, + AlfredpayGetKycRedirectLinkResponse, + AlfredpayGetKycStatusResponse, + AlfredpayKybCustomerAndBusiness, + AlfredpayStatusResponse, + SubmitKybInformationResponse, + SubmitKycInformationResponse +} from "@vortexfi/shared"; +import { + AlfredpayKycFormData, + AlfredpayKycMachineErrorType, + alfredpayKycMachine, + KybBusinessFiles, + KybFormData, + MxnKycFiles +} from "./alfredpayKyc.machine"; +import { AlfredpayKycContext } from "./kyc.states"; +import { initialRampContext } from "./ramp.context"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +// XState actor logic is invariant in its output type, so fakes must declare the exact output of the real actor. +type SubmitFilesInput = AlfredpayKycContext & { mxnFormData?: AlfredpayKycFormData; mxnFiles?: MxnKycFiles }; +type KybFilesInput = AlfredpayKycContext & { kybBusinessFiles?: KybBusinessFiles }; + +const notifyActor = () => fromPromise<{ success: boolean }, AlfredpayKycContext>(async () => ({ success: true })); + +const statusOf = (status: AlfredPayStatus) => ({ status }) as AlfredpayStatusResponse; +const kycStatusOf = (status: AlfredPayStatus, lastFailure?: string) => + ({ lastFailure, status }) as AlfredpayGetKycStatusResponse; + +const baseInput: AlfredpayKycContext = { + ...initialRampContext, + country: "US" +}; + +const kycLink: AlfredpayGetKycRedirectLinkResponse = { + submissionId: "link-sub-1", + verification_url: "https://verify.alfred.example" +}; + +const mxnFormData = { firstName: "Frida" } as unknown as AlfredpayKycFormData; +const mxnFiles = { back: {} as File, front: {} as File } as MxnKycFiles; +const kybFormData = { businessName: "ACME" } as unknown as KybFormData; +const kybBusinessFiles = { + articlesIncorporation: {} as File, + docBack: {} as File, + docFront: {} as File, + proofAddress: {} as File, + taxIdDocument: {} as File +} as KybBusinessFiles; + +function createTestActor( + actors: Parameters[0]["actors"], + input: AlfredpayKycContext = baseInput +) { + return createActor(alfredpayKycMachine.provide({ actors }), { input }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("alfredpayKycMachine", () => { + describe("CheckingStatus routing", () => { + it("finishes immediately when the status is already Success", async () => { + const actor = createTestActor({ + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Success)) + }); + actor.start(); + + await waitFor(actor, s => s.status === "done"); + expect(actor.getSnapshot().value).toBe("Done"); + expect(actor.getSnapshot().output?.error).toBeUndefined(); + }); + + it("polls a Verifying status through to VerificationDone and Done", async () => { + const poll = deferred(); + const actor = createTestActor({ + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Verifying)), + pollStatus: fromPromise(() => poll.promise) + }); + actor.start(); + + await waitFor(actor, s => s.matches("PollingStatus")); + + poll.resolve(kycStatusOf(AlfredPayStatus.Success)); + await waitFor(actor, s => s.matches("VerificationDone")); + + actor.send({ type: "CONFIRM_SUCCESS" }); + expect(actor.getSnapshot().value).toBe("Done"); + expect(actor.getSnapshot().status).toBe("done"); + }); + + it("a Failed status lands in FailureKyc and USER_CANCEL finishes with the error", async () => { + const actor = createTestActor({ + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Failed)) + }); + actor.start(); + + await waitFor(actor, s => s.matches("FailureKyc")); + expect(actor.getSnapshot().context.error?.message).toBe("KYC Failed"); + expect(actor.getSnapshot().context.error?.type).toBe(AlfredpayKycMachineErrorType.UnknownError); + + actor.send({ type: "USER_CANCEL" }); + const snapshot = actor.getSnapshot(); + expect(snapshot.value).toBe("Done"); + expect(snapshot.output?.error?.message).toBe("KYC Failed"); + }); + + it("USER_RETRY from FailureKyc retries and returns to the link flow for iFrame countries", async () => { + const actor = createTestActor({ + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Failed)), + getKycLink: fromPromise(() => new Promise(() => {})), + retryKyc: fromPromise(async () => kycLink) + }); + actor.start(); + await waitFor(actor, s => s.matches("FailureKyc")); + + actor.send({ type: "USER_RETRY" }); + expect(actor.getSnapshot().value).toBe("Retrying"); + await waitFor(actor, s => s.matches("GettingKycLink")); + }); + + it("a 404 (no customer) routes to CustomerDefinition where the customer type can be toggled", async () => { + const actor = createTestActor({ + checkStatus: fromPromise(async () => { + throw new Error("Request failed with status 404"); + }), + createCustomer: fromPromise(async () => ({}) as AlfredpayCreateCustomerResponse), + getKycLink: fromPromise(() => new Promise(() => {})) + }); + actor.start(); + + await waitFor(actor, s => s.matches("CustomerDefinition")); + + actor.send({ type: "TOGGLE_BUSINESS" }); + expect(actor.getSnapshot().context.business).toBe(true); + actor.send({ type: "TOGGLE_BUSINESS" }); + expect(actor.getSnapshot().context.business).toBe(false); + + actor.send({ type: "USER_ACCEPT" }); + expect(actor.getSnapshot().value).toBe("CreatingCustomer"); + // Country US is an iFrame country, so a fresh customer goes to the redirect-link flow. + await waitFor(actor, s => s.matches("GettingKycLink")); + }); + + it("a non-404 status check error lands in Failure and RETRY_PROCESS re-runs the check", async () => { + let calls = 0; + const actor = createTestActor({ + checkStatus: fromPromise(() => { + calls += 1; + if (calls === 1) return Promise.reject(new Error("service unavailable")); + return new Promise(() => {}); + }) + }); + actor.start(); + + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toBe("service unavailable"); + + actor.send({ type: "RETRY_PROCESS" }); + expect(actor.getSnapshot().value).toBe("CheckingStatus"); + expect(calls).toBe(2); + }); + + it("CANCEL_PROCESS from Failure finishes the machine with the error in the output", async () => { + const actor = createTestActor({ + checkStatus: fromPromise(async () => { + throw new Error("service unavailable"); + }) + }); + actor.start(); + await waitFor(actor, s => s.matches("Failure")); + + actor.send({ type: "CANCEL_PROCESS" }); + expect(actor.getSnapshot().value).toBe("Done"); + expect(actor.getSnapshot().output?.error?.message).toBe("service unavailable"); + }); + }); + + describe("iFrame link flow (US)", () => { + it("gets a link, opens it, and reports a failed verification with the lastFailure message", async () => { + const openSpy = vi.fn(); + vi.stubGlobal("window", { open: openSpy }); + + const poll = deferred(); + const actor = createTestActor({ + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + getKycLink: fromPromise(async () => kycLink), + notifyFinished: notifyActor(), + notifyOpened: notifyActor(), + pollStatus: fromPromise(() => poll.promise), + waitForValidation: fromPromise(() => new Promise(() => {})) + }); + actor.start(); + + await waitFor(actor, s => s.matches("LinkReady")); + expect(actor.getSnapshot().context.submissionId).toBe("link-sub-1"); + expect(actor.getSnapshot().context.verificationUrl).toBe("https://verify.alfred.example"); + + actor.send({ type: "OPEN_LINK" }); + expect(openSpy).toHaveBeenCalledWith("https://verify.alfred.example", "_blank"); + + await waitFor(actor, s => s.matches("FillingKyc")); + + actor.send({ type: "COMPLETED_FILLING" }); + expect(actor.getSnapshot().value).toBe("FinishingFilling"); + await waitFor(actor, s => s.matches("PollingStatus")); + + poll.resolve(kycStatusOf(AlfredPayStatus.Failed, "Document expired")); + await waitFor(actor, s => s.matches("FailureKyc")); + expect(actor.getSnapshot().context.error?.message).toBe("Document expired"); + }); + + it("background validation completing moves FillingKyc to PollingStatus without user action", async () => { + vi.stubGlobal("window", { open: vi.fn() }); + const validation = deferred(); + const actor = createTestActor({ + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + getKycLink: fromPromise(async () => kycLink), + notifyOpened: notifyActor(), + pollStatus: fromPromise(() => new Promise(() => {})), + waitForValidation: fromPromise(() => validation.promise) + }); + actor.start(); + + await waitFor(actor, s => s.matches("LinkReady")); + actor.send({ type: "OPEN_LINK" }); + await waitFor(actor, s => s.matches("FillingKyc")); + + validation.resolve(kycStatusOf(AlfredPayStatus.Verifying)); + await waitFor(actor, s => s.matches("PollingStatus")); + }); + + it("moves to Failure when fetching the KYC link fails", async () => { + const actor = createTestActor({ + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + getKycLink: fromPromise(async () => { + throw new Error("link service down"); + }) + }); + actor.start(); + + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toBe("Failed to get KYC link"); + }); + }); + + describe("API-based KYC form flow (MX)", () => { + const mxInput: AlfredpayKycContext = { ...baseInput, country: "MX" }; + + it("submits the form, uploads documents, and sends the submission through to polling", async () => { + const poll = deferred(); + const actor = createTestActor( + { + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + pollStatus: fromPromise(() => poll.promise), + sendSubmission: fromPromise(async () => undefined), + submitFiles: fromPromise(async () => undefined), + submitKycInfo: fromPromise(async () => ({ submissionId: "kyc-sub-9" }) as SubmitKycInformationResponse) + }, + mxInput + ); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKycForm")); + + actor.send({ data: mxnFormData, type: "SUBMIT_FORM" }); + expect(actor.getSnapshot().value).toBe("SubmittingKycInfo"); + expect(actor.getSnapshot().context.mxnFormData).toEqual(mxnFormData); + + await waitFor(actor, s => s.matches("UploadingDocuments")); + expect(actor.getSnapshot().context.submissionId).toBe("kyc-sub-9"); + + actor.send({ files: mxnFiles, type: "SUBMIT_FILES" }); + expect(actor.getSnapshot().value).toBe("SubmittingFiles"); + expect(actor.getSnapshot().context.mxnFiles).toBe(mxnFiles); + + await waitFor(actor, s => s.matches("PollingStatus")); + + poll.resolve(kycStatusOf(AlfredPayStatus.Success)); + await waitFor(actor, s => s.matches("VerificationDone")); + }); + + it("skips re-submitting KYC info when a submissionId already exists", async () => { + const actor = createTestActor( + { + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)) + }, + { ...mxInput, submissionId: "existing-sub" } + ); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKycForm")); + actor.send({ data: mxnFormData, type: "SUBMIT_FORM" }); + expect(actor.getSnapshot().value).toBe("UploadingDocuments"); + }); + + it("a failed document upload returns to UploadingDocuments with the error", async () => { + const actor = createTestActor( + { + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + submitFiles: fromPromise(async () => { + throw new Error("upload failed"); + }), + submitKycInfo: fromPromise(async () => ({ submissionId: "kyc-sub-9" }) as SubmitKycInformationResponse) + }, + mxInput + ); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKycForm")); + actor.send({ data: mxnFormData, type: "SUBMIT_FORM" }); + await waitFor(actor, s => s.matches("UploadingDocuments")); + + actor.send({ files: mxnFiles, type: "SUBMIT_FILES" }); + await waitFor(actor, s => s.matches("UploadingDocuments") && s.context.error !== undefined); + expect(actor.getSnapshot().context.error?.message).toBe("upload failed"); + }); + }); + + describe("KYB flow (MX business)", () => { + const kybInput: AlfredpayKycContext = { ...baseInput, business: true, country: "MX" }; + + it("submits KYB info, uploads documents, and sends the submission through to polling", async () => { + const actor = createTestActor( + { + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + findKybCustomerAndBusiness: fromPromise( + async () => [{ relatedPersons: [{ idRelatedPerson: "rp-1" }] }] as unknown as AlfredpayKybCustomerAndBusiness[] + ), + pollStatus: fromPromise(() => new Promise(() => {})), + sendKybSubmissionActor: fromPromise(async () => undefined), + submitKybBusinessFiles: fromPromise(async () => undefined), + submitKybInfo: fromPromise(async () => ({ submissionId: "kyb-sub-1" }) as SubmitKybInformationResponse), + submitKybRelatedPersonBundleFiles: fromPromise(async () => undefined) + }, + kybInput + ); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKybForm")); + + actor.send({ data: kybFormData, type: "SUBMIT_KYB_FORM" }); + expect(actor.getSnapshot().value).toBe("SubmittingKybInfo"); + expect(actor.getSnapshot().context.kybFormData).toEqual(kybFormData); + + await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); + expect(actor.getSnapshot().context.submissionId).toBe("kyb-sub-1"); + + actor.send({ files: kybBusinessFiles, type: "SUBMIT_KYB_BUSINESS_FILES" }); + await waitFor(actor, s => s.matches("PollingStatus")); + expect(actor.getSnapshot().context.kybRelatedPersonIds).toEqual(["rp-1"]); + }); + + it("fails when the KYB submission response has no submission id", async () => { + const actor = createTestActor( + { + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + submitKybInfo: fromPromise(async () => ({ submissionId: "" }) as SubmitKybInformationResponse) + }, + kybInput + ); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKybForm")); + actor.send({ data: kybFormData, type: "SUBMIT_KYB_FORM" }); + + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toContain("did not return a submission ID"); + }); + + it("fails when no related person ids can be extracted", async () => { + const actor = createTestActor( + { + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + findKybCustomerAndBusiness: fromPromise(async () => [] as AlfredpayKybCustomerAndBusiness[]), + submitKybBusinessFiles: fromPromise(async () => undefined), + submitKybInfo: fromPromise(async () => ({ submissionId: "kyb-sub-1" }) as SubmitKybInformationResponse) + }, + kybInput + ); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKybForm")); + actor.send({ data: kybFormData, type: "SUBMIT_KYB_FORM" }); + await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); + actor.send({ files: kybBusinessFiles, type: "SUBMIT_KYB_BUSINESS_FILES" }); + + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toContain("did not return relatedPersons[].idRelatedPerson"); + }); + + // NOTE: documents current behavior — for country AR with business=true, CheckingStatus routes to the + // *individual* KYC form (FillingKycForm) because its business guard only covers MX/CO + // (alfredpayKyc.machine.ts:388), while CreatingCustomer and Retrying include AR in their business + // guards (alfredpayKyc.machine.ts:434, :674). This inconsistency looks unintended. + it("AR business customers are routed to the individual KYC form from CheckingStatus", async () => { + const actor = createTestActor( + { + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)) + }, + { ...baseInput, business: true, country: "AR" } + ); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKycForm")); + }); + }); +}); diff --git a/apps/frontend/src/machines/brlaKyc.machine.test.ts b/apps/frontend/src/machines/brlaKyc.machine.test.ts new file mode 100644 index 000000000..0bf7007ee --- /dev/null +++ b/apps/frontend/src/machines/brlaKyc.machine.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it, vi } from "vitest"; +import { createActor, fromPromise, waitFor } from "xstate"; + +// The real module instantiates a Supabase client at import time, which fails in a node test environment. +vi.mock("../services/auth", () => ({ + AuthService: { + getTokens: vi.fn(), + refreshAccessToken: vi.fn(), + storeTokens: vi.fn() + } +})); + +import { BrlaGetKycStatusResponse, KycFailureReason } from "@vortexfi/shared"; +import { KYCFormData } from "../hooks/brla/useKYCForm"; +import { KybLevel1Response } from "../services/api"; +import { KycStatus, KycSubmissionRejectedError } from "../services/signingService"; +import { VerifyStatusActorOutput } from "./actors/brla/verifyLevel1Status.actor"; +import { AveniaKycMachineErrorType, aveniaKycMachine, UploadIds } from "./brlaKyc.machine"; +import { AveniaKycContext } from "./kyc.states"; +import { initialRampContext } from "./ramp.context"; +import { RampContext } from "./types"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +// Must match the exact output type of the real createSubaccountActor (actor logic is invariant in its output). +type SubaccountOutput = { + subAccountId: string; + maybeKycAttemptStatus?: BrlaGetKycStatusResponse; + isCompany: boolean; + kybUrls?: KybLevel1Response; +}; + +const baseInput: RampContext = { + ...initialRampContext, + connectedWalletAddress: "0x1111111111111111111111111111111111111111", + quoteId: "quote-1" +}; + +const formData = { taxId: "12345678900" } as KYCFormData; +const uploadIds: UploadIds = { + livenessUrl: "https://liveness.example", + uploadedDocumentId: "doc-1", + uploadedSelfieId: "selfie-1" +}; + +function createTestActor(actors: Parameters[0]["actors"]) { + return createActor(aveniaKycMachine.provide({ actors }), { input: baseInput }); +} + +function subaccountActorWith(output: SubaccountOutput) { + return fromPromise(async () => output); +} + +describe("aveniaKycMachine", () => { + it("walks the individual happy path from form filling to Finish", async () => { + const subaccount = deferred(); + const submit = deferred(); + const verify = deferred(); + const actor = createTestActor({ + createSubaccountActor: fromPromise(() => subaccount.promise), + submitActor: fromPromise(() => submit.promise), + verifyStatusActor: fromPromise(() => verify.promise) + }); + actor.start(); + + expect(actor.getSnapshot().value).toBe("FormFilling"); + + actor.send({ formData, type: "FORM_SUBMIT" }); + expect(actor.getSnapshot().value).toBe("SubaccountSetup"); + expect(actor.getSnapshot().context.taxId).toBe("12345678900"); + expect(actor.getSnapshot().context.kycFormData).toEqual(formData); + + subaccount.resolve({ isCompany: false, subAccountId: "sub-1" }); + await waitFor(actor, s => s.matches("DocumentUpload")); + expect(actor.getSnapshot().context.subAccountId).toBe("sub-1"); + expect(actor.getSnapshot().context.isCompany).toBe(false); + + actor.send({ documentsId: uploadIds, type: "DOCUMENTS_SUBMIT" }); + expect(actor.getSnapshot().value).toBe("LivenessCheck"); + expect(actor.getSnapshot().context.documentUploadIds).toEqual(uploadIds); + + // LIVENESS_DONE is guarded: it must be ignored until the liveness check was actually opened. + actor.send({ type: "LIVENESS_DONE" }); + expect(actor.getSnapshot().value).toBe("LivenessCheck"); + + actor.send({ type: "LIVENESS_OPENED" }); + expect(actor.getSnapshot().context.livenessCheckOpened).toBe(true); + actor.send({ type: "LIVENESS_DONE" }); + expect(actor.getSnapshot().value).toBe("Submit"); + expect(actor.getSnapshot().context.kycStatus).toBe(KycStatus.PENDING); + // Exiting LivenessCheck resets the opened flag. + expect(actor.getSnapshot().context.livenessCheckOpened).toBe(false); + + submit.resolve(undefined); + await waitFor(actor, s => s.matches("Verifying")); + + verify.resolve({ type: "APPROVED" }); + await waitFor(actor, s => s.matches("Success")); + expect(actor.getSnapshot().context.kycStatus).toBe(KycStatus.APPROVED); + + actor.send({ type: "CLOSE_SUCCESS_MODAL" }); + const snapshot = actor.getSnapshot(); + expect(snapshot.value).toBe("Finish"); + expect(snapshot.status).toBe("done"); + expect(snapshot.output?.kycStatus).toBe(KycStatus.APPROVED); + expect(snapshot.output?.error).toBeUndefined(); + }); + + it("routes companies with KYB URLs into the KYB flow and through its steps", async () => { + const actor = createTestActor({ + createSubaccountActor: subaccountActorWith({ + isCompany: true, + kybUrls: { + attemptId: "attempt-1", + authorizedRepresentativeUrl: "https://rep.example", + basicCompanyDataUrl: "https://company.example" + }, + subAccountId: "sub-2" + }) + }); + actor.start(); + actor.send({ formData: { taxId: "12345678000199" } as KYCFormData, type: "FORM_SUBMIT" }); + + await waitFor(actor, s => s.matches({ KYBFlow: "CompanyVerification" })); + let context = actor.getSnapshot().context; + expect(context.kybStep).toBe("company"); + expect(context.kybAttemptId).toBe("attempt-1"); + expect(context.kybUrls).toEqual({ + authorizedRepresentativeUrl: "https://rep.example", + basicCompanyDataUrl: "https://company.example" + }); + + actor.send({ type: "COMPANY_VERIFICATION_STARTED" }); + expect(actor.getSnapshot().context.companyVerificationStarted).toBe(true); + + actor.send({ type: "KYB_COMPANY_DONE" }); + expect(actor.getSnapshot().value).toEqual({ KYBFlow: "RepresentativeVerification" }); + expect(actor.getSnapshot().context.kybStep).toBe("representative"); + + actor.send({ type: "KYB_COMPANY_BACK" }); + expect(actor.getSnapshot().value).toEqual({ KYBFlow: "CompanyVerification" }); + actor.send({ type: "KYB_COMPANY_DONE" }); + + actor.send({ type: "REPRESENTATIVE_VERIFICATION_STARTED" }); + expect(actor.getSnapshot().context.representativeVerificationStarted).toBe(true); + + actor.send({ type: "KYB_REPRESENTATIVE_DONE" }); + context = actor.getSnapshot().context; + expect(actor.getSnapshot().value).toEqual({ KYBFlow: "StatusVerification" }); + expect(context.kybStep).toBe("verification"); + expect(context.kycStatus).toBe(KycStatus.PENDING); + + actor.send({ type: "KYB_COMPLETE" }); + expect(actor.getSnapshot().value).toBe("Success"); + }); + + it("resumes into Verifying when a KYC attempt is already in progress", async () => { + const actor = createTestActor({ + createSubaccountActor: subaccountActorWith({ + isCompany: false, + maybeKycAttemptStatus: { status: "PROCESSING" } as unknown as BrlaGetKycStatusResponse, + subAccountId: "sub-3" + }), + verifyStatusActor: fromPromise(() => new Promise(() => {})) + }); + actor.start(); + actor.send({ formData, type: "FORM_SUBMIT" }); + + await waitFor(actor, s => s.matches("Verifying")); + expect(actor.getSnapshot().context.kycStatus).toBe(KycStatus.PENDING); + }); + + it("subaccount creation failure lands in Failure and RETRY returns to the form", async () => { + const actor = createTestActor({ + createSubaccountActor: fromPromise(async () => { + throw new Error("subaccount failed"); + }) + }); + actor.start(); + actor.send({ formData, type: "FORM_SUBMIT" }); + + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.type).toBe(AveniaKycMachineErrorType.UnknownError); + expect(actor.getSnapshot().context.error?.message).toBe("subaccount failed"); + + actor.send({ type: "RETRY" }); + expect(actor.getSnapshot().value).toBe("FormFilling"); + }); + + it("cancelling from Failure finishes with a UserCancelled error output", async () => { + const actor = createTestActor({ + createSubaccountActor: fromPromise(async () => { + throw new Error("subaccount failed"); + }) + }); + actor.start(); + actor.send({ formData, type: "FORM_SUBMIT" }); + await waitFor(actor, s => s.matches("Failure")); + + actor.send({ type: "CANCEL_RETRY" }); + const snapshot = actor.getSnapshot(); + expect(snapshot.value).toBe("Finish"); + expect(snapshot.status).toBe("done"); + expect(snapshot.output?.error?.type).toBe(AveniaKycMachineErrorType.UserCancelled); + }); + + it("a rejected submission moves to Rejected with the reject reason", async () => { + const actor = createTestActor({ + createSubaccountActor: subaccountActorWith({ isCompany: false, subAccountId: "sub-1" }), + submitActor: fromPromise(async () => { + throw new KycSubmissionRejectedError("document unreadable"); + }) + }); + actor.start(); + actor.send({ formData, type: "FORM_SUBMIT" }); + await waitFor(actor, s => s.matches("DocumentUpload")); + actor.send({ documentsId: uploadIds, type: "DOCUMENTS_SUBMIT" }); + actor.send({ type: "LIVENESS_OPENED" }); + actor.send({ type: "LIVENESS_DONE" }); + + await waitFor(actor, s => s.matches("Rejected")); + expect(actor.getSnapshot().context.kycStatus).toBe(KycStatus.REJECTED); + expect(actor.getSnapshot().context.rejectReason).toBe("document unreadable"); + + actor.send({ type: "RETRY" }); + expect(actor.getSnapshot().value).toBe("FormFilling"); + }); + + it("a generic submission error moves to Failure", async () => { + const actor = createTestActor({ + createSubaccountActor: subaccountActorWith({ isCompany: false, subAccountId: "sub-1" }), + submitActor: fromPromise(async () => { + throw new Error("network error"); + }) + }); + actor.start(); + actor.send({ formData, type: "FORM_SUBMIT" }); + await waitFor(actor, s => s.matches("DocumentUpload")); + actor.send({ documentsId: uploadIds, type: "DOCUMENTS_SUBMIT" }); + actor.send({ type: "LIVENESS_OPENED" }); + actor.send({ type: "LIVENESS_DONE" }); + + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toBe("network error"); + }); + + it("a rejected verification result moves to Rejected with the failure reason", async () => { + const actor = createTestActor({ + createSubaccountActor: subaccountActorWith({ isCompany: false, subAccountId: "sub-1" }), + submitActor: fromPromise(async () => undefined), + verifyStatusActor: fromPromise(async (): Promise => { + return { reason: KycFailureReason.FACE, type: "REJECTED" }; + }) + }); + actor.start(); + actor.send({ formData, type: "FORM_SUBMIT" }); + await waitFor(actor, s => s.matches("DocumentUpload")); + actor.send({ documentsId: uploadIds, type: "DOCUMENTS_SUBMIT" }); + actor.send({ type: "LIVENESS_OPENED" }); + actor.send({ type: "LIVENESS_DONE" }); + + await waitFor(actor, s => s.matches("Rejected")); + expect(actor.getSnapshot().context.kycStatus).toBe(KycStatus.REJECTED); + expect(actor.getSnapshot().context.rejectReason).toBe(KycFailureReason.FACE); + }); + + it("a verification polling error moves to Failure", async () => { + const actor = createTestActor({ + createSubaccountActor: subaccountActorWith({ isCompany: false, subAccountId: "sub-1" }), + submitActor: fromPromise(async () => undefined), + verifyStatusActor: fromPromise(async (): Promise => { + throw new Error("Failed to fetch KYC status after 10 attempts."); + }) + }); + actor.start(); + actor.send({ formData, type: "FORM_SUBMIT" }); + await waitFor(actor, s => s.matches("DocumentUpload")); + actor.send({ documentsId: uploadIds, type: "DOCUMENTS_SUBMIT" }); + actor.send({ type: "LIVENESS_OPENED" }); + actor.send({ type: "LIVENESS_DONE" }); + + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toBe("Failed to fetch KYC status after 10 attempts."); + }); + + it("refreshes the liveness URL and merges it into the stored upload ids", async () => { + const refresh = deferred<{ livenessUrl: string; uploadedSelfieId: string }>(); + const actor = createTestActor({ + createSubaccountActor: subaccountActorWith({ isCompany: false, subAccountId: "sub-1" }), + refreshLivenessUrlActor: fromPromise(() => refresh.promise) + }); + actor.start(); + actor.send({ formData, type: "FORM_SUBMIT" }); + await waitFor(actor, s => s.matches("DocumentUpload")); + actor.send({ documentsId: uploadIds, type: "DOCUMENTS_SUBMIT" }); + + actor.send({ type: "REFRESH_LIVENESS_URL" }); + expect(actor.getSnapshot().value).toBe("RefreshingLivenessUrl"); + + refresh.resolve({ livenessUrl: "https://liveness.example/new", uploadedSelfieId: "selfie-2" }); + await waitFor(actor, s => s.matches("LivenessCheck")); + expect(actor.getSnapshot().context.documentUploadIds).toEqual({ + livenessUrl: "https://liveness.example/new", + uploadedDocumentId: "doc-1", + uploadedSelfieId: "selfie-2" + }); + }); + + it("returns to LivenessCheck unchanged when refreshing the liveness URL fails", async () => { + const actor = createTestActor({ + createSubaccountActor: subaccountActorWith({ isCompany: false, subAccountId: "sub-1" }), + refreshLivenessUrlActor: fromPromise(async (): Promise<{ livenessUrl: string; uploadedSelfieId: string }> => { + throw new Error("refresh failed"); + }) + }); + actor.start(); + actor.send({ formData, type: "FORM_SUBMIT" }); + await waitFor(actor, s => s.matches("DocumentUpload")); + actor.send({ documentsId: uploadIds, type: "DOCUMENTS_SUBMIT" }); + + actor.send({ type: "REFRESH_LIVENESS_URL" }); + await waitFor(actor, s => s.matches("LivenessCheck")); + expect(actor.getSnapshot().context.documentUploadIds).toEqual(uploadIds); + }); + + // NOTE: documents current behavior — GO_BACK from the *initial* FormFilling state jumps forward to + // DocumentUpload even though the user never created a subaccount. This looks unintended: the + // transition presumably exists for returning from DocumentUpload, but it is reachable straight + // from the initial state too (brlaKyc.machine.ts, FormFilling.on.GO_BACK). + it("GO_BACK from the initial FormFilling state moves to DocumentUpload", () => { + const actor = createTestActor({}); + actor.start(); + + actor.send({ type: "GO_BACK" }); + expect(actor.getSnapshot().value).toBe("DocumentUpload"); + }); +}); diff --git a/apps/frontend/src/machines/fiatAccount.machine.test.ts b/apps/frontend/src/machines/fiatAccount.machine.test.ts new file mode 100644 index 000000000..37db7edb2 --- /dev/null +++ b/apps/frontend/src/machines/fiatAccount.machine.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { createActor } from "xstate"; +import { fiatAccountMachine } from "./fiatAccount.machine"; + +function createFiatAccountActor() { + const actor = createActor(fiatAccountMachine); + actor.start(); + return actor; +} + +function openedActor(country = "MX") { + const actor = createFiatAccountActor(); + actor.send({ country, type: "OPEN" }); + return actor; +} + +describe("fiatAccountMachine", () => { + it("starts closed with an empty context", () => { + const actor = createFiatAccountActor(); + + expect(actor.getSnapshot().value).toBe("Closed"); + expect(actor.getSnapshot().context).toEqual({ + fiatRegistrationCountry: null, + selectedAccountType: undefined, + selectedFiatAccountId: null + }); + }); + + it("OPEN stores the country and shows the accounts list", () => { + const actor = openedActor("CO"); + + expect(actor.getSnapshot().value).toEqual({ Open: "AccountsList" }); + expect(actor.getSnapshot().context.fiatRegistrationCountry).toBe("CO"); + }); + + it("selects an account while closed without opening the dialog", () => { + const actor = createFiatAccountActor(); + + actor.send({ id: "account-1", type: "SELECT_ACCOUNT" }); + + expect(actor.getSnapshot().value).toBe("Closed"); + expect(actor.getSnapshot().context.selectedFiatAccountId).toBe("account-1"); + }); + + it("selects an account from the accounts list", () => { + const actor = openedActor(); + + actor.send({ id: "account-2", type: "SELECT_ACCOUNT" }); + + expect(actor.getSnapshot().value).toEqual({ Open: "AccountsList" }); + expect(actor.getSnapshot().context.selectedFiatAccountId).toBe("account-2"); + }); + + it("GO_BACK from the accounts list closes the dialog and clears the country and selection", () => { + const actor = openedActor(); + actor.send({ id: "account-1", type: "SELECT_ACCOUNT" }); + + actor.send({ type: "GO_BACK" }); + + expect(actor.getSnapshot().value).toBe("Closed"); + expect(actor.getSnapshot().context.fiatRegistrationCountry).toBeNull(); + expect(actor.getSnapshot().context.selectedFiatAccountId).toBeNull(); + }); + + it("walks the registration flow: add new, pick a type, register, back to the list", () => { + const actor = openedActor(); + + actor.send({ type: "ADD_NEW" }); + expect(actor.getSnapshot().value).toEqual({ Open: "PickAccountType" }); + + actor.send({ accountType: "SPEI", type: "SELECT_ACCOUNT_TYPE" }); + expect(actor.getSnapshot().value).toEqual({ Open: "RegisterAccount" }); + expect(actor.getSnapshot().context.selectedAccountType).toBe("SPEI"); + + actor.send({ type: "REGISTER_DONE" }); + expect(actor.getSnapshot().value).toEqual({ Open: "AccountsList" }); + expect(actor.getSnapshot().context.selectedAccountType).toBeUndefined(); + }); + + it("GO_BACK steps back through the registration flow one screen at a time", () => { + const actor = openedActor(); + actor.send({ type: "ADD_NEW" }); + actor.send({ accountType: "WIRE", type: "SELECT_ACCOUNT_TYPE" }); + + actor.send({ type: "GO_BACK" }); + expect(actor.getSnapshot().value).toEqual({ Open: "PickAccountType" }); + // Stepping back does not clear the previously picked type. + expect(actor.getSnapshot().context.selectedAccountType).toBe("WIRE"); + + actor.send({ type: "GO_BACK" }); + expect(actor.getSnapshot().value).toEqual({ Open: "AccountsList" }); + }); +}); diff --git a/apps/frontend/src/machines/mykoboKyc.machine.test.ts b/apps/frontend/src/machines/mykoboKyc.machine.test.ts new file mode 100644 index 000000000..3241dffda --- /dev/null +++ b/apps/frontend/src/machines/mykoboKyc.machine.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from "vitest"; +import { assign, createActor, fromPromise, setup, waitFor } from "xstate"; + +// The real module instantiates a Supabase client at import time, which fails in a node test environment. +vi.mock("../services/auth", () => ({ + AuthService: { + getTokens: vi.fn(), + refreshAccessToken: vi.fn(), + storeTokens: vi.fn() + } +})); + +import { MykoboProfile } from "../services/api/mykobo.service"; +import { MykoboKycContext } from "./kyc.states"; +import { MykoboKycFiles, MykoboKycFormData, MykoboKycMachineErrorType, mykoboKycMachine } from "./mykoboKyc.machine"; +import { initialRampContext } from "./ramp.context"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +const neverResolves = () => fromPromise(() => new Promise(() => {})); + +const baseInput: MykoboKycContext = { + ...initialRampContext, + connectedWalletAddress: "0x1111111111111111111111111111111111111111", + userEmail: "user@example.com" +}; + +const approvedProfile = { kycStatus: { receivedAt: null, reviewStatus: "approved" } } as MykoboProfile; +const pendingProfile = { kycStatus: { receivedAt: null, reviewStatus: "pending" } } as MykoboProfile; + +const formData = { firstName: "Ada", lastName: "Lovelace" } as MykoboKycFormData; +const files = { face: {} as File, front: {} as File, utilityBill: {} as File } as MykoboKycFiles; + +type PollResult = { status: "approved" } | { status: "rejected" }; + +// XState actor logic is invariant in its output type, so fakes must declare the exact output of the real actor. +const profileActor = (result: MykoboProfile | null) => fromPromise(async () => result); + +function createTestActor(actors: Parameters[0]["actors"]) { + return createActor(mykoboKycMachine.provide({ actors }), { input: baseInput }); +} + +describe("mykoboKycMachine", () => { + it("walks the happy path from form filling to Done", async () => { + const check = deferred(); + const submit = deferred(); + const poll = deferred(); + const actor = createTestActor({ + checkExistingProfile: fromPromise(() => check.promise), + pollProfileStatus: fromPromise(() => poll.promise), + submitProfile: fromPromise(() => submit.promise) + }); + actor.start(); + + expect(actor.getSnapshot().value).toBe("CheckingProfile"); + + check.resolve(null); + await waitFor(actor, s => s.matches("FormFilling")); + + actor.send({ files, formData, type: "SubmitKycForm" }); + expect(actor.getSnapshot().value).toBe("Submitting"); + expect(actor.getSnapshot().context.formData).toEqual(formData); + expect(actor.getSnapshot().context.files).toBe(files); + + submit.resolve(pendingProfile); + await waitFor(actor, s => s.matches("Verifying")); + + poll.resolve({ status: "approved" }); + await waitFor(actor, s => s.matches("VerificationDone")); + expect(actor.getSnapshot().context.profileApproved).toBe(true); + + actor.send({ type: "CONFIRM_SUCCESS" }); + const snapshot = actor.getSnapshot(); + expect(snapshot.value).toBe("Done"); + expect(snapshot.status).toBe("done"); + expect(snapshot.output).toEqual({ error: undefined, profileApproved: true }); + }); + + it("skips straight to Done when the profile is already approved", async () => { + const actor = createTestActor({ + checkExistingProfile: profileActor(approvedProfile) + }); + actor.start(); + + await waitFor(actor, s => s.status === "done"); + expect(actor.getSnapshot().value).toBe("Done"); + expect(actor.getSnapshot().output).toEqual({ error: undefined, profileApproved: true }); + }); + + it("resumes verification when a profile is still pending", async () => { + const actor = createTestActor({ + checkExistingProfile: profileActor(pendingProfile), + pollProfileStatus: neverResolves() + }); + actor.start(); + + await waitFor(actor, s => s.matches("Verifying")); + expect(actor.getSnapshot().context.profileApproved).toBeUndefined(); + }); + + it("moves to Failure when the profile check fails", async () => { + const actor = createTestActor({ + checkExistingProfile: fromPromise(async () => { + throw new Error("network down"); + }) + }); + actor.start(); + + await waitFor(actor, s => s.matches("Failure")); + const snapshot = actor.getSnapshot(); + // Failure is intentionally non-final: the parent dismisses it via RESET_RAMP. + expect(snapshot.status).toBe("active"); + expect(snapshot.context.error?.type).toBe(MykoboKycMachineErrorType.UnknownError); + expect(snapshot.context.error?.message).toBe("Failed to check Mykobo profile"); + }); + + it("cancelling the form finishes with a UserRejected error", async () => { + const actor = createTestActor({ + checkExistingProfile: profileActor(null) + }); + actor.start(); + await waitFor(actor, s => s.matches("FormFilling")); + + actor.send({ type: "CANCEL" }); + const snapshot = actor.getSnapshot(); + expect(snapshot.value).toBe("Cancelled"); + expect(snapshot.status).toBe("done"); + expect(snapshot.output?.error?.type).toBe(MykoboKycMachineErrorType.UserRejected); + }); + + it("moves to Failure when profile submission fails", async () => { + const actor = createTestActor({ + checkExistingProfile: profileActor(null), + submitProfile: fromPromise(async () => { + throw new Error("500"); + }) + }); + actor.start(); + await waitFor(actor, s => s.matches("FormFilling")); + + actor.send({ files, formData, type: "SubmitKycForm" }); + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toBe("Failed to submit Mykobo profile"); + }); + + it("moves to Rejected with a KycRejected error when the review is rejected", async () => { + const actor = createTestActor({ + checkExistingProfile: profileActor(pendingProfile), + pollProfileStatus: fromPromise(async () => ({ status: "rejected" })) + }); + actor.start(); + + await waitFor(actor, s => s.matches("Rejected")); + const snapshot = actor.getSnapshot(); + // Rejected is intentionally non-final so the rejection screen stays rendered. + expect(snapshot.status).toBe("active"); + expect(snapshot.context.error?.type).toBe(MykoboKycMachineErrorType.KycRejected); + expect(snapshot.context.profileApproved).toBeUndefined(); + }); + + it("moves to Failure when polling errors out (e.g. timeout)", async () => { + const actor = createTestActor({ + checkExistingProfile: profileActor(pendingProfile), + pollProfileStatus: fromPromise(async () => { + throw new Error("KYC polling timed out"); + }) + }); + actor.start(); + + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toBe("KYC verification failed"); + }); + + it("forwards SIGNING_UPDATE events to the parent machine", async () => { + const childMachine = mykoboKycMachine.provide({ + actors: { checkExistingProfile: neverResolves() } + }); + const parentMachine = setup({ + actors: { mykoboKyc: childMachine }, + types: { + context: {} as { received: unknown[] }, + events: {} as { type: "SIGNING_UPDATE"; phase: string | undefined; current?: number; max?: number } + } + }).createMachine({ + context: { received: [] }, + invoke: { id: "mykoboKyc", input: baseInput, src: "mykoboKyc" }, + on: { + SIGNING_UPDATE: { + actions: assign({ received: ({ context, event }) => [...context.received, event] }) + } + } + }); + + const parent = createActor(parentMachine); + parent.start(); + const child = parent.getSnapshot().children.mykoboKyc; + expect(child).toBeDefined(); + + child?.send({ current: 1, max: 3, phase: "started", type: "SIGNING_UPDATE" }); + expect(parent.getSnapshot().context.received).toEqual([{ current: 1, max: 3, phase: "started", type: "SIGNING_UPDATE" }]); + }); +}); diff --git a/apps/frontend/src/machines/ramp.actors.ts b/apps/frontend/src/machines/ramp.actors.ts index e22c3e2a4..e50e6f601 100644 --- a/apps/frontend/src/machines/ramp.actors.ts +++ b/apps/frontend/src/machines/ramp.actors.ts @@ -78,12 +78,14 @@ export async function checkAndRefreshTokenActor() { if (refreshedTokens) { return { success: true, tokens: refreshedTokens }; } + // A null result means the refresh token was confirmed invalid; refreshAccessToken() + // has already cleared the stored session, so we just report failure here. + return { success: false, tokens: null }; } catch { - // If refreshing fails, continue to the shared cleanup path below. + // Transient refresh failure (network/5xx): keep the session rather than forcing a + // logout. Proceed with the current tokens; request-level 401 retry will recover later. + return { success: true, tokens }; } - - AuthService.clearTokens(); - return { success: false, tokens: null }; } export async function loadQuoteActor({ input }: { input: { quoteId: string } }) { diff --git a/apps/frontend/src/machines/ramp.machine.test.ts b/apps/frontend/src/machines/ramp.machine.test.ts new file mode 100644 index 000000000..31cad4309 --- /dev/null +++ b/apps/frontend/src/machines/ramp.machine.test.ts @@ -0,0 +1,652 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AnyActorRef, createActor, fromCallback, fromPromise, setup, waitFor } from "xstate"; + +// The real module instantiates a Supabase client at import time, which fails in a node test environment. +vi.mock("../services/auth", () => ({ + AuthService: { + getTokens: vi.fn(), + refreshAccessToken: vi.fn(), + storeTokens: vi.fn() + } +})); + +import { FiatToken, QuoteResponse, RampDirection, RampProcess } from "@vortexfi/shared"; +import { ToastMessage } from "../helpers/notifications"; +import { CheckEmailResponse, VerifyOTPResponse } from "../services/api/auth.api"; +import { AuthService, type AuthTokens } from "../services/auth"; +import { RampExecutionInput } from "../types/phases"; +import { SignRampError, SignRampErrorType } from "./actors/sign.actor"; +import { RampLimitExceededError } from "./actors/validateKyc.actor"; +import { AlfredpayKycMachineError, AlfredpayKycMachineErrorType, alfredpayKycMachine } from "./alfredpayKyc.machine"; +import { aveniaKycMachine } from "./brlaKyc.machine"; +import { MykoboKycMachineError, MykoboKycMachineErrorType, mykoboKycMachine } from "./mykoboKyc.machine"; +import { RampContext, RampMachineEvents, RampState } from "./types"; +import { rampMachine } from "./ramp.machine"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +const quote = { + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + id: "quote-1", + inputAmount: "100", + outputAmount: "95", + rampType: RampDirection.SELL +} as unknown as QuoteResponse; + +const makeExecutionInput = (fiatToken: FiatToken): RampExecutionInput => + ({ + fiatToken, + quote, + sourceOrDestinationAddress: "0x2222222222222222222222222222222222222222" + }) as unknown as RampExecutionInput; + +const rampState = { + quote, + ramp: { id: "ramp-1", unsignedTxs: [] }, + requiredUserActionsCompleted: false, + signedTransactions: [] +} as unknown as RampState; + +const authedTokens: AuthTokens = { accessToken: "at", refreshToken: "rt", userEmail: "user@example.com", userId: "user-1" }; + +// The fakes must declare the exact output types of the real actors (actor logic is invariant in its output). +type CheckTokenOutput = { success: boolean; tokens: null } | { success: boolean; tokens: AuthTokens }; +type LoadQuoteOutput = { isExpired: boolean; quote: QuoteResponse }; +type ValidateKycOutput = { kycNeeded: boolean; brlaEvmAddress?: string }; + +type ProvideArg = Parameters[0]; + +function buildImplementations(actors?: ProvideArg["actors"], actions?: ProvideArg["actions"]): ProvideArg { + return { + actions: { + // The real action waits 30 seconds; never useful in unit tests. + refreshQuoteActionWithDelay: () => {}, + // The real action touches window.location. + urlCleanerWithCallbackAction: () => {}, + ...actions + }, + actors: { + checkAndRefreshToken: fromPromise(async (): Promise => ({ success: true, tokens: authedTokens })), + checkEmail: fromPromise(async (): Promise => ({ action: "signin", exists: true })), + loadQuote: fromPromise(async (): Promise => ({ isExpired: false, quote })), + quoteRefresher: fromCallback(() => undefined), + registerRamp: fromPromise(async () => rampState), + requestOTP: fromPromise(async (): Promise<{ success: boolean }> => ({ success: true })), + signTransactions: fromPromise(async () => rampState), + startRamp: fromPromise(async () => ({ id: "ramp-1" }) as unknown as RampProcess), + urlCleaner: fromPromise(async (): Promise => undefined), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: false })), + verifyOTP: fromPromise( + async (): Promise => ({ accessToken: "at", refreshToken: "rt", success: true, userId: "user-1" }) + ), + ...actors + } + }; +} + +function createRampActor(actors?: ProvideArg["actors"], actions?: ProvideArg["actions"]) { + return createActor(rampMachine.provide(buildImplementations(actors, actions))); +} + +/** Minimal final child machine standing in for the Mykobo KYC machine. It finishes on FINISH. */ +function stubMykoboMachine(output: { profileApproved?: boolean; error?: MykoboKycMachineError }) { + return setup({}).createMachine({ + initial: "Waiting", + output: () => output, + states: { Done: { type: "final" }, Waiting: { on: { FINISH: "Done" } } } + }) as unknown as typeof mykoboKycMachine; +} + +/** Minimal child machine standing in for the Avenia KYC machine that completes immediately without error. */ +const stubAveniaMachine = setup({}).createMachine({ + initial: "Done", + output: () => ({}), + states: { Done: { type: "final" } } +}) as unknown as typeof aveniaKycMachine; + +/** + * Minimal child machine standing in for the Alfredpay KYC machine. It captures + * the input the ramp machine passes (country routing) and finishes on FINISH — + * or on SummaryConfirm, to prove the parent forwards that event to this child. + */ +function stubAlfredpayMachine(output: { error?: AlfredpayKycMachineError } = {}) { + return setup({}).createMachine({ + context: ({ input }) => ({ capturedInput: input as { country?: string; business?: boolean } }), + initial: "Waiting", + output: () => output, + states: { Done: { type: "final" }, Waiting: { on: { FINISH: "Done", SummaryConfirm: "Done" } } } + }) as unknown as typeof alfredpayKycMachine; +} + +/** Waiting variant of the Avenia stub so the test can observe the {KYC: "Avenia"} state. */ +function stubWaitingAveniaMachine(output: { error?: { message: string } } = {}) { + return setup({}).createMachine({ + initial: "Waiting", + output: () => output, + states: { Done: { type: "final" }, Waiting: { on: { FINISH: "Done" } } } + }) as unknown as typeof aveniaKycMachine; +} + +async function goToQuoteReady(actor: ReturnType) { + actor.send({ lock: false, quoteId: "quote-1", type: "SET_QUOTE" }); + await waitFor(actor, s => s.matches("QuoteReady")); +} + +async function confirmRamp(actor: ReturnType, fiatToken = FiatToken.EURC, direction = RampDirection.SELL) { + actor.send({ + input: { chainId: 1, executionInput: makeExecutionInput(fiatToken), rampDirection: direction }, + type: "CONFIRM" + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("rampMachine", () => { + describe("quote loading and auth", () => { + it("starts in Idle with the initial context", () => { + const actor = createRampActor(); + actor.start(); + expect(actor.getSnapshot().value).toBe("Idle"); + expect(actor.getSnapshot().context.isAuthenticated).toBe(false); + expect(actor.getSnapshot().context.quote).toBeUndefined(); + }); + + it("loads a quote and lands in QuoteReady when the session is valid", async () => { + const load = deferred<{ isExpired: boolean; quote: QuoteResponse }>(); + const actor = createRampActor({ loadQuote: fromPromise(() => load.promise) }); + actor.start(); + + actor.send({ lock: true, quoteId: "quote-1", type: "SET_QUOTE" }); + expect(actor.getSnapshot().value).toBe("LoadingQuote"); + expect(actor.getSnapshot().context.quoteId).toBe("quote-1"); + expect(actor.getSnapshot().context.quoteLocked).toBe(true); + + load.resolve({ isExpired: false, quote }); + await waitFor(actor, s => s.matches("QuoteReady")); + + const context = actor.getSnapshot().context; + expect(context.quote).toEqual(quote); + expect(context.isAuthenticated).toBe(true); + expect(context.userEmail).toBe("user@example.com"); + expect(context.userId).toBe("user-1"); + // PostAuthRouting clears the routing target on exit. + expect(context.postAuthTarget).toBeUndefined(); + }); + + it("returns to Idle with an expired-quote flag when loading the quote fails", async () => { + const actor = createRampActor({ + loadQuote: fromPromise(async (): Promise => { + throw new Error("quote not found"); + }) + }); + actor.start(); + + actor.send({ lock: false, quoteId: "missing", type: "SET_QUOTE" }); + await waitFor(actor, s => s.matches("Idle")); + expect(actor.getSnapshot().context.isQuoteExpired).toBe(true); + expect(actor.getSnapshot().context.quote).toBeUndefined(); + }); + + it("walks the email/OTP login flow and stores the tokens", async () => { + const actor = createRampActor({ + checkAndRefreshToken: fromPromise(async (): Promise => ({ success: false, tokens: null })) + }); + actor.start(); + + actor.send({ lock: false, quoteId: "quote-1", type: "SET_QUOTE" }); + await waitFor(actor, s => s.matches("EnterEmail")); + + actor.send({ email: "new@user.com", type: "ENTER_EMAIL" }); + expect(actor.getSnapshot().value).toBe("CheckingEmail"); + expect(actor.getSnapshot().context.userEmail).toBe("new@user.com"); + + await waitFor(actor, s => s.matches("EnterOTP")); + + actor.send({ code: "123456", type: "VERIFY_OTP" }); + expect(actor.getSnapshot().value).toBe("VerifyingOTP"); + + await waitFor(actor, s => s.matches("QuoteReady")); + expect(actor.getSnapshot().context.isAuthenticated).toBe(true); + expect(actor.getSnapshot().context.userId).toBe("user-1"); + expect(AuthService.storeTokens).toHaveBeenCalledWith({ + accessToken: "at", + refreshToken: "rt", + userEmail: "new@user.com", + userId: "user-1" + }); + }); + + it("an invalid OTP returns to EnterOTP with an error message", async () => { + const actor = createRampActor({ + checkAndRefreshToken: fromPromise(async (): Promise => ({ success: false, tokens: null })), + verifyOTP: fromPromise(async (): Promise => { + throw new Error("invalid code"); + }) + }); + actor.start(); + actor.send({ lock: false, quoteId: "quote-1", type: "SET_QUOTE" }); + await waitFor(actor, s => s.matches("EnterEmail")); + actor.send({ email: "new@user.com", type: "ENTER_EMAIL" }); + await waitFor(actor, s => s.matches("EnterOTP")); + + actor.send({ code: "000000", type: "VERIFY_OTP" }); + await waitFor(actor, s => s.matches("EnterOTP") && s.context.errorMessage !== undefined); + expect(actor.getSnapshot().context.errorMessage).toBe("Invalid OTP code. Please try again."); + }); + + it("a failed OTP request returns to EnterEmail with an error message", async () => { + const actor = createRampActor({ + checkAndRefreshToken: fromPromise(async (): Promise => ({ success: false, tokens: null })), + requestOTP: fromPromise(async (): Promise<{ success: boolean }> => { + throw new Error("mail service down"); + }) + }); + actor.start(); + actor.send({ lock: false, quoteId: "quote-1", type: "SET_QUOTE" }); + await waitFor(actor, s => s.matches("EnterEmail")); + + actor.send({ email: "new@user.com", type: "ENTER_EMAIL" }); + await waitFor(actor, s => s.matches("EnterEmail") && s.context.errorMessage !== undefined); + expect(actor.getSnapshot().context.errorMessage).toBe("Failed to send OTP. Please try again."); + }); + + it("a failed email check returns to EnterEmail with an error message", async () => { + const actor = createRampActor({ + checkAndRefreshToken: fromPromise(async (): Promise => ({ success: false, tokens: null })), + checkEmail: fromPromise(async (): Promise => { + throw new Error("email check down"); + }) + }); + actor.start(); + actor.send({ lock: false, quoteId: "quote-1", type: "SET_QUOTE" }); + await waitFor(actor, s => s.matches("EnterEmail")); + + actor.send({ email: "new@user.com", type: "ENTER_EMAIL" }); + await waitFor(actor, s => s.matches("EnterEmail") && s.context.errorMessage !== undefined); + expect(actor.getSnapshot().context.errorMessage).toBe("Failed to check email. Please try again."); + }); + }); + + describe("ramp execution", () => { + it("runs a SELL ramp from CONFIRM through signing to RampFollowUp", async () => { + const register = deferred(); + const sign = deferred(); + const actor = createRampActor({ + registerRamp: fromPromise(() => register.promise), + signTransactions: fromPromise(() => sign.promise) + }); + actor.start(); + await goToQuoteReady(actor); + + await confirmRamp(actor); + expect(actor.getSnapshot().value).toBe("RampRequested"); + expect(actor.getSnapshot().context.chainId).toBe(1); + expect(actor.getSnapshot().context.rampDirection).toBe(RampDirection.SELL); + + // NOTE: with kycNeeded=false and a non-BRL token, no RampRequested.onDone branch matches, so the + // machine intentionally stays in RampRequested until the user confirms the summary modal. + await new Promise(resolve => setTimeout(resolve, 0)); + expect(actor.getSnapshot().value).toBe("RampRequested"); + + actor.send({ type: "SummaryConfirm" }); + expect(actor.getSnapshot().value).toBe("RegisterRamp"); + expect(actor.getSnapshot().context.quoteLocked).toBe(true); + + register.resolve(rampState); + await waitFor(actor, s => s.matches("UpdateRamp")); + expect(actor.getSnapshot().context.rampState).toEqual(rampState); + + sign.resolve(rampState); + await waitFor(actor, s => s.matches("RampFollowUp")); + + actor.send({ type: "FINISH_OFFRAMPING" }); + await waitFor(actor, s => s.matches("Idle")); + expect(actor.getSnapshot().context.quote).toBeUndefined(); + }); + + it("a BUY ramp stays in UpdateRamp after signing until the payment is confirmed", async () => { + const signedState = { ...rampState, signedTransactions: [{}] } as unknown as RampState; + const actor = createRampActor({ + signTransactions: fromPromise(async () => signedState) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor, FiatToken.EURC, RampDirection.BUY); + actor.send({ type: "SummaryConfirm" }); + + await waitFor(actor, s => s.matches("UpdateRamp") && s.context.rampState?.signedTransactions.length === 1); + expect(actor.getSnapshot().value).toBe("UpdateRamp"); + + actor.send({ type: "PAYMENT_CONFIRMED" }); + expect(actor.getSnapshot().context.rampPaymentConfirmed).toBe(true); + await waitFor(actor, s => s.matches("RampFollowUp")); + }); + + it("a user-rejected signature emits a toast and resets the ramp", async () => { + const actor = createRampActor({ + signTransactions: fromPromise(async (): Promise => { + throw new SignRampError("User rejected", SignRampErrorType.UserRejected); + }) + }); + const toasts: ToastMessage[] = []; + actor.on("SHOW_ERROR_TOAST", event => toasts.push(event.message)); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + actor.send({ type: "SummaryConfirm" }); + + await waitFor(actor, s => s.matches("Idle")); + expect(toasts).toEqual([ToastMessage.SIGNING_REJECTED]); + expect(actor.getSnapshot().context.quote).toBeUndefined(); + expect(actor.getSnapshot().context.errorMessage).toBeUndefined(); + }); + + it("a generic signing failure lands in Error with the message and can be reset", async () => { + const actor = createRampActor({ + signTransactions: fromPromise(async (): Promise => { + throw new Error("insufficient funds for gas"); + }) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + actor.send({ type: "SummaryConfirm" }); + + await waitFor(actor, s => s.matches("Error")); + const context = actor.getSnapshot().context; + expect(context.errorMessage).toBe("insufficient funds for gas"); + expect(context.rampSigningPhase).toBeUndefined(); + + actor.send({ type: "RESET_RAMP" }); + await waitFor(actor, s => s.matches("Idle")); + expect(actor.getSnapshot().context.quote).toBeUndefined(); + }); + + it("a registration failure lands in Error with the message", async () => { + const actor = createRampActor({ + registerRamp: fromPromise(async (): Promise => { + throw new Error("registration failed"); + }) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + actor.send({ type: "SummaryConfirm" }); + + await waitFor(actor, s => s.matches("Error")); + expect(actor.getSnapshot().context.errorMessage).toBe("registration failed"); + }); + + it("a KYC validation failure returns to Idle", async () => { + const actor = createRampActor({ + validateKyc: fromPromise(async (): Promise => { + throw new Error("validation error"); + }) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + + await waitFor(actor, s => s.matches("Idle")); + expect(actor.getSnapshot().context.initializeFailedMessage).toBeUndefined(); + }); + + it("an exceeded ramp limit returns to Idle with the limit error message", async () => { + const actor = createRampActor({ + validateKyc: fromPromise(async (): Promise => { + throw new RampLimitExceededError(); + }) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor, FiatToken.BRL); + + await waitFor(actor, s => s.matches("Idle")); + expect(actor.getSnapshot().context.initializeFailedMessage).toBe("Insufficient remaining limit for this transaction."); + }); + + it("redirects to the callback URL after starting a ramp and returns to Idle after the delay", async () => { + vi.useFakeTimers(); + try { + const callbackAction = vi.fn(); + const actor = createRampActor(undefined, { urlCleanerWithCallbackAction: callbackAction }); + actor.start(); + + actor.send({ callbackUrl: "https://partner.example/callback", type: "SET_QUOTE_PARAMS" }); + await goToQuoteReady(actor); + await confirmRamp(actor); + actor.send({ type: "SummaryConfirm" }); + + await waitFor(actor, s => s.matches("RedirectCallback")); + + vi.advanceTimersByTime(5000); + expect(actor.getSnapshot().value).toBe("Idle"); + expect(callbackAction).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe("KYC routing", () => { + it("routes EURC ramps with kycNeeded to the Mykobo child and advances to KycComplete on approval", async () => { + const actor = createRampActor({ + mykoboKyc: stubMykoboMachine({ profileApproved: true }), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + + await waitFor(actor, s => s.matches({ KYC: "Mykobo" })); + + (actor.getSnapshot().children.mykoboKyc as AnyActorRef).send({ type: "FINISH" }); + await waitFor(actor, s => s.matches("KycComplete")); + }); + + it("returns to QuoteReady when the user cancels Mykobo KYC", async () => { + const actor = createRampActor({ + mykoboKyc: stubMykoboMachine({ + error: new MykoboKycMachineError("Cancelled by the user", MykoboKycMachineErrorType.UserRejected) + }), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + await waitFor(actor, s => s.matches({ KYC: "Mykobo" })); + + (actor.getSnapshot().children.mykoboKyc as AnyActorRef).send({ type: "FINISH" }); + await waitFor(actor, s => s.matches("QuoteReady")); + }); + + it("a KYC rejection resets the ramp but keeps the failure message", async () => { + const actor = createRampActor({ + mykoboKyc: stubMykoboMachine({ + error: new MykoboKycMachineError("KYC was rejected", MykoboKycMachineErrorType.KycRejected) + }), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + await waitFor(actor, s => s.matches({ KYC: "Mykobo" })); + + (actor.getSnapshot().children.mykoboKyc as AnyActorRef).send({ type: "FINISH" }); + // KycFailure immediately resets; the reset preserves initializeFailedMessage for the UI. + await waitFor(actor, s => s.matches("Idle")); + expect(actor.getSnapshot().context.initializeFailedMessage).toBe("KYC was rejected"); + }); + + it("BRL ramps with a valid KYC go straight to KycComplete", async () => { + const actor = createRampActor({ + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: false })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor, FiatToken.BRL); + + await waitFor(actor, s => s.matches("KycComplete")); + }); + + it("PROCEED_TO_REGISTRATION from KycComplete stores the fiat account and registers directly when authenticated", async () => { + const actor = createRampActor({ + registerRamp: fromPromise(() => new Promise(() => {})), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: false })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor, FiatToken.BRL); + await waitFor(actor, s => s.matches("KycComplete")); + + actor.send({ selectedFiatAccountId: "fiat-account-1", type: "PROCEED_TO_REGISTRATION" }); + expect(actor.getSnapshot().value).toBe("RegisterRamp"); + expect(actor.getSnapshot().context.executionInput?.selectedFiatAccountId).toBe("fiat-account-1"); + }); + + it.each([ + [FiatToken.MXN, "MX"], + [FiatToken.USD, "US"], + [FiatToken.COP, "CO"], + [FiatToken.ARS, "AR"] + ])("routes %s ramps with kycNeeded to the Alfredpay child with country %s and completes", async (fiatToken, country) => { + const actor = createRampActor({ + alfredpayKyc: stubAlfredpayMachine(), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor, fiatToken); + + await waitFor(actor, s => s.matches({ KYC: "Alfredpay" })); + + // The parent derived the Alfredpay country from the quote's fiat token. + const child = actor.getSnapshot().children.alfredpayKyc as AnyActorRef; + const capturedInput = child.getSnapshot().context.capturedInput as { country?: string; business?: boolean }; + expect(capturedInput.country).toBe(country); + expect(capturedInput.business).toBeUndefined(); + + child.send({ type: "FINISH" }); + await waitFor(actor, s => s.matches("KycComplete")); + }); + + it("an Alfredpay KYC error output resets the ramp but keeps the failure message", async () => { + const actor = createRampActor({ + alfredpayKyc: stubAlfredpayMachine({ + error: new AlfredpayKycMachineError("Verification rejected", AlfredpayKycMachineErrorType.UnknownError) + }), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor, FiatToken.MXN); + await waitFor(actor, s => s.matches({ KYC: "Alfredpay" })); + + (actor.getSnapshot().children.alfredpayKyc as AnyActorRef).send({ type: "FINISH" }); + // KycFailure immediately resets; the reset preserves initializeFailedMessage for the UI. + await waitFor(actor, s => s.matches("Idle")); + expect(actor.getSnapshot().context.initializeFailedMessage).toBe("Verification rejected"); + }); + + it("SummaryConfirm inside KYC is forwarded to the Alfredpay child", async () => { + const actor = createRampActor({ + alfredpayKyc: stubAlfredpayMachine(), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor, FiatToken.ARS); + await waitFor(actor, s => s.matches({ KYC: "Alfredpay" })); + + // The stub finalizes when it receives the forwarded SummaryConfirm. + actor.send({ type: "SummaryConfirm" }); + await waitFor(actor, s => s.matches("KycComplete")); + }); + + it("routes BRL ramps with kycNeeded to the Avenia child and advances to KycComplete on success", async () => { + const actor = createRampActor({ + aveniaKyc: stubWaitingAveniaMachine(), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor, FiatToken.BRL); + + await waitFor(actor, s => s.matches({ KYC: "Avenia" })); + + (actor.getSnapshot().children.aveniaKyc as AnyActorRef).send({ type: "FINISH" }); + await waitFor(actor, s => s.matches("KycComplete")); + }); + + it("completes the quote-less KYB deep link via SelectRegion and lands in KybLinkComplete", async () => { + const actor = createRampActor({ aveniaKyc: stubAveniaMachine }); + actor.start(); + + actor.send({ type: "START_KYB_LINK" }); + await waitFor(actor, s => s.matches("SelectRegion")); + expect(actor.getSnapshot().context.kybLink).toEqual({ fiatToken: undefined, regionLocked: false }); + + actor.send({ fiatToken: FiatToken.BRL, type: "SELECT_REGION" }); + await waitFor(actor, s => s.matches("KybLinkComplete")); + }); + }); + + describe("global events", () => { + it("updates the connected wallet address from anywhere", () => { + const actor = createRampActor(); + actor.start(); + actor.send({ address: "0x3333333333333333333333333333333333333333", type: "SET_ADDRESS" }); + expect(actor.getSnapshot().context.connectedWalletAddress).toBe("0x3333333333333333333333333333333333333333"); + }); + + it("EXPIRE_QUOTE marks the quote expired unless the quote is locked", async () => { + const actor = createRampActor(); + actor.start(); + actor.send({ type: "EXPIRE_QUOTE" }); + expect(actor.getSnapshot().context.isQuoteExpired).toBe(true); + + const lockedActor = createRampActor(); + lockedActor.start(); + lockedActor.send({ lock: true, quoteId: "quote-1", type: "SET_QUOTE" }); + await waitFor(lockedActor, s => s.matches("QuoteReady")); + lockedActor.send({ type: "EXPIRE_QUOTE" }); + expect(lockedActor.getSnapshot().context.isQuoteExpired).toBe(false); + }); + + it("LOGOUT clears the session and moves to EnterEmail", async () => { + const actor = createRampActor(); + actor.start(); + await goToQuoteReady(actor); + expect(actor.getSnapshot().context.isAuthenticated).toBe(true); + + actor.send({ type: "LOGOUT" }); + expect(actor.getSnapshot().value).toBe("EnterEmail"); + expect(actor.getSnapshot().context.isAuthenticated).toBe(false); + expect(actor.getSnapshot().context.userEmail).toBeUndefined(); + }); + + it("SIGNING_UPDATE keeps previous progress values when the event omits them", () => { + const actor = createRampActor(); + actor.start(); + + actor.send({ current: 1, max: 4, phase: "started", type: "SIGNING_UPDATE" }); + actor.send({ phase: "signed", type: "SIGNING_UPDATE" }); + + const context = actor.getSnapshot().context; + expect(context.rampSigningPhase).toBe("signed"); + expect(context.rampSigningPhaseCurrent).toBe(1); + expect(context.rampSigningPhaseMax).toBe(4); + }); + }); +}); diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index fd669849c..a0e664121 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -1,3 +1,4 @@ +import * as Sentry from "@sentry/react"; import { FiatToken, RampDirection } from "@vortexfi/shared"; import { assign, emit, fromCallback, fromPromise, setup } from "xstate"; import { findKybRegionByCode } from "../constants/kybRegions"; @@ -7,7 +8,7 @@ import { checkEmailActor, requestOTPActor, verifyOTPActor } from "./actors/auth. import { registerRampActor } from "./actors/register.actor"; import { SignRampError, SignRampErrorType, signTransactionsActor } from "./actors/sign.actor"; import { startRampActor } from "./actors/start.actor"; -import { validateKycActor } from "./actors/validateKyc.actor"; +import { RampLimitExceededError, validateKycActor } from "./actors/validateKyc.actor"; import { alfredpayKycMachine } from "./alfredpayKyc.machine"; import { aveniaKycMachine } from "./brlaKyc.machine"; import { kycStateNode } from "./kyc.states"; @@ -65,6 +66,15 @@ function getActorErrorMessage(event: unknown): string { export const rampMachine = setup({ actions: { + // Report genuine money-flow failures to Sentry. User-rejected signatures are expected, + // not bugs, so they are skipped here regardless of how the transition was routed. + captureActorError: ({ event }) => { + const error = (event as { error?: unknown }).error; + if (!error || (error instanceof SignRampError && error.type === SignRampErrorType.UserRejected)) { + return; + } + Sentry.captureException(error); + }, refreshQuoteActionWithDelay: async ({ context, self }) => { const { quote, quoteLocked, apiKey, partnerId } = context; if (quoteLocked || !quote) { @@ -359,12 +369,15 @@ export const rampMachine = setup({ } }, Error: { - entry: assign(({ context }) => ({ - ...context, - rampSigningPhase: undefined, - rampSigningPhaseCurrent: undefined, - rampSigningPhaseMax: undefined - })), + entry: [ + "captureActorError", + assign(({ context }) => ({ + ...context, + rampSigningPhase: undefined, + rampSigningPhaseCurrent: undefined, + rampSigningPhaseMax: undefined + })) + ], on: { RESET_RAMP: { target: "Resetting" @@ -612,7 +625,18 @@ export const rampMachine = setup({ target: "KycComplete" } ], - onError: "Idle", + onError: [ + { + // An exceeded Avenia limit is a user-facing error, not a KYC redirect; + // RampErrorMessage renders initializeFailedMessage in the widget. + actions: assign({ + initializeFailedMessage: ({ event }) => getActorErrorMessage(event) + }), + guard: ({ event }) => event.error instanceof RampLimitExceededError, + target: "Idle" + }, + { target: "Idle" } + ], src: "validateKyc" }, on: { diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 8a9045f5b..b1833fd99 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -16,6 +16,8 @@ import { WagmiProvider } from "wagmi"; import { config } from "./config"; import { PolkadotNodeProvider } from "./contexts/polkadotNode"; import { PolkadotWalletStateProvider } from "./contexts/polkadotWallet"; +import { SENTRY_DENY_URLS, SENTRY_IGNORE_ERRORS, sentryBeforeSend } from "./helpers/sentry"; +import { AuthService } from "./services/auth"; import { initializeEvmTokens } from "./services/tokens"; import { wagmiConfig } from "./wagmiConfig"; import "./helpers/googleTranslate"; @@ -57,17 +59,34 @@ declare module "@tanstack/react-router" { const sentryDsn = import.meta.env.VITE_SENTRY_DSN; if (sentryDsn) { Sentry.init({ + beforeSend: sentryBeforeSend, + denyUrls: SENTRY_DENY_URLS, dsn: sentryDsn, enabled: !window.location.hostname.includes("localhost"), // Disable sentry entirely when testing locally - environment: config.isProd ? "production" : "development", - integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router), Sentry.replayIntegration()], + environment: config.env, // production | staging | development — keeps preview/QA noise out of prod + ignoreErrors: SENTRY_IGNORE_ERRORS, + // Explicit replay masking — these are the defaults, but pinned for a KYC/KYB app so a future + // default change can't start leaking user input into replays. + integrations: [ + Sentry.tanstackRouterBrowserTracingIntegration(router), + Sentry.replayIntegration({ blockAllMedia: true, maskAllText: true }) + ], // Capture 100% of sessions where an error occurs; sample plain sessions only in prod. replaysOnErrorSampleRate: 1.0, replaysSessionSampleRate: config.isProd ? 0.1 : 1.0, - // Allow all targets to account for different Netlify URLs which depend on the branch name. - tracePropagationTargets: ["*"], + // Only propagate trace headers to our own (same-origin) API. The API is served same-origin + // (/api/...), so this works across all Netlify branch URLs and avoids leaking headers to + // third parties (Squid, RPCs). + tracePropagationTargets: [window.location.origin], tracesSampleRate: config.isProd ? 0.2 : 1.0 }); + + // On a page reload the session is restored from localStorage without calling storeTokens, so + // seed the Sentry user here too (pseudonymous id only). Runtime login/logout keeps it in sync. + const restoredUserId = AuthService.getUserId(); + if (restoredUserId) { + Sentry.setUser({ id: restoredUserId }); + } } const root = document.getElementById("app"); diff --git a/apps/frontend/src/pages/progress/ProgressPage.test.tsx b/apps/frontend/src/pages/progress/ProgressPage.test.tsx new file mode 100644 index 000000000..502b3b52d --- /dev/null +++ b/apps/frontend/src/pages/progress/ProgressPage.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from "@testing-library/react"; +import { RampPhase } from "@vortexfi/shared"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createFakeRampActor, FakeRampActor } from "../../test/fakeRampActor"; +import { buildQuoteResponse, buildRampProcess } from "../../test/fixtures"; +import "../../test/i18n"; +import { API_BASE_URL, server } from "../../test/msw-server"; +import { RampState } from "../../types/phases"; + +// Module-level singletons: hooks that appear in effect dependency arrays must return +// stable references (see Onramp.test.tsx). +const stubs = { + events: { trackEvent: vi.fn() }, + network: { + networkSelectorDisabled: false, + selectedNetwork: "base", + setNetworkSelectorDisabled: vi.fn(), + setSelectedNetwork: vi.fn() + } +}; + +let fakeRampActor: FakeRampActor; +vi.mock("../../contexts/rampState", () => ({ + useRampActor: () => fakeRampActor +})); + +vi.mock("../../contexts/network", () => ({ + useNetwork: () => stubs.network +})); + +vi.mock("../../contexts/events", () => ({ + useEventsContext: () => stubs.events +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children }: { children?: React.ReactNode }) => {children} +})); + +import { ProgressPage } from "./index"; + +// A BRL onramp (BUY, inputCurrency BRL) — flow "onramp_brl" on the progress page. +function buildRampState(currentPhase: RampPhase, rampOverrides: Record = {}): RampState { + return { + quote: buildQuoteResponse(), + ramp: buildRampProcess(currentPhase, rampOverrides), + requiredUserActionsCompleted: true, + signedTransactions: [], + userSigningMeta: undefined + }; +} + +// Serves GET /ramp/:id and records how often it was polled. +function mockRampStatusEndpoint(response: () => ReturnType) { + const calls: string[] = []; + server.use( + http.get(`${API_BASE_URL}/ramp/:id`, ({ params }) => { + calls.push(params.id as string); + return response(); + }) + ); + return calls; +} + +describe("ProgressPage", () => { + beforeEach(() => { + localStorage.clear(); + stubs.events.trackEvent.mockClear(); + }); + + it.each([ + ["brlaOnrampMint" as RampPhase, "Your payment is being processed. This can take up to 5 minutes."], + ["nablaSwap" as RampPhase, "Swapping to USDC on Vortex DEX"] + ])("renders the message for the current phase (%s)", async (phase, expectedMessage) => { + fakeRampActor = createFakeRampActor(buildRampState(phase)); + mockRampStatusEndpoint(() => HttpResponse.json(buildRampProcess(phase))); + + render(); + + expect(screen.getByText("Your transaction is in progress.")).toBeInTheDocument(); + expect(await screen.findByText(expectedMessage)).toBeInTheDocument(); + }); + + it("advances the displayed phase when polling returns a later phase", async () => { + fakeRampActor = createFakeRampActor(buildRampState("brlaOnrampMint")); + mockRampStatusEndpoint(() => HttpResponse.json(buildRampProcess("nablaSwap"))); + + render(); + + expect(await screen.findByText("Swapping to USDC on Vortex DEX")).toBeInTheDocument(); + expect(stubs.events.trackEvent).toHaveBeenCalledWith(expect.objectContaining({ event: "progress", phase_name: "nablaSwap" })); + }); + + it("drops a poll response whose ramp id does not match the active ramp", async () => { + fakeRampActor = createFakeRampActor(buildRampState("brlaOnrampMint")); + const calls = mockRampStatusEndpoint(() => HttpResponse.json(buildRampProcess("nablaSwap", { id: "some-other-ramp" }))); + + render(); + + await waitFor(() => expect(calls.length).toBeGreaterThanOrEqual(1)); + expect(fakeRampActor.events.filter(e => e.type === "SET_RAMP_STATE")).toHaveLength(0); + expect(screen.getByText("Your payment is being processed. This can take up to 5 minutes.")).toBeInTheDocument(); + }); + + it("keeps rendering the current phase when the status poll fails", async () => { + fakeRampActor = createFakeRampActor(buildRampState("brlaOnrampMint")); + const calls = mockRampStatusEndpoint(() => HttpResponse.json({ error: "boom" }, { status: 500 })); + + render(); + + await waitFor(() => expect(calls.length).toBeGreaterThanOrEqual(1)); + expect(fakeRampActor.events.filter(e => e.type === "SET_RAMP_STATE")).toHaveLength(0); + expect(screen.getByText("Your payment is being processed. This can take up to 5 minutes.")).toBeInTheDocument(); + }); + + it("shows the delayed-transaction banner with the ramp id once a ramp is older than 20 minutes", async () => { + const twentyFiveMinutesAgo = new Date(Date.now() - 25 * 60 * 1000).toISOString(); + fakeRampActor = createFakeRampActor(buildRampState("brlaOnrampMint", { createdAt: twentyFiveMinutesAgo })); + mockRampStatusEndpoint(() => HttpResponse.json(buildRampProcess("brlaOnrampMint", { createdAt: twentyFiveMinutesAgo }))); + + render(); + + expect(await screen.findByText("Transaction Status")).toBeInTheDocument(); + expect(screen.getByText("ramp-123")).toBeInTheDocument(); + }); + + it("does not show the delayed-transaction banner for a fresh ramp", () => { + fakeRampActor = createFakeRampActor(buildRampState("brlaOnrampMint")); + mockRampStatusEndpoint(() => HttpResponse.json(buildRampProcess("brlaOnrampMint"))); + + render(); + + expect(screen.queryByText("Transaction Status")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index a80ab228e..85c3cebac 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -1,5 +1,5 @@ import { CheckIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid"; -import { FiatToken, isNetworkEVM, RampDirection, RampPhase } from "@vortexfi/shared"; +import { FiatToken, isAlfredpayToken, isNetworkEVM, RampDirection, RampPhase } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { motion } from "motion/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; @@ -37,6 +37,10 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS return "offramp_eur_evm"; } + if (rampState.quote && isAlfredpayToken(rampState.quote.outputCurrency)) { + return "offramp_alfredpay"; + } + return null; } diff --git a/apps/frontend/src/pages/progress/phaseFlows.test.ts b/apps/frontend/src/pages/progress/phaseFlows.test.ts deleted file mode 100644 index e01cd7bd3..000000000 --- a/apps/frontend/src/pages/progress/phaseFlows.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { PHASE_FLOWS } from "./phaseFlows"; - -describe("progress phase flows", () => { - it("matches the active BRL offramp Base runtime phases", () => { - expect(PHASE_FLOWS.offramp_brl).toEqual([ - "initial", - "fundEphemeral", - "distributeFees", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "subsidizePostSwap", - "brlaPayoutOnBase", - "complete" - ]); - }); - - it("matches the active BRL onramp Base runtime phases", () => { - expect(PHASE_FLOWS.onramp_brl).toEqual([ - "initial", - "brlaOnrampMint", - "onHoldForComplianceCheck", - "fundEphemeral", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "distributeFees", - "subsidizePostSwap", - "squidRouterSwap", - "squidRouterPay", - "finalSettlementSubsidy", - "destinationTransfer", - "complete" - ]); - }); -}); diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index 444133d82..9cba0e8e3 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -44,6 +44,15 @@ export const PHASE_DURATIONS: Record = { }; export const PHASE_FLOWS = { + offramp_alfredpay: [ + "initial", + "squidRouterPermitExecute", + "fundEphemeral", + "finalSettlementSubsidy", + "alfredpayOfframpTransfer", + "complete" + ] as RampPhase[], + offramp_brl: [ "initial", "fundEphemeral", diff --git a/apps/frontend/src/pages/widget/index.tsx b/apps/frontend/src/pages/widget/index.tsx index 9a244adaa..5b29b313b 100644 --- a/apps/frontend/src/pages/widget/index.tsx +++ b/apps/frontend/src/pages/widget/index.tsx @@ -1,6 +1,8 @@ +import * as Sentry from "@sentry/react"; import { isValidCnpj } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { motion } from "motion/react"; +import { useTranslation } from "react-i18next"; import { AlfredpayKycFlow } from "../../components/Alfredpay/AlfredpayKycFlow"; import { LoadingScreen } from "../../components/Alfredpay/LoadingScreen"; import { AveniaKYBFlow } from "../../components/Avenia/AveniaKYBFlow"; @@ -36,6 +38,18 @@ export interface WidgetProps { className?: string; } +const WidgetErrorFallback = ({ onReset }: { onReset: () => void }) => { + const { t } = useTranslation(); + return ( +
+

{t("toasts.genericError")}

+ +
+ ); +}; + export const Widget = ({ className }: WidgetProps) => (
( className )} > - + }> + +
diff --git a/apps/frontend/src/services/api/api-client.ts b/apps/frontend/src/services/api/api-client.ts index 66f9c66a1..2b1d3670d 100644 --- a/apps/frontend/src/services/api/api-client.ts +++ b/apps/frontend/src/services/api/api-client.ts @@ -1,14 +1,49 @@ import { SIGNING_SERVICE_URL } from "../../constants/constants"; -import { AuthService } from "../auth"; +import { AuthService, type AuthTokens } from "../auth"; -export class ApiError extends Error { +// Single-flight token refresh: concurrent 401s share one refresh instead of each firing +// their own (which would race the refresh-token rotation and fail). +let refreshPromise: Promise | null = null; + +function refreshTokenOnce(): Promise { + if (!refreshPromise) { + refreshPromise = AuthService.refreshAccessToken() + // A transient refresh failure shouldn't reject every waiting request; treat as "no new token". + .catch(() => null) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} + +// Named business areas used as the Sentry `domain` tag. API errors map to these by endpoint; +// unmapped endpoints fall through to their raw path segment (see getApiDomain). +export enum SentryDomain { + Kyc = "kyc", + Kyb = "kyb", + Quote = "quote", + Ramp = "ramp", + Auth = "auth", + Wallet = "wallet" +} + +// Errors carrying a `domain` are tagged by business area in Sentry's beforeSend. +// Implemented by ApiError and SignRampError. +export interface DomainError { + domain: string; +} + +export class ApiError extends Error implements DomainError { status: number; data: { error?: string; message?: string; details?: string }; + domain: string; - constructor(status: number, data: { error?: string; message?: string; details?: string }, message: string) { + constructor(status: number, data: { error?: string; message?: string; details?: string }, message: string, domain: string) { super(message); this.status = status; this.data = data; + this.domain = domain; } } @@ -16,6 +51,41 @@ export function isApiError(error: unknown): error is ApiError { return error instanceof ApiError; } +// Replace dynamic path segments (uuids, numeric ids, wallet addresses/long tokens) with ":id" +// so Sentry groups errors by endpoint instead of creating one issue per id. Also keeps +// addresses out of issue titles. +function normalizePath(path: string): string { + // Split off any query string first so the last path segment is followed by end-of-string + // (the `(?=\/|$)` lookaheads wouldn't match a segment trailed by "?"), and so query params — + // which can carry ids/addresses — never reach the error message or Sentry issue title. + const [rawPath, query] = path.split("?"); + const normalized = rawPath + .replace(/\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?=\/|$)/g, "/:id") + .replace(/\/\d+(?=\/|$)/g, "/:id") + .replace(/\/[A-Za-z0-9]{20,}(?=\/|$)/g, "/:id"); + return query === undefined ? normalized : `${normalized}?[Filtered]`; +} + +const DOMAIN_BY_SEGMENT: Record = { + alfredpay: SentryDomain.Kyc, + brla: SentryDomain.Kyc, + mykobo: SentryDomain.Kyc, + quotes: SentryDomain.Quote, + ramp: SentryDomain.Ramp, + siwe: SentryDomain.Auth, + subsidize: SentryDomain.Ramp +}; + +// Map an endpoint path to a business domain for Sentry tagging. Unmapped endpoints fall +// through to their raw path segment. Strip any query string first so params (which can carry +// ids/addresses) never leak into the domain tag when a caller inlines them in the path. +function getApiDomain(path: string): string { + const pathWithoutQuery = path.split("?")[0]; + if (pathWithoutQuery.toLowerCase().includes("kyb")) return SentryDomain.Kyb; + const segment = pathWithoutQuery.split("/").filter(Boolean)[0]?.toLowerCase() ?? "api"; + return DOMAIN_BY_SEGMENT[segment] ?? segment; +} + async function apiFetch( method: string, path: string, @@ -26,8 +96,6 @@ async function apiFetch( signal?: AbortSignal; } = {} ): Promise { - const tokens = AuthService.getTokens(); - const url = new URL(`${SIGNING_SERVICE_URL}/v1${path}`, window.location.origin); if (options.params) { for (const [key, value] of Object.entries(options.params)) { @@ -36,24 +104,40 @@ async function apiFetch( } const isFormData = options.data instanceof FormData; + const body = isFormData ? (options.data as FormData) : options.data !== undefined ? JSON.stringify(options.data) : undefined; - const response = await fetch(url.toString(), { - body: isFormData ? (options.data as FormData) : options.data !== undefined ? JSON.stringify(options.data) : undefined, - headers: { - ...(tokens?.accessToken ? { Authorization: `Bearer ${tokens.accessToken}` } : {}), - ...(!isFormData ? { "Content-Type": "application/json" } : {}), - ...options.headers - }, - method, - signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000) - }); + const doFetch = (accessToken: string | undefined) => + fetch(url.toString(), { + body, + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + ...(!isFormData ? { "Content-Type": "application/json" } : {}), + ...options.headers + }, + method, + signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000) + }); + + const initialTokens = AuthService.getTokens(); + let response = await doFetch(initialTokens?.accessToken); + + if (response.status === 401 && initialTokens?.accessToken) { + const refreshed = await refreshTokenOnce(); + if (refreshed?.accessToken) { + response = await doFetch(refreshed.accessToken); + } + } if (!response.ok) { const errorData = (await response.json().catch(() => ({}))) as { error?: string; message?: string }; console.error("API Error:", errorData); const serverMessage = errorData.error ?? errorData.message ?? response.statusText; - // Prefix with method/status/path so Sentry groups by endpoint instead of one generic bucket. - throw new ApiError(response.status, errorData, `${method.toUpperCase()} ${path} (${response.status}): ${serverMessage}`); + // Keep the message clean for the user; the endpoint/status prefix lives on `name` so Sentry + // still groups by endpoint (one issue per endpoint, not per id — hence normalizePath) without + // leaking "POST /path (500):" into user-facing error text. + const error = new ApiError(response.status, errorData, serverMessage, getApiDomain(path)); + error.name = `ApiError ${method.toUpperCase()} ${normalizePath(path)} (${response.status})`; + throw error; } if (response.status === 204) return undefined as T; diff --git a/apps/frontend/src/services/auth.ts b/apps/frontend/src/services/auth.ts index cfd4f89fc..f9f4ca8fb 100644 --- a/apps/frontend/src/services/auth.ts +++ b/apps/frontend/src/services/auth.ts @@ -1,4 +1,6 @@ +import * as Sentry from "@sentry/react"; import { supabase } from "../config/supabase"; +import { SIGNING_SERVICE_URL } from "../constants/constants"; export interface AuthTokens { accessToken: string; @@ -28,6 +30,9 @@ export class AuthService { if (tokens.userEmail) { localStorage.setItem(this.USER_EMAIL_KEY, tokens.userEmail); } + // Attach the pseudonymous Supabase user id for issue-impact counts. Deliberately no email/IP + // so Sentry can count affected users without storing PII. + Sentry.setUser({ id: tokens.userId }); } /** @@ -54,13 +59,46 @@ export class AuthService { localStorage.removeItem(this.REFRESH_TOKEN_KEY); localStorage.removeItem(this.USER_ID_KEY); localStorage.removeItem(this.USER_EMAIL_KEY); + Sentry.setUser(null); } /** * Check if user is authenticated */ static isAuthenticated(): boolean { - return this.getTokens() !== null; + const tokens = this.getTokens(); + if (!tokens) { + return false; + } + const expiryMs = this.decodeJwtExpiryMs(tokens.accessToken); + return expiryMs === null || expiryMs > Date.now(); + } + + /** + * Returns the access token expiry as epoch milliseconds, or null if it can't be decoded. + */ + static getAccessTokenExpiryMs(): number | null { + const tokens = this.getTokens(); + if (!tokens) { + return null; + } + return this.decodeJwtExpiryMs(tokens.accessToken); + } + + private static decodeJwtExpiryMs(token: string): number | null { + try { + const payload = token.split(".")[1]; + if (!payload) { + return null; + } + // JWT segments are base64url and usually unpadded; convert to base64 and re-pad before decoding. + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + const decoded = JSON.parse(atob(padded)) as { exp?: number }; + return typeof decoded.exp === "number" ? decoded.exp * 1000 : null; + } catch { + return null; + } } /** @@ -89,7 +127,12 @@ export class AuthService { } /** - * Refresh access token + * Refresh the access token via the backend `/auth/refresh` endpoint. + * + * Returns the new tokens on success, or `null` when the refresh token is confirmed + * invalid/revoked (a 401 from the backend) — in which case the session is cleared. + * Transient failures (network errors, timeouts, 5xx) throw so callers can retry + * without destroying a still-valid session. */ static async refreshAccessToken(): Promise { const tokens = this.getTokens(); @@ -97,45 +140,36 @@ export class AuthService { return null; } - try { - const { data, error } = await supabase.auth.refreshSession({ - refresh_token: tokens.refreshToken - }); - - if (error || !data.session || !data.user) { - this.clearTokens(); - return null; - } + const response = await fetch(`${SIGNING_SERVICE_URL}/v1/auth/refresh`, { + body: JSON.stringify({ refresh_token: tokens.refreshToken }), + headers: { "Content-Type": "application/json" }, + method: "POST", + signal: AbortSignal.timeout(30000) + }); - const newTokens: AuthTokens = { - accessToken: data.session.access_token, - refreshToken: data.session.refresh_token, - userEmail: data.user.email, - userId: data.user.id - }; - - this.storeTokens(newTokens); - return newTokens; - } catch (error) { - console.error("Token refresh failed:", error); + // A 401 means the refresh token itself is invalid/revoked: the session is dead. + if (response.status === 401) { this.clearTokens(); return null; } - } - /** - * Setup auto-refresh (refresh 5 minutes before expiry) - */ - static setupAutoRefresh(): () => void { - const REFRESH_INTERVAL = 55 * 60 * 1000; // 55 minutes + // Any other non-OK status is transient — keep the session and let the caller retry. + if (!response.ok) { + throw new Error(`Token refresh failed with status ${response.status}`); + } - const intervalId = setInterval(async () => { - if (this.isAuthenticated()) { - await this.refreshAccessToken(); - } - }, REFRESH_INTERVAL); + const data = (await response.json()) as { access_token: string; refresh_token: string }; + + // The refresh endpoint does not return identity; it is unchanged across a refresh. + const newTokens: AuthTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + userEmail: tokens.userEmail, + userId: tokens.userId + }; - return () => clearInterval(intervalId); + this.storeTokens(newTokens); + return newTokens; } /** diff --git a/apps/frontend/src/services/initialChecks.ts b/apps/frontend/src/services/initialChecks.ts index 3bedea44b..48d592122 100644 --- a/apps/frontend/src/services/initialChecks.ts +++ b/apps/frontend/src/services/initialChecks.ts @@ -5,6 +5,7 @@ import { useToastMessage } from "../helpers/notifications"; import { useRampDirection } from "../stores/rampDirectionStore"; import { RampExecutionInput } from "../types/phases"; import { BrlaService } from "./api"; +import { AuthService } from "./auth"; function useRampAmountWithinAllowedLimits() { const { t } = useTranslation(); @@ -13,6 +14,13 @@ function useRampAmountWithinAllowedLimits() { return useCallback( async (amountUnits: string, taxId: string): Promise => { + // /brla/getUser and /brla/getUserRemainingLimit require an authenticated (effective) user. + // The Confirm click can happen before login, so skip the pre-flight here — the backend + // enforces limits authoritatively at ramp registration. + if (!AuthService.isAuthenticated()) { + return true; + } + try { const subaccount = await BrlaService.getUser(taxId); // This check passes if subaccount is not created, or if identity status is not confirmed diff --git a/apps/frontend/src/test/fakeRampActor.ts b/apps/frontend/src/test/fakeRampActor.ts new file mode 100644 index 000000000..16cbb27dd --- /dev/null +++ b/apps/frontend/src/test/fakeRampActor.ts @@ -0,0 +1,65 @@ +import { RampState } from "../types/phases"; + +interface FakeRampContext { + initializeFailedMessage?: string; + isSep24Redo?: boolean; + rampState?: RampState; + walletLocked?: string; +} + +interface FakeSnapshot { + context: FakeRampContext; + // Components query machine states via useSelector(state => state.matches(...)). + // The fake never enters a named state. + matches: (state: string) => boolean; +} + +type Listener = (snapshot: FakeSnapshot) => void; +// xstate actors accept either a callback or a partial observer; @xstate/react uses the +// observer form, so the fake must support both. +type ListenerOrObserver = Listener | { next?: Listener }; + +export interface FakeRampActor { + events: Array<{ type: string } & Record>; + getSnapshot: () => FakeSnapshot; + send: (event: { type: string } & Record) => void; + setRampState: (rampState: RampState | undefined) => void; + subscribe: (listener: ListenerOrObserver) => { unsubscribe: () => void }; +} + +// Minimal stand-in for the ramp machine actor: stable snapshot identity until a +// change occurs (required by useSyncExternalStore-based `useSelector`), records +// every sent event, and applies SET_RAMP_STATE like the real machine does. +export function createFakeRampActor(initialRampState?: RampState): FakeRampActor { + const matches = () => false; + let snapshot: FakeSnapshot = { context: { rampState: initialRampState }, matches }; + const listeners = new Set(); + const events: FakeRampActor["events"] = []; + + const update = (context: FakeRampContext) => { + snapshot = { context, matches }; + for (const listener of listeners) { + listener(snapshot); + } + }; + + return { + events, + getSnapshot: () => snapshot, + send: event => { + events.push(event); + if (event.type === "SET_RAMP_STATE") { + update({ ...snapshot.context, rampState: event.rampState as RampState }); + } + }, + setRampState: rampState => { + update({ ...snapshot.context, rampState }); + }, + subscribe: listenerOrObserver => { + const listener: Listener = + typeof listenerOrObserver === "function" ? listenerOrObserver : snapshot => listenerOrObserver.next?.(snapshot); + listeners.add(listener); + return { unsubscribe: () => listeners.delete(listener) }; + } + }; +} diff --git a/apps/frontend/src/test/fixtures.ts b/apps/frontend/src/test/fixtures.ts new file mode 100644 index 000000000..427e37837 --- /dev/null +++ b/apps/frontend/src/test/fixtures.ts @@ -0,0 +1,94 @@ +import { + EPaymentMethod, + EvmTransactionData, + Networks, + QuoteResponse, + RampCurrency, + RampDirection, + RampPhase, + RampProcess, + SignedTypedData, + UnsignedTx +} from "@vortexfi/shared"; + +export function buildQuoteResponse(overrides: Partial = {}): QuoteResponse { + return { + anchorFeeFiat: "0.5", + anchorFeeUsd: "0.1", + createdAt: new Date("2026-07-05T10:00:00Z"), + expiresAt: new Date("2026-07-05T10:10:00Z"), + feeCurrency: "BRL" as RampCurrency, + from: EPaymentMethod.PIX, + id: "quote-1", + inputAmount: "150", + inputCurrency: "BRL" as RampCurrency, + network: Networks.Base, + networkFeeFiat: "0.2", + networkFeeUsd: "0.04", + outputAmount: "25.5", + outputCurrency: "USDC" as RampCurrency, + partnerFeeFiat: "0", + partnerFeeUsd: "0", + paymentMethod: EPaymentMethod.PIX, + processingFeeFiat: "0.5", + processingFeeUsd: "0.1", + rampType: RampDirection.BUY, + to: Networks.Base, + totalFeeFiat: "0.7", + totalFeeUsd: "0.14", + vortexFeeFiat: "0", + vortexFeeUsd: "0", + ...overrides + }; +} + +export function buildEvmTransactionData(overrides: Partial = {}): EvmTransactionData { + return { + data: "0x", + gas: "21000", + to: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + value: "0", + ...overrides + }; +} + +export function buildUnsignedTx(overrides: Partial = {}): UnsignedTx { + return { + meta: {}, + network: Networks.Base, + nonce: 0, + phase: "squidRouterSwap", + signer: "0x1111111111111111111111111111111111111111", + txData: buildEvmTransactionData(), + ...overrides + }; +} + +export function buildSignedTypedData(overrides: Partial = {}): SignedTypedData { + return { + domain: { name: "Permit2", verifyingContract: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", version: "1" }, + message: { amount: "1" }, + primaryType: "PermitTransferFrom", + types: { PermitTransferFrom: [{ name: "amount", type: "uint256" }] }, + ...overrides + }; +} + +export function buildRampProcess(currentPhase: RampPhase, overrides: Partial = {}): RampProcess { + return { + createdAt: new Date().toISOString(), + currentPhase, + from: EPaymentMethod.PIX, + id: "ramp-123", + inputAmount: "150", + inputCurrency: "BRL", + outputAmount: "25.5", + outputCurrency: "USDC", + paymentMethod: EPaymentMethod.PIX, + quoteId: "quote-1", + to: Networks.Base, + type: RampDirection.BUY, + updatedAt: new Date().toISOString(), + ...overrides + }; +} diff --git a/apps/frontend/src/test/i18n.ts b/apps/frontend/src/test/i18n.ts new file mode 100644 index 000000000..869127a9a --- /dev/null +++ b/apps/frontend/src/test/i18n.ts @@ -0,0 +1,18 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import enTranslations from "../translations/en.json"; + +// Mirrors the production init in src/main.tsx, restricted to English. +if (!i18n.isInitialized) { + i18n.use(initReactI18next).init({ + fallbackLng: "en", + lng: "en", + resources: { + en: { + translation: enTranslations + } + } + }); +} + +export default i18n; diff --git a/apps/frontend/src/test/msw-server.ts b/apps/frontend/src/test/msw-server.ts new file mode 100644 index 000000000..07e228c5d --- /dev/null +++ b/apps/frontend/src/test/msw-server.ts @@ -0,0 +1,8 @@ +import { setupServer } from "msw/node"; + +// Shared MSW server. Tests register handlers per-test via `server.use(...)`; +// handlers are reset after each test in src/test/setup.ts. +export const server = setupServer(); + +// Matches SIGNING_SERVICE_URL ("http://localhost:3000") + the "/v1" prefix used by api-client. +export const API_BASE_URL = "http://localhost:3000/v1"; diff --git a/apps/frontend/src/test/setup.ts b/apps/frontend/src/test/setup.ts new file mode 100644 index 000000000..cdd94c10b --- /dev/null +++ b/apps/frontend/src/test/setup.ts @@ -0,0 +1,72 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { server } from "./msw-server"; + +// jsdom doesn't implement matchMedia; @walletconnect/modal calls it at import time. +if (typeof window !== "undefined" && !window.matchMedia) { + window.matchMedia = (query: string) => + ({ + addEventListener: () => undefined, + addListener: () => undefined, + dispatchEvent: () => false, + matches: false, + media: query, + onchange: null, + removeEventListener: () => undefined, + removeListener: () => undefined + }) as MediaQueryList; +} + +// jsdom doesn't implement the Web Animations API; motion/react and torph call these. +if (typeof Element !== "undefined") { + if (!Element.prototype.getAnimations) { + Element.prototype.getAnimations = () => []; + } + if (!Element.prototype.animate) { + // `finished` intentionally never resolves: an instantly-finished animation makes + // animation libraries restart in a tight loop. + Element.prototype.animate = () => + ({ + cancel: () => undefined, + finish: () => undefined, + finished: new Promise(() => undefined), + onfinish: null, + pause: () => undefined, + play: () => undefined, + reverse: () => undefined + }) as unknown as Animation; + } +} + +// jsdom doesn't implement IntersectionObserver; motion/react's whileInView needs it. +if (typeof window !== "undefined" && !window.IntersectionObserver) { + window.IntersectionObserver = class { + readonly root = null; + readonly rootMargin = ""; + readonly thresholds = []; + disconnect() { + return undefined; + } + observe() { + return undefined; + } + takeRecords() { + return []; + } + unobserve() { + return undefined; + } + } as unknown as typeof IntersectionObserver; +} + +// "bypass" keeps pre-existing node-environment tests untouched: any request they make +// behaves exactly as before this setup file existed. +beforeAll(() => server.listen({ onUnhandledRequest: "bypass" })); + +afterEach(() => { + server.resetHandlers(); + cleanup(); +}); + +afterAll(() => server.close()); diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 8d03cdc37..0cf61d648 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1290,6 +1290,7 @@ "sell": { "ARS": "Your funds will arrive in your bank account in a few minutes.", "BRL": "Your funds were sent via PIX and are now in your bank account.", + "COP": "Your funds will arrive in your bank account in a few minutes.", "default": "Your funds will arrive in your bank account soon.", "EURC": "Funds will be received via Standard SEPA within 1-2 business days.", "MXN": "Your funds will arrive in your bank account via SPEI in a few minutes.", diff --git a/apps/frontend/src/translations/helpers.test.ts b/apps/frontend/src/translations/helpers.test.ts index 6f3c23a5d..7e0ec36f2 100644 --- a/apps/frontend/src/translations/helpers.test.ts +++ b/apps/frontend/src/translations/helpers.test.ts @@ -157,34 +157,3 @@ describe('Language Detection Helpers', () => { }); }); }); - -describe('Extensibility Example', () => { - it('demonstrates how easy it would be to add Spanish support', () => { - expect(Object.keys(LANGUAGE_FAMILIES)).toHaveLength(2); - - const extendedFamilies = { - ...LANGUAGE_FAMILIES, - es: 'es' as any - }; - - expect(Object.keys(extendedFamilies)).toHaveLength(3); - expect(extendedFamilies.es).toBe('es'); - }); - - it('demonstrates the simplicity of the language code extraction', () => { - - const testCases = [ - { input: 'pt-BR', expected: 'pt' }, - { input: 'pt-PT', expected: 'pt' }, - { input: 'en-US', expected: 'en' }, - { input: 'en-GB', expected: 'en' }, - { input: 'es-ES', expected: 'es' }, - { input: 'es-MX', expected: 'es' } - ]; - - testCases.forEach(({ input, expected }) => { - const languageCode = input.toLowerCase().split('-')[0]; - expect(languageCode).toBe(expected); - }); - }); -}); diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 2423abdb9..a584e7279 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1295,6 +1295,7 @@ "sell": { "ARS": "Em breve os fundos estarão disponíveis em sua conta bancária.", "BRL": "Em breve os fundos estarão disponíveis em sua conta bancária..", + "COP": "Em breve os fundos estarão disponíveis em sua conta bancária.", "default": "Em breve os fundos estarão disponíveis em sua conta bancária.", "EURC": "Você receberá os fundos em até 1 minuto (SEPA Instantâneo) ou em até 2 dias (SEPA Padrão), dependendo do seu banco.", "MXN": "Seus fundos chegarão à sua conta bancária via SPEI em alguns minutos.", diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts new file mode 100644 index 000000000..9c1de7737 --- /dev/null +++ b/apps/frontend/vitest.config.ts @@ -0,0 +1,43 @@ +import { coverageConfigDefaults, defineConfig } from "vitest/config"; + +// Dedicated Vitest config so test runs don't load the full Vite build pipeline +// (tanstack router codegen, Sentry upload plugin, tailwind) from vite.config.ts. +export default defineConfig({ + test: { + coverage: { + // Keep the denominator to shippable app code: no Playwright support code, + // Storybook stories, generated files (route tree, contract ABIs), or test infra. + exclude: [ + ...coverageConfigDefaults.exclude, + "e2e/**", + "playwright.config.ts", + "src/stories/**", + "src/contracts/**", + "src/routeTree.gen.ts", + "src/setupTests.ts", + "src/test/**" + ], + provider: "v8", + // lcov feeds scripts/coverage-report.ts (root `bun run test:coverage`). + reporter: ["text-summary", "lcov"], + // Ratchet floors, set just under the coverage measured when they were + // last raised (enforced by `bun run test:coverage`, which CI runs). + // Raise them as tested code grows; never lower them to make CI pass. + thresholds: { + functions: 43, + lines: 25.8 + } + }, + // Dummy values so src/config/supabase.ts (pulled in transitively via services/auth) + // doesn't throw at import time; keeps the suite hermetic (no real credentials in CI). + // ".invalid" never resolves, so an accidental real call fails instead of hitting a live project. + env: { + VITE_SUPABASE_ANON_KEY: "test-anon-key", + VITE_SUPABASE_URL: "http://supabase.invalid" + }, + environment: "node", + globals: false, + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + setupFiles: ["./src/test/setup.ts"] + } +}); diff --git a/apps/rebalancer/.env.example b/apps/rebalancer/.env.example index 617565554..9c79b66f1 100644 --- a/apps/rebalancer/.env.example +++ b/apps/rebalancer/.env.example @@ -11,6 +11,8 @@ BRLA_LOGIN_PASSWORD=your_password_here SLACK_WEB_HOOK_TOKEN=your_slack_webhook_token_here REBALANCING_USD_TO_BRL_AMOUNT=100 +# Larger USDC amount evaluated for USDC→BRLA→USDC and used when that larger quote is projected profitable. +REBALANCING_PROFITABLE_USD_TO_BRL_AMOUNT=200 SUPABASE_URL=your_supabase_url_here SUPABASE_SERVICE_KEY=your_supabase_service_key_here diff --git a/apps/rebalancer/README.md b/apps/rebalancer/README.md index e74981273..d03276c98 100644 --- a/apps/rebalancer/README.md +++ b/apps/rebalancer/README.md @@ -21,6 +21,10 @@ PENDULUM_ACCOUNT_SECRET=xxx For Base rebalancing, the in-range opportunistic USDC→BRLA→USDC trigger is controlled by `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` and defaults to `10` bps when unset. +USDC→BRLA→USDC runs quote `REBALANCING_USD_TO_BRL_AMOUNT` by default. When +`REBALANCING_PROFITABLE_USD_TO_BRL_AMOUNT` is set to a different value, the rebalancer also evaluates that larger +amount with fresh quotes and uses it only if the larger amount is projected profitable. When unset, it defaults to the +standard amount. `REBALANCING_DAILY_BRIDGE_LIMIT_USD` caps paid Base rebalances only: projected-profitable current runs bypass the cap, but all completed Base runs are recorded in history and count toward later paid-run limit checks. diff --git a/apps/rebalancer/bunfig.toml b/apps/rebalancer/bunfig.toml new file mode 100644 index 000000000..3b075d0e5 --- /dev/null +++ b/apps/rebalancer/bunfig.toml @@ -0,0 +1,6 @@ +[test] +# Coverage report config; the gate itself lives in scripts/check-coverage.ts. +# Without the ignore pattern, the built shared bundle (~158k lines) dominates the +# denominator and the ratchet measures shared's coverage instead of this app's. +coverageSkipTestFiles = true +coveragePathIgnorePatterns = ["**/packages/shared/dist/**"] diff --git a/apps/rebalancer/package.json b/apps/rebalancer/package.json index 64b3f1389..8bb1c5764 100644 --- a/apps/rebalancer/package.json +++ b/apps/rebalancer/package.json @@ -35,7 +35,8 @@ "prepare": "husky", "serve": "bun dist/index.js", "start": "bun run build && bun run serve", - "test": "bun test" + "test": "bun test", + "test:coverage": "bun test --coverage --coverage-reporter=lcov && bun ../../scripts/check-coverage.ts coverage/lcov.info 0.33 0.50" }, "type": "module" } diff --git a/apps/rebalancer/src/index.ts b/apps/rebalancer/src/index.ts index b3ab979b7..5ad243391 100644 --- a/apps/rebalancer/src/index.ts +++ b/apps/rebalancer/src/index.ts @@ -6,6 +6,7 @@ import { checkInitialPendulumBalance } from "./rebalance/brla-to-axlusdc/steps.t import { rebalanceBrlaToUsdcBase } from "./rebalance/brla-to-usdc-base"; import { quoteBrlaToUsdcBaseRebalance } from "./rebalance/brla-to-usdc-base/steps.ts"; import { rebalanceUsdcBrlaUsdcBase } from "./rebalance/usdc-brla-usdc-base"; +import { selectEvaluatedUsdcToBrlaAmount, selectUsdcToBrlaAmount } from "./rebalance/usdc-brla-usdc-base/amountPolicy.ts"; import { evaluatePaidRunDailyLimit, sumTodayBridgedUsdRaw } from "./rebalance/usdc-brla-usdc-base/dailyLimit.ts"; import { type DailyBridgeLimitDecision, @@ -276,11 +277,69 @@ async function executeUsdcToBrlaRebalance( return true; } +function toUsdcRaw(amountUsdc: string): string { + return multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); +} + +// Squidrouter's per-address rate limit is tight enough that two route quotes fired back-to-back +// (standard then profitable amount) can trigger "Too many quote requests for this address". +const SQUIDROUTER_QUOTE_STAGGER_MS = 1500; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function selectUsdcToBrlaPolicyAmount(coverageDeviationBps: number): Promise<{ + amountUsdcRaw: string; + policyDecision: Awaited>; +}> { + const config = getConfig(); + const standardAmountSelection = selectUsdcToBrlaAmount( + config.rebalancingUsdToBrlAmount, + config.rebalancingUsdToBrlAmount, + false, + manualAmount + ); + const standardAmountRaw = toUsdcRaw(standardAmountSelection.amountUsdc); + const standardPolicyDecision = await evaluateUsdcToBrlaPolicy(standardAmountRaw, coverageDeviationBps); + + if (standardAmountSelection.reason === "manual") { + return { amountUsdcRaw: standardAmountRaw, policyDecision: standardPolicyDecision }; + } + + const profitableAmountRaw = toUsdcRaw(config.rebalancingProfitableUsdToBrlAmount); + if (profitableAmountRaw === standardAmountRaw) { + return { amountUsdcRaw: standardAmountRaw, policyDecision: standardPolicyDecision }; + } + + console.log( + `Evaluating USDC->BRLA rebalance amounts independently: standard ${standardAmountSelection.amountUsdc} USDC, ` + + `profitable ${config.rebalancingProfitableUsdToBrlAmount} USDC.` + ); + + await sleep(SQUIDROUTER_QUOTE_STAGGER_MS); + const profitablePolicyDecision = await evaluateUsdcToBrlaPolicy(profitableAmountRaw, coverageDeviationBps); + + const selectedAmount = selectEvaluatedUsdcToBrlaAmount( + { amountUsdc: standardAmountSelection.amountUsdc, projectedProfitable: standardPolicyDecision.profitable }, + { amountUsdc: config.rebalancingProfitableUsdToBrlAmount, projectedProfitable: profitablePolicyDecision.profitable }, + null + ); + + if (selectedAmount.reason !== "profitable") { + console.log( + `Configured profitable amount ${config.rebalancingProfitableUsdToBrlAmount} USDC is not projected profitable. ` + + `Using standard amount ${standardAmountSelection.amountUsdc} USDC.` + ); + return { amountUsdcRaw: standardAmountRaw, policyDecision: standardPolicyDecision }; + } + + return { amountUsdcRaw: profitableAmountRaw, policyDecision: profitablePolicyDecision }; +} + async function tryOpportunisticUsdcToBrla(): Promise { const config = getConfig(); - const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; - const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); - const policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, 0); + const { amountUsdcRaw, policyDecision } = await selectUsdcToBrlaPolicyAmount(0); const opportunisticMaxCostBps = config.rebalancingCostPolicy.opportunisticUsdcToBrlaMaxCostBps; if (!policyDecision.shouldExecute) return false; @@ -329,17 +388,17 @@ async function evaluateBrlaToUsdcPolicy( async function runUsdcToBrla(coverageDeviationBps: number) { const config = getConfig(); - const amountUsdc = manualAmount || config.rebalancingUsdToBrlAmount; - const amountUsdcRaw = multiplyByPowerOfTen(new Big(amountUsdc), 6).toFixed(0, 0); + const amountUsdcRaw = toUsdcRaw(manualAmount || config.rebalancingUsdToBrlAmount); const stateManager = new UsdcBaseStateManager(); const state = await stateManager.getState(); const isResuming = !forceRestart && state && state.currentPhase !== UsdcBaseRebalancePhase.Idle; if (!isResuming) { - const policyDecision = await evaluateUsdcToBrlaPolicy(amountUsdcRaw, coverageDeviationBps); + const selectedAmount = await selectUsdcToBrlaPolicyAmount(coverageDeviationBps); + const policyDecision = selectedAmount.policyDecision; if (!policyDecision.shouldExecute) return; - await executeUsdcToBrlaRebalance(amountUsdcRaw, coverageDeviationBps, policyDecision); + await executeUsdcToBrlaRebalance(selectedAmount.amountUsdcRaw, coverageDeviationBps, policyDecision); return; } diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/amountPolicy.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/amountPolicy.test.ts new file mode 100644 index 000000000..0e2a3e5c8 --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/amountPolicy.test.ts @@ -0,0 +1,55 @@ +import {describe, expect, test} from "bun:test"; +import {selectEvaluatedUsdcToBrlaAmount, selectUsdcToBrlaAmount} from "./amountPolicy.ts"; + +describe("USDC to BRLA amount policy", () => { + test("uses the standard amount when the projection is not profitable", () => { + expect(selectUsdcToBrlaAmount("1000", "2000", false, null)).toEqual({ + amountUsdc: "1000", + reason: "standard" + }); + }); + + test("uses the profitable amount when the profitable amount projection is profitable", () => { + expect(selectUsdcToBrlaAmount("1000", "2000", true, null)).toEqual({ + amountUsdc: "2000", + reason: "profitable" + }); + }); + + test("keeps manual amounts explicit", () => { + expect(selectUsdcToBrlaAmount("1000", "2000", true, "750")).toEqual({ + amountUsdc: "750", + reason: "manual" + }); + }); + + test("selects the larger evaluated amount even when the standard amount is not profitable", () => { + expect( + selectEvaluatedUsdcToBrlaAmount( + { amountUsdc: "1000", projectedProfitable: false }, + { amountUsdc: "2000", projectedProfitable: true }, + null + ) + ).toEqual({ amountUsdc: "2000", reason: "profitable" }); + }); + + test("falls back to the standard evaluated amount when the larger amount is not profitable", () => { + expect( + selectEvaluatedUsdcToBrlaAmount( + { amountUsdc: "1000", projectedProfitable: true }, + { amountUsdc: "2000", projectedProfitable: false }, + null + ) + ).toEqual({ amountUsdc: "1000", reason: "standard" }); + }); + + test("keeps manual amounts explicit after evaluating both configured amounts", () => { + expect( + selectEvaluatedUsdcToBrlaAmount( + { amountUsdc: "1000", projectedProfitable: false }, + { amountUsdc: "2000", projectedProfitable: true }, + "750" + ) + ).toEqual({ amountUsdc: "750", reason: "manual" }); + }); +}); diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/amountPolicy.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/amountPolicy.ts new file mode 100644 index 000000000..2803fc02e --- /dev/null +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/amountPolicy.ts @@ -0,0 +1,34 @@ +export interface UsdcToBrlaAmountSelection { + amountUsdc: string; + reason: "manual" | "standard" | "profitable"; +} + +export interface EvaluatedUsdcToBrlaAmount { + amountUsdc: string; + projectedProfitable: boolean; +} + +export function selectUsdcToBrlaAmount( + standardAmount: string, + profitableAmount: string, + isProfitableAmountProjectedProfitable: boolean, + manualAmount: string | null +): UsdcToBrlaAmountSelection { + if (manualAmount) return { amountUsdc: manualAmount, reason: "manual" }; + + if (isProfitableAmountProjectedProfitable) return { amountUsdc: profitableAmount, reason: "profitable" }; + + return { amountUsdc: standardAmount, reason: "standard" }; +} + +export function selectEvaluatedUsdcToBrlaAmount( + standard: EvaluatedUsdcToBrlaAmount, + profitable: EvaluatedUsdcToBrlaAmount, + manualAmount: string | null +): UsdcToBrlaAmountSelection { + if (manualAmount) return { amountUsdc: manualAmount, reason: "manual" }; + + if (profitable.projectedProfitable) return { amountUsdc: profitable.amountUsdc, reason: "profitable" }; + + return { amountUsdc: standard.amountUsdc, reason: "standard" }; +} diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts index ad8ce851d..3882885c8 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.test.ts @@ -4,10 +4,90 @@ import { ensurePolygonBrlaAvailableForSquidSwap, recoverAveniaPolygonTransferFromBalance, recoverSquidUsdcOutputFromBaseBalance, + resetFailedNablaSwapOnResume, resetFailedSquidRouterSwapOnResume } from "./steps.ts"; describe("USDC Base SquidRouter steps", () => { + test("clears a persisted Nabla swap when the Base receipt failed", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.NablaApprove); + state.nablaSwapHash = "0xfailed"; + + const savedStates: Array<{ nablaSwapHash: string | null }> = []; + const stateManager = { + saveState: async () => { + savedStates.push({ nablaSwapHash: state.nablaSwapHash }); + } + }; + const publicClient = { + getTransactionReceipt: async () => ({ status: "reverted" as const }) + }; + + await expect(resetFailedNablaSwapOnResume("0xfailed", state, stateManager, publicClient)).resolves.toBe(true); + expect(state.nablaSwapHash).toBeNull(); + expect(savedStates).toEqual([{ nablaSwapHash: null }]); + }); + + test("keeps a persisted Nabla swap when the Base receipt succeeded", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.NablaApprove); + state.nablaSwapHash = "0xsuccess"; + + const stateManager = { + saveState: async () => { + throw new Error("successful receipts should not rewrite state"); + } + }; + const publicClient = { + getTransactionReceipt: async () => ({ status: "success" as const }) + }; + + await expect(resetFailedNablaSwapOnResume("0xsuccess", state, stateManager, publicClient)).resolves.toBe(false); + expect(state.nablaSwapHash).toBe("0xsuccess"); + }); + + test("clears a stale persisted Nabla swap when the Base receipt is missing", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.NablaApprove); + state.nablaSwapHash = "0xmissing"; + state.updatedTime = new Date(Date.now() - 16 * 60_000).toISOString(); + + const savedStates: Array<{ nablaSwapHash: string | null }> = []; + const stateManager = { + saveState: async () => { + savedStates.push({ nablaSwapHash: state.nablaSwapHash }); + } + }; + const publicClient = { + getTransactionReceipt: async () => { + throw new Error("Transaction not found"); + } + }; + + await expect(resetFailedNablaSwapOnResume("0xmissing", state, stateManager, publicClient)).resolves.toBe(true); + expect(state.nablaSwapHash).toBeNull(); + expect(savedStates).toEqual([{ nablaSwapHash: null }]); + }); + + test("keeps a fresh persisted Nabla swap when the Base receipt is temporarily missing", async () => { + const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.NablaApprove); + state.nablaSwapHash = "0xmissing"; + + const stateManager = { + saveState: async () => { + throw new Error("fresh missing receipts should not rewrite state"); + } + }; + const publicClient = { + getTransactionReceipt: async () => { + throw new Error("Transaction not found"); + } + }; + + await expect(resetFailedNablaSwapOnResume("0xmissing", state, stateManager, publicClient)).rejects.toThrow( + "Transaction not found" + ); + expect(state.nablaSwapHash).toBe("0xmissing"); + }); + test("clears a persisted SquidRouter swap when the Polygon receipt failed", async () => { const state = createUsdcBaseRebalanceState("1000000000", UsdcBaseRebalancePhase.SquidRouterApproveAndSwap); state.squidRouterSwapHash = "0xfailed"; diff --git a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts index fe318a8bf..9a9a504ff 100644 --- a/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts +++ b/apps/rebalancer/src/rebalance/usdc-brla-usdc-base/steps.ts @@ -29,6 +29,18 @@ export const USDC_BASE: `0x${string}` = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02 export const BRLA_POLYGON: `0x${string}` = brlaMoonbeamTokenDetails.polygonErc20Address as `0x${string}`; const NABLA_SWAP_DEADLINE_MINUTES = 60 * 24 * 7; const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; +const STALE_PERSISTED_TX_MINUTES = 15; + +interface TransactionReceiptStatusReader { + getTransactionReceipt(args: { hash: `0x${string}` }): Promise<{ status: "success" | "reverted" }>; +} + +function isPersistedTransactionStale(updatedTime: string): boolean { + const updatedAt = Date.parse(updatedTime); + if (Number.isNaN(updatedAt)) return false; + + return Date.now() - updatedAt > STALE_PERSISTED_TX_MINUTES * 60_000; +} function buildRecoveredBrlaOutput(brlaReceivedRaw: bigint) { const brlaAmountRaw = brlaReceivedRaw.toString(); @@ -203,6 +215,10 @@ export async function nablaApproveAndSwapOnBase( let approveHash = state.nablaApproveHash; let swapHash = state.nablaSwapHash; + if (swapHash && (await resetFailedNablaSwapOnResume(swapHash, state, stateManager, publicClient))) { + swapHash = null; + } + if (!swapHash) { const evmClientManager = EvmClientManager.getInstance(); const quoteAbi = [ @@ -340,6 +356,34 @@ export async function nablaApproveAndSwapOnBase( }; } +export async function resetFailedNablaSwapOnResume( + swapHash: string, + state: UsdcBaseRebalanceState, + stateManager: Pick, + publicClient: TransactionReceiptStatusReader +): Promise { + let receipt: { status: "success" | "reverted" }; + try { + receipt = await publicClient.getTransactionReceipt({ + hash: swapHash as `0x${string}` + }); + } catch (error) { + if (!isPersistedTransactionStale(state.updatedTime)) throw error; + + console.warn(`Persisted Nabla swap tx ${swapHash} was not found after ${STALE_PERSISTED_TX_MINUTES} minutes. Retrying.`); + state.nablaSwapHash = null; + await stateManager.saveState(state); + return true; + } + + if (receipt.status === "success") return false; + + console.warn(`Persisted Nabla swap tx ${swapHash} failed on Base. Retrying with a fresh quote and swap.`); + state.nablaSwapHash = null; + await stateManager.saveState(state); + return true; +} + export async function transferBrlaToAveniaOnBase( brlaAmountRaw: string, baseNonce: NonceManager, diff --git a/apps/rebalancer/src/utils/config.test.ts b/apps/rebalancer/src/utils/config.test.ts index 92f68739f..8d33d0c43 100644 --- a/apps/rebalancer/src/utils/config.test.ts +++ b/apps/rebalancer/src/utils/config.test.ts @@ -1,11 +1,16 @@ import {afterEach, beforeEach, describe, expect, test} from "bun:test"; import { + getConfig, getRebalancingCostPolicyConfig, parseRebalancingDailyBridgeLimitUsd, parseRebalancingPolicyMode } from "./config.ts"; const policyEnvVars = [ + "EVM_ACCOUNT_SECRET", + "REBALANCING_DAILY_BRIDGE_LIMIT_USD", + "REBALANCING_USD_TO_BRL_AMOUNT", + "REBALANCING_PROFITABLE_USD_TO_BRL_AMOUNT", "REBALANCING_POLICY_MODE", "REBALANCING_MODERATE_DEVIATION_BPS", "REBALANCING_SEVERE_DEVIATION_BPS", @@ -128,3 +133,20 @@ describe("getRebalancingCostPolicyConfig", () => { delete process.env.REBALANCING_MAX_COST_BPS_MODERATE; }); }); + +describe("getConfig", () => { + test("defaults the profitable USDC to BRLA amount to the standard amount", () => { + process.env.EVM_ACCOUNT_SECRET = "test test test test test test test test test test test junk"; + process.env.REBALANCING_USD_TO_BRL_AMOUNT = "1000"; + + expect(getConfig().rebalancingProfitableUsdToBrlAmount).toBe("1000"); + }); + + test("allows configuring a larger profitable USDC to BRLA amount", () => { + process.env.EVM_ACCOUNT_SECRET = "test test test test test test test test test test test junk"; + process.env.REBALANCING_USD_TO_BRL_AMOUNT = "1000"; + process.env.REBALANCING_PROFITABLE_USD_TO_BRL_AMOUNT = "2000"; + + expect(getConfig().rebalancingProfitableUsdToBrlAmount).toBe("2000"); + }); +}); diff --git a/apps/rebalancer/src/utils/config.ts b/apps/rebalancer/src/utils/config.ts index 803efe915..32fb80822 100644 --- a/apps/rebalancer/src/utils/config.ts +++ b/apps/rebalancer/src/utils/config.ts @@ -105,6 +105,9 @@ export function getConfig() { rebalancingBrlToUsdMinBalance: process.env.REBALANCING_BRL_TO_USD_MIN_BALANCE || undefined, rebalancingCostPolicy: getRebalancingCostPolicyConfig(), rebalancingDailyBridgeLimitUsd: parseRebalancingDailyBridgeLimitUsd(), + /// The larger USDC amount to evaluate for USDC→BRLA→USDC runs and use when that larger amount is projected profitable. + rebalancingProfitableUsdToBrlAmount: + process.env.REBALANCING_PROFITABLE_USD_TO_BRL_AMOUNT || process.env.REBALANCING_USD_TO_BRL_AMOUNT || "1", /// The threshold above and below the optimal coverage ratio at which the rebalancing will be triggered. rebalancingThreshold: Number(process.env.REBALANCING_THRESHOLD) || 0.01, diff --git a/bun.lock b/bun.lock index 6eaa48629..3ea0c9aab 100644 --- a/bun.lock +++ b/bun.lock @@ -182,6 +182,7 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-typescript": "^7.18.6", "@pendulum-chain/types": "catalog:", + "@playwright/test": "^1.61.1", "@polkadot/types-augment": "catalog:", "@polkadot/types-codec": "catalog:", "@polkadot/types-create": "catalog:", @@ -189,6 +190,10 @@ "@storybook/react-vite": "^9.1.4", "@tanstack/react-query-devtools": "^5.91.1", "@tanstack/router-plugin": "^1.136.8", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/big.js": "catalog:", "@types/bn.js": "^5", "@types/node": "catalog:", @@ -196,6 +201,7 @@ "@types/react-dom": "^19.0.3", "@typescript-eslint/eslint-plugin": "^5.53.0", "@typescript-eslint/parser": "^5.53.0", + "@vitest/coverage-v8": "3.2.4", "babel-preset-vite": "^1.1.3", "daisyui": "^5.5.5", "esbuild": "^0.25.9", @@ -204,7 +210,9 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^9.1.4", "husky": ">=6", + "jsdom": "26", "lint-staged": ">=10", + "msw": "^2.14.6", "prettier": "catalog:", "storybook": "^9.1.4", "ts-node": "^10.9.1", @@ -258,7 +266,7 @@ }, "packages/sdk": { "name": "@vortexfi/sdk", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@vortexfi/shared": "workspace:*", }, @@ -274,7 +282,7 @@ }, "packages/shared": { "name": "@vortexfi/shared", - "version": "0.1.2", + "version": "0.2.0", "dependencies": { "@paraspell/sdk-pjs": "^11.8.5", "@pendulum-chain/api-solang": "catalog:", @@ -394,8 +402,12 @@ "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.10.1", "", {}, "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw=="], + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@ardatan/relay-compiler": ["@ardatan/relay-compiler@13.0.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "immutable": "^5.1.5", "invariant": "^2.2.4" }, "peerDependencies": { "graphql": "*" } }, "sha512-afG3YPwuSA0E5foouZusz5GlXKs74dObv4cuWyLyfKsYFj2r7oGRNB28v18HvwuLSQtQFCi+DpIe0TZkgQDYyg=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], @@ -642,6 +654,8 @@ "@base-org/account": ["@base-org/account@2.4.0", "", { "dependencies": { "@coinbase/cdp-sdk": "^1.0.0", "@noble/hashes": "1.4.0", "clsx": "1.2.1", "eventemitter3": "5.0.1", "idb-keyval": "6.2.1", "ox": "0.6.9", "preact": "10.24.2", "viem": "^2.31.7", "zustand": "5.0.3" } }, "sha512-A4Umpi8B9/pqR78D1Yoze4xHyQaujioVRqqO3d6xuDFw9VRtjg6tK3bPlwE0aW+nVH/ntllCpPa2PbI8Rnjcug=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@biomejs/biome": ["@biomejs/biome@2.0.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.0.0", "@biomejs/cli-darwin-x64": "2.0.0", "@biomejs/cli-linux-arm64": "2.0.0", "@biomejs/cli-linux-arm64-musl": "2.0.0", "@biomejs/cli-linux-x64": "2.0.0", "@biomejs/cli-linux-x64-musl": "2.0.0", "@biomejs/cli-win32-arm64": "2.0.0", "@biomejs/cli-win32-x64": "2.0.0" }, "bin": { "biome": "bin/biome" } }, "sha512-BlUoXEOI/UQTDEj/pVfnkMo8SrZw3oOWBDrXYFT43V7HTkIUDkBRY53IC5Jx1QkZbaB+0ai1wJIfYwp9+qaJTQ=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.0.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QvqWYtFFhhxdf8jMAdJzXW+Frc7X8XsnHQLY+TBM1fnT1TfeV/v9vsFI5L2J7GH6qN1+QEEJ19jHibCY2Ypplw=="], @@ -670,6 +684,16 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], @@ -934,12 +958,24 @@ "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], + + "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], + "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], + + "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="], + "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.6.1", "", { "dependencies": { "glob": "^10.0.0", "magic-string": "^0.30.0", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -1008,6 +1044,8 @@ "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="], + "@napi-rs/nice": ["@napi-rs/nice@1.1.1", "", { "optionalDependencies": { "@napi-rs/nice-android-arm-eabi": "1.1.1", "@napi-rs/nice-android-arm64": "1.1.1", "@napi-rs/nice-darwin-arm64": "1.1.1", "@napi-rs/nice-darwin-x64": "1.1.1", "@napi-rs/nice-freebsd-x64": "1.1.1", "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", "@napi-rs/nice-linux-arm64-gnu": "1.1.1", "@napi-rs/nice-linux-arm64-musl": "1.1.1", "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", "@napi-rs/nice-linux-s390x-gnu": "1.1.1", "@napi-rs/nice-linux-x64-gnu": "1.1.1", "@napi-rs/nice-linux-x64-musl": "1.1.1", "@napi-rs/nice-openharmony-arm64": "1.1.1", "@napi-rs/nice-win32-arm64-msvc": "1.1.1", "@napi-rs/nice-win32-ia32-msvc": "1.1.1", "@napi-rs/nice-win32-x64-msvc": "1.1.1" } }, "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw=="], "@napi-rs/nice-android-arm-eabi": ["@napi-rs/nice-android-arm-eabi@1.1.1", "", { "os": "android", "cpu": "arm" }, "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw=="], @@ -1114,6 +1152,12 @@ "@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="], + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@3.0.0", "", {}, "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA=="], + + "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], + + "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@open-web3/api-mobx": ["@open-web3/api-mobx@1.1.4", "", { "dependencies": { "mobx": "^5.15.7", "mobx-utils": "^5.6.2" }, "peerDependencies": { "@polkadot/api": ">6.3.1" } }, "sha512-MheCFMiGp08i5ukMB8Dai6sNYEpX6UkuCobGIOZzON4K/Yj4mp9jUjzxZ24SCTtGLRwhI3qtUv3AyL06neObnw=="], "@open-web3/orml-api-derive": ["@open-web3/orml-api-derive@1.1.4", "", { "dependencies": { "memoizee": "^0.4.15", "rxjs": "^7.2.0" }, "peerDependencies": { "@polkadot/api": ">6.3.1" } }, "sha512-h8FgrNtA0+PvM1bPu3cJpaMTCGj0pyRbMid2M8XOq1PtQH216Z2CAtrilpQhQIgthJFcfmfirZ+80uG/faWirA=="], @@ -1194,6 +1238,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], + "@polkadot-api/json-rpc-provider": ["@polkadot-api/json-rpc-provider@0.0.1", "", {}, "sha512-/SMC/l7foRjpykLTUTacIH05H3mr9ip8b5xxfwXlVezXrNVLp3Cv0GX6uItkKd+ZjzVPf3PFrDF2B2/HLSNESA=="], "@polkadot-api/json-rpc-provider-proxy": ["@polkadot-api/json-rpc-provider-proxy@0.1.0", "", {}, "sha512-8GSFE5+EF73MCuLQm8tjrbCqlgclcHBSRaswvXziJ0ZW7iw3UEMsKkkKvELayWyBuOPa2T5i1nj6gFOeIsqvrg=="], @@ -1838,6 +1884,8 @@ "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], "@thi.ng/api": ["@thi.ng/api@8.12.25", "", {}, "sha512-5Xixty2r+t6LNixnKpVBICz6R7SINaG2KnUG/CVJ2cuiQhih0RztldItEFYk4aRUFthRibhsG2Ie5ULS7qr9Kg=="], @@ -1996,6 +2044,10 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="], + + "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -2036,6 +2088,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="], + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], @@ -2160,7 +2214,7 @@ "aes-js": ["aes-js@4.0.0-beta.5", "", {}, "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q=="], - "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], @@ -2202,7 +2256,7 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "array-back": ["array-back@3.1.0", "", {}, "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q=="], @@ -2236,6 +2290,8 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], + "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -2564,6 +2620,8 @@ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="], @@ -2572,6 +2630,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], @@ -2592,6 +2652,8 @@ "decamelize": ["decamelize@4.0.0", "", {}, "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], @@ -2652,7 +2714,7 @@ "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], - "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "domain-browser": ["domain-browser@4.22.0", "", {}, "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw=="], @@ -2702,6 +2764,8 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -2846,8 +2910,14 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], @@ -3034,6 +3104,8 @@ "header-case": ["header-case@2.0.4", "", { "dependencies": { "capital-case": "^1.0.4", "tslib": "^2.0.3" } }, "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q=="], + "headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="], + "heap": ["heap@0.2.7", "", {}, "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg=="], "helmet": ["helmet@4.6.0", "", {}, "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg=="], @@ -3046,6 +3118,10 @@ "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], "http-basic": ["http-basic@8.1.3", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^1.6.2", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw=="], @@ -3064,7 +3140,7 @@ "https-browserify": ["https-browserify@1.0.0", "", {}, "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg=="], - "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -3176,6 +3252,8 @@ "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], @@ -3184,6 +3262,8 @@ "is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -3232,6 +3312,14 @@ "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], @@ -3256,6 +3344,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], @@ -3398,6 +3488,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], + "make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="], "make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="], @@ -3500,6 +3592,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], + "multer": ["multer@2.1.1", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "type-is": "^1.6.18" } }, "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A=="], "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], @@ -3570,6 +3664,8 @@ "numora-react": ["numora-react@4.0.0", "", { "peerDependencies": { "numora": ">=4.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-lAN32AIfEfCOofgfpvCib157Psv+2egG48pmDzJnFtnVxnleu8C5AwIWyyyPXzxsY8f4ZyC3X1BKwHnlfXSv8w=="], + "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], + "obj-multiplex": ["obj-multiplex@1.0.0", "", { "dependencies": { "end-of-stream": "^1.4.0", "once": "^1.4.0", "readable-stream": "^2.3.3" } }, "sha512-0GNJAOsHoBHeNTvl5Vt6IWnpUEcc3uSRxzBri7EDyIcMgYvnY2JL2qdeV5zTMjWQX5OHcD5amcW2HFfDh0gjIA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -3618,6 +3714,8 @@ "os-browserify": ["os-browserify@0.3.0", "", {}, "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A=="], + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "ox": ["ox@0.14.25", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-8DoibKtxE8yw63Y2jjMhlbjaURev6WCx4QR4MWLusl2/qIaeTzMJMBIYIDl1KOF45+8H1Ur6eLTdPlUoO8PlRw=="], @@ -3654,6 +3752,8 @@ "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], @@ -3728,6 +3828,10 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "pony-cause": ["pony-cause@2.1.11", "", {}, "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg=="], @@ -3832,7 +3936,7 @@ "react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], @@ -3904,6 +4008,8 @@ "retry-as-promised": ["retry-as-promised@7.1.1", "", {}, "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw=="], + "rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], @@ -3918,6 +4024,8 @@ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + "run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -3936,6 +4044,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "sc-istanbul": ["sc-istanbul@0.4.6", "", { "dependencies": { "abbrev": "1.0.x", "async": "1.x", "escodegen": "1.8.x", "esprima": "2.7.x", "glob": "^5.0.15", "handlebars": "^4.0.1", "js-yaml": "3.x", "mkdirp": "0.5.x", "nopt": "3.x", "once": "1.x", "resolve": "1.1.x", "supports-color": "^3.1.0", "which": "^1.1.1", "wordwrap": "^1.0.0" }, "bin": { "istanbul": "lib/cli.js" } }, "sha512-qJFF/8tW/zJsbyfh/iT/ZM5QNHE3CXxtLJbZsL+CzdJLBsPD7SedJZoUA4d8iAcN2IoMp/Dx80shOOd2x96X/g=="], "scale-ts": ["scale-ts@1.6.1", "", {}, "sha512-PBMc2AWc6wSEqJYBDPcyCLUj9/tMKnLX70jLOSndMtcUoLQucP/DM0vnQo1wJAYjTrQiq8iG9rD0q6wFzgjH7g=="], @@ -3976,6 +4086,8 @@ "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@3.1.1", "", {}, "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], @@ -4008,7 +4120,7 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-update-notifier": ["simple-update-notifier@1.1.0", "", { "dependencies": { "semver": "~7.0.0" } }, "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg=="], @@ -4088,6 +4200,8 @@ "streamx": ["streamx@2.26.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A=="], + "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], + "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="], "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], @@ -4144,6 +4258,8 @@ "swap-case": ["swap-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "sync-fetch": ["sync-fetch@0.6.0", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IELLEvzHuCfc1uTsshPK58ViSdNqXxlml1U+fmwJIKLYKOr/rAtBrorE2RYm5IHaMpDNlmC0fr1LAvdXvyheEQ=="], "sync-request": ["sync-request@6.1.0", "", { "dependencies": { "http-response-object": "^3.0.1", "sync-rpc": "^1.2.1", "then-request": "^6.0.0" } }, "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw=="], @@ -4154,6 +4270,8 @@ "table-layout": ["table-layout@1.0.2", "", { "dependencies": { "array-back": "^4.0.1", "deep-extend": "~0.6.0", "typical": "^5.2.0", "wordwrapjs": "^4.0.0" } }, "sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], @@ -4166,6 +4284,8 @@ "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + "test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], @@ -4206,6 +4326,10 @@ "title-case": ["title-case@3.0.3", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "tmp": ["tmp@0.2.4", "", {}, "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ=="], "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], @@ -4224,7 +4348,9 @@ "touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -4332,6 +4458,8 @@ "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], + "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "upper-case": ["upper-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg=="], @@ -4394,6 +4522,8 @@ "vortex-rebalancer": ["vortex-rebalancer@workspace:apps/rebalancer"], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "wagmi": ["wagmi@2.19.5", "", { "dependencies": { "@wagmi/connectors": "6.2.0", "@wagmi/core": "2.22.1", "use-sync-external-store": "1.4.0" }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "react": ">=18", "typescript": ">=5.0.4", "viem": "2.x" }, "optionalPeers": ["typescript"] }, "sha512-RQUfKMv6U+EcSNNGiPbdkDtJwtuFxZWLmvDiQmjjBgkuPulUwDJsKhi7gjynzJdsx2yDqhHCXkKsbbfbIsHfcQ=="], "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], @@ -4440,15 +4570,17 @@ "webextension-polyfill": ["webextension-polyfill@0.10.0", "", {}, "sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -4490,8 +4622,12 @@ "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], "xstate": ["xstate@5.31.1", "", {}, "sha512-3P7t7GQ61BvLu+8Cj6Zq7rcS34vecL9pvfN2ucUWmIFIUG+rAREviOs4Xy4OO3BuJHSz6RLU8eqDXxSbVotjDQ=="], @@ -4524,8 +4660,12 @@ "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], + "@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@ardatan/relay-compiler/immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@babel/helper-define-polyfill-provider/resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], @@ -4656,8 +4796,6 @@ "@graphql-tools/merge/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], - "@graphql-tools/prisma-loader/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "@graphql-tools/relay-operation-optimizer/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], "@graphql-tools/schema/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], @@ -4668,6 +4806,10 @@ "@humanwhocodes/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "@inquirer/core/mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -4682,6 +4824,8 @@ "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mapbox/node-pre-gyp/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "@mapbox/node-pre-gyp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine": ["@metamask/json-rpc-engine@7.3.3", "", { "dependencies": { "@metamask/rpc-errors": "^6.2.1", "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^8.3.0" } }, "sha512-dwZPq8wx9yV3IX2caLi9q9xZBw2XeIoYqdyihDDDpuHVCEiqadJLwqM3zy+uwf6F1QYQ65A8aOMQg1Uw7LMLNg=="], @@ -4714,6 +4858,8 @@ "@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + "@nomicfoundation/hardhat-verify/@ethersproject/address": ["@ethersproject/address@5.8.0", "", { "dependencies": { "@ethersproject/bignumber": "^5.8.0", "@ethersproject/bytes": "^5.8.0", "@ethersproject/keccak256": "^5.8.0", "@ethersproject/logger": "^5.8.0", "@ethersproject/rlp": "^5.8.0" } }, "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA=="], "@nomicfoundation/hardhat-verify/cbor": ["cbor@8.1.0", "", { "dependencies": { "nofilter": "^3.1.0" } }, "sha512-DwGjNW9omn6EwP70aXsn7FQJx5kO12tX0bZkaTjzdVFM6/7nhA4t0EENocKGx6D2Bch9PE2KzCUf5SceBdeijg=="], @@ -4774,6 +4920,8 @@ "@sentry/bundler-plugin-core/unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], + "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "@sentry/hub/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -4784,6 +4932,8 @@ "@sentry/node/cookie": ["cookie@0.4.2", "", {}, "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="], + "@sentry/node/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "@sentry/node/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "@sentry/tracing/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -4838,9 +4988,9 @@ "@tanstack/zod-adapter/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "@typechain/hardhat/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -4912,6 +5062,14 @@ "asn1.js/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + "ast-v8-to-istanbul/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "ast-v8-to-istanbul/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "axios-retry/is-retry-allowed": ["is-retry-allowed@2.2.0", "", {}, "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg=="], "babel-plugin-transform-vite-meta-glob/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -5028,6 +5186,8 @@ "ethjs-unit/bn.js": ["bn.js@4.11.6", "", {}, "sha512-XWwnNNFCuuSQ0m3r3C4LE3EiORltHd9M05pq6FOlVeiophzRbMo50Sbz1ehl8K3Z+jw9+vmgnXefY1hz8X+2wA=="], + "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -5038,11 +5198,9 @@ "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "gauge/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], @@ -5072,9 +5230,9 @@ "hardhat/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - "http-basic/concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="], + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "http-basic/concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="], "http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="], @@ -5082,12 +5240,20 @@ "inquirer/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "istanbul-lib-report/make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + + "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "istanbul-lib-source-maps/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], "js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "jsdom/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "json-rpc-engine/@metamask/safe-event-emitter": ["@metamask/safe-event-emitter@2.0.0", "", {}, "sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q=="], "keccak/node-addon-api": ["node-addon-api@2.0.2", "", {}, "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA=="], @@ -5132,8 +5298,18 @@ "morgan/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], + "msw/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "msw/graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], + + "msw/tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + + "msw/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "ndjson/split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="], + "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "node-stdlib-browser/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "node-stdlib-browser/punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="], @@ -5164,11 +5340,13 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "porto/ox": ["ox@0.9.17", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.9", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-rKAnhzhRU3Xh3hiko+i1ZxywZ55eWQzeS/Q4HRKLx2PqfHOolisZHErSsJVipGlmQKHW5qwOED/GighEw9dbLg=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "public-encrypt/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], @@ -5186,6 +5364,8 @@ "req-from/resolve-from": ["resolve-from@3.0.0", "", {}, "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw=="], + "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="], @@ -5270,6 +5450,10 @@ "table-layout/typical": ["typical@5.2.0", "", {}, "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg=="], + "test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "test-exclude/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "then-request/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="], "then-request/concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="], @@ -5324,6 +5508,8 @@ "web3-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "wordwrapjs/typical": ["typical@5.2.0", "", {}, "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -5384,8 +5570,6 @@ "@graphql-tools/load/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "@graphql-tools/prisma-loader/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "@graphql-tools/url-loader/sync-fetch/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "@humanwhocodes/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], @@ -5396,6 +5580,8 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@mapbox/node-pre-gyp/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors": ["@metamask/rpc-errors@6.4.0", "", { "dependencies": { "@metamask/utils": "^9.0.0", "fast-safe-stringify": "^2.0.6" } }, "sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils": ["@metamask/utils@8.5.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.0.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ=="], @@ -5460,6 +5646,10 @@ "@sentry/bundler-plugin-core/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], + "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "@sentry/node/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "@sentry/vite-plugin/unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "@sentry/vite-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], @@ -5552,6 +5742,8 @@ "@walletconnect/utils/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "browserify-sign/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], @@ -5616,8 +5808,6 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ghost-testrpc/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "ghost-testrpc/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -5644,6 +5834,8 @@ "http-basic/concat-stream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "istanbul-lib-report/make-dir/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "js-beautify/nopt/abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -5664,10 +5856,14 @@ "mongodb-connection-string-url/whatwg-url/tr46": ["tr46@3.0.0", "", { "dependencies": { "punycode": "^2.1.1" } }, "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA=="], - "mongodb-connection-string-url/whatwg-url/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], - "morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "msw/tough-cookie/tldts": ["tldts@7.4.6", "", { "dependencies": { "tldts-core": "^7.4.6" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q=="], + + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "nodemon/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "nodemon/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -5734,6 +5930,10 @@ "solidity-coverage/web3-utils/ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="], + "test-exclude/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "test-exclude/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "then-request/concat-stream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "then-request/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -5976,12 +6176,12 @@ "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "mocha/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.4.6", "", {}, "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA=="], + "nodemon/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -6018,6 +6218,8 @@ "solidity-coverage/web3-utils/ethereum-cryptography/@scure/bip39": ["@scure/bip39@1.3.0", "", { "dependencies": { "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ=="], + "test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "then-request/concat-stream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "then-request/concat-stream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..c653e0a2e --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,7 @@ +[test] +# `bun test` from the repo root is NOT the test aggregate: it would discover every +# test file in the monorepo, including stale compiled copies under apps/api/dist/. +# Restrict discovery to a directory that doesn't exist at the root so a root-level +# `bun test` finds nothing instead of running garbage. Use `bun run test` (the +# package.json script) to run all workspace suites. +root = "src" diff --git a/docs/Alfredpay.md b/docs/Alfredpay.md index 4bc27c9bb..b4977b38a 100644 --- a/docs/Alfredpay.md +++ b/docs/Alfredpay.md @@ -1,6 +1,6 @@ -# Alfredpay Onramp Flow — USD, MXN, COP +# Alfredpay Onramp Flow — USD, MXN, COP, ARS -Alfredpay is a fiat-to-crypto (onramp) and crypto-to-fiat (offramp) payment provider integrated into Vortex. It supports three fiat currencies: **USD** (USA), **MXN** (Mexico), **COP** (Colombia). All three route through the same backend transaction phases; KYC/KYB onboarding differs per country. +Alfredpay is a fiat-to-crypto (onramp) and crypto-to-fiat (offramp) payment provider integrated into Vortex. It supports **USD** (USA), **MXN** (Mexico), **COP** (Colombia), and **ARS** (Argentina). These currencies route through the same backend transaction phases; KYC/KYB onboarding differs per country. --- @@ -11,8 +11,9 @@ Alfredpay is a fiat-to-crypto (onramp) and crypto-to-fiat (offramp) payment prov | USD | US | iFrame redirect (Persona) | | MXN | MX | API form + ID document upload | | COP | CO | API form + ID document upload | +| ARS | AR | API form + ID document upload | -`isAlfredpayToken` in `packages/shared/src/services/alfredpay/types.ts` gates all three tokens into the Alfredpay path. +`isAlfredpayToken` in `packages/shared/src/services/alfredpay/types.ts` gates these fiat tokens into the Alfredpay path. --- diff --git a/docs/add-updateRamp-endpoint-plan.md b/docs/add-updateRamp-endpoint-plan.md deleted file mode 100644 index 8b7f9900a..000000000 --- a/docs/add-updateRamp-endpoint-plan.md +++ /dev/null @@ -1,133 +0,0 @@ -# Plan to Add `updateRamp` Endpoint - -**Objective:** Introduce a new `updateRamp` endpoint to allow the frontend to submit presigned transactions and other necessary data *before* the `startRamp` endpoint is called. This enhances resilience by decoupling data submission from process initiation. - -## Phase 1: Backend Changes (API) - -1. **Define DTOs for `updateRamp`:** - * Location: `packages/shared/src/endpoints/ramp.endpoints.ts` - * **`UpdateRampRequest`**: - * `rampId: string` - * `presignedTxs: PresignedTx[]` - * `additionalData?: { squidRouterApproveHash?: string; squidRouterSwapHash?: string; assethubToPendulumHash?: string; [key: string]: unknown; }` (consistent with data `startRamp` might need) - * **`UpdateRampResponse`**: - * `RampProcess` (the updated ramp process object) - -2. **`RampState` Model Considerations:** - * Location: `apps/api/src/models/rampState.model.ts` - * The `updateRamp` endpoint will populate fields within `RampState` that `startRamp` will consume. - * This will reuse existing fields or add new nullable fields to `RampState` if necessary to hold `presignedTxs` and `additionalData` directly. - * **No new database migration will be created for *separate "pending"* fields.** The aim is for `updateRamp` to set data that `startRamp` directly uses from the existing or slightly augmented `RampState` structure. - -3. **Implement `rampController.updateRamp`:** - * Location: `apps/api/src/api/controllers/ramp.controller.ts` - * **Logic:** - * Validate `rampId` and request payload. - * Retrieve the `RampState`. - * Return `409 Conflict` if ramp is not in a state allowing updates (e.g., already started, completed, failed). - * Store/update `presignedTxs` and `additionalData` directly in the `RampState` fields that `startRamp` will consume. Handle merging/replacement if called multiple times. - * Do NOT trigger phase transitions. - * Save updated `RampState`. - * Return the updated `RampProcess`. - -4. **Add Route for `updateRamp`:** - * Location: `apps/api/src/api/routes/v1/ramp.route.ts` - * Route: `router.post('/:rampId/update', rampController.updateRamp);` - * Add JSDoc API documentation. - -5. **Modify `rampController.startRamp`:** - * Location: `apps/api/src/api/controllers/ramp.controller.ts` - * **Logic Change:** - * `startRamp` will expect necessary `presignedTxs` and `additionalData` to be present in `RampState` (populated by `updateRamp`). - * The `StartRampRequest` DTO will be modified (see Phase 2, Step 1) to only require `rampId`. - * If required data is missing in `RampState` when `startRamp` is called, return an error (e.g., `400 Bad Request` or `422 Unprocessable Entity`). - -6. **Update `RampService` (Backend - `apps/api/src/api/services/ramp/ramp.service.ts`):** - * Reflect changes in how `startRamp` retrieves data (from `RampState`). - -## Phase 2: Frontend Changes - -1. **Update `StartRampRequest` DTO (Shared Package):** - * Location: `packages/shared/src/endpoints/ramp.endpoints.ts` - * Modify `RampEndpoints.StartRampRequest` to: - ```typescript - export interface StartRampRequest { - rampId: string; - } - ``` - (Remove `presignedTxs` and `additionalData` from this request DTO). - -2. **Add `updateRamp` to `RampService` (Frontend):** - * Location: `apps/frontend/src/services/api/ramp.service.ts` - * Add method: - ```typescript - static async updateRamp( - rampId: string, - presignedTxs: PresignedTx[], - additionalData?: RampEndpoints.UpdateRampRequest['additionalData'] - ): Promise { - const request: RampEndpoints.UpdateRampRequest = { - rampId, - presignedTxs, - additionalData, - }; - return apiRequest('post', `${this.BASE_PATH}/${rampId}/update`, request); - } - ``` - -3. **Integrate `updateRamp` Call in `useRegisterRamp.ts`:** - * Location: `apps/frontend/src/hooks/offramp/useRampService/useRegisterRamp.ts` - * Call `RampService.updateRamp` after ephemeral transactions are signed. - * Call `RampService.updateRamp` again after user transactions are signed (for offramps), including user transaction hashes in `additionalData`. - * The backend will merge data from multiple calls. - -4. **Modify `startRamp` Call in Frontend:** - * Update calls to `RampService.startRamp` to only pass `rampId`. - * Ensure `startRamp` is called after all necessary `updateRamp` calls. - -## Phase 3: Documentation - -1. **API Documentation:** - * Document the new `POST /v1/ramp/:rampId/update` endpoint in `apps/api/src/api/routes/v1/ramp.route.ts` (JSDoc). - * Update JSDoc for `POST /v1/ramp/start` to reflect its simplified request payload (only `rampId`). - * Update any relevant project documentation (e.g., in `/docs`) to describe the new `register` -> `update` (multiple times potentially) -> `start` flow. - -2. **Memory Bank Update:** - * Update `memory-bank/activeContext.md`: Note the ongoing work on this feature. - * Update `memory-bank/decisionLog.md`: Log the decision to add `updateRamp`, the rationale, and the choice to reuse/augment existing `RampState` fields rather than adding separate "pending" fields. - * Update `memory-bank/systemPatterns.md` or `memory-bank/phases.md` with the new flow diagram if significantly different. - -## Flow Diagram - -```mermaid -sequenceDiagram - participant FE as Frontend - participant API as Backend API - participant DB as Database (RampState) - - FE->>API: POST /v1/ramp/register (quoteId, signingAccounts, initialAdditionalData) - API->>DB: Create RampState (initial state, unsignedTxs) - API-->>FE: rampProcess (incl. rampId, unsignedTxs) - - FE->>FE: Sign Ephemeral Transactions (ephemeralPresignedTxs) - FE->>API: POST /v1/ramp/{rampId}/update (rampId, ephemeralPresignedTxs) - API->>DB: Store/Merge ephemeralPresignedTxs in RampState - API-->>FE: Updated rampProcess - - opt Offramp User Signing - FE->>FE: User signs transactions (userPresignedTxs, userTxHashes) - FE->>API: POST /v1/ramp/{rampId}/update (rampId, userPresignedTxs, additionalData: {userTxHashes}) - API->>DB: Store/Merge userPresignedTxs and additionalData in RampState - API-->>FE: Updated rampProcess - end - - FE->>API: POST /v1/ramp/start (rampId) - API->>DB: Read presignedTxs & additionalData from RampState - API->>API: Process ramp using stored data (execute first phase) - API->>DB: Update RampState (new phase) - API-->>FE: Updated rampProcess (ramp started) - - loop Poll Status - FE->>API: GET /v1/ramp/{rampId} - API-->>FE: rampProcess (current status) - end diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index c9bac3280..67bd7e51b 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -2,6 +2,10 @@ "apidogProjectId": "918521", "endpointReference": { "currentDocumentedPaths": [ + "/v1/api-keys", + "/v1/api-keys/{keyId}", + "/v1/auth/request-otp", + "/v1/auth/verify-otp", "/v1/brla/createSubaccount", "/v1/brla/getKycStatus", "/v1/brla/getSelfieLivenessUrl", @@ -28,7 +32,16 @@ "/v1/webhook", "/v1/webhook/{id}" ], - "recommendedGroups": ["Quotes", "Vortex Widget", "Ramp", "Account Management", "Webhooks", "Public Key", "Reference Data"], + "recommendedGroups": [ + "Quotes", + "Vortex Widget", + "Ramp", + "Account Management", + "Authentication", + "Webhooks", + "Public Key", + "Reference Data" + ], "source": "docs/api/openapi/vortex.openapi.json" }, "markdownSync": { @@ -52,7 +65,7 @@ "order": 3, "slug": "authentication-and-partner-keys", "source": "docs/api/pages/03-authentication-and-partner-keys.md", - "title": "Authentication And Partner Keys" + "title": "Authentication And API Keys" }, { "order": 4, @@ -86,9 +99,9 @@ }, { "order": 9, - "slug": "brl-kyc-notes", - "source": "docs/api/pages/09-brl-kyc-notes.md", - "title": "BRL / KYC Notes" + "slug": "fiat-corridors", + "source": "docs/api/pages/09-fiat-corridors.md", + "title": "Fiat Corridors" }, { "order": 10, diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 3d697659d..64b28394a 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -4,2163 +4,1443 @@ */ export interface paths { - "/v1/brla/createSubaccount": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Create user or retry KYC - * @description `companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ` - * - * **Auth:** uses `optionalAuth` — accepts a Supabase Bearer token if present but does not require one. - */ - post: operations["createSubaccount"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/brla/getKycStatus": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get user's KYC status - * @description **Auth:** requires `Authorization: Bearer `. - */ - get: operations["fetchSubaccountKycStatus"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/brla/getSelfieLivenessUrl": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get selfie liveness URL - * @description Returns the Avenia selfie/liveness-check URL for the subaccount associated with this tax ID. - * - * **Auth:** requires `Authorization: Bearer `. - */ - get: operations["brlaGetSelfieLivenessUrl"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/brla/getUploadUrls": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Get KYC document upload URLs - * @description Returns presigned upload URLs for the user's ID document and selfie. Only `ID` and `DRIVERS-LICENSE` are accepted for `documentType` (passport not supported here). - * - * **Auth:** uses `optionalAuth` — accepts a Supabase Bearer token if present but does not require one. - */ - post: operations["brlaGetUploadUrls"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/brla/getUser": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get user information - * @description Fetches a user's subaccount information. The response contains only the EVM wallet address and KYC level. - * - * **Auth:** requires `Authorization: Bearer `. - */ - get: operations["getBrlaUser"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/brla/getUserRemainingLimit": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get user's remaining transaction limits - * @description **Auth:** requires `Authorization: Bearer `. - */ - get: operations["getBrlaUserRemainingLimit"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/brla/newKyc": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Submit KYC level 1 data - * @description Submits the user's KYC level 1 payload to Avenia after documents have been uploaded via `/v1/brla/getUploadUrls`. Includes a built-in 5-second delay to allow upstream document propagation. - * - * **Auth:** uses `optionalAuth`. - */ - post: operations["brlaNewKyc"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/brla/validatePixKey": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Validate Pix key - * @description Checks whether a Pix key exists and is valid. The key value itself is intentionally not echoed back in the response for security. - * - * **Auth:** requires `Authorization: Bearer `. - */ - get: operations["brlaValidatePixKey"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/public-key": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Public Key - * @description Returns the RSA-PSS 2048 / SHA-256 public key used to verify Vortex webhook signatures. This is NOT a partner `pk_*` API key. - */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description RSA-PSS public key in PEM format. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...replace-with-actual-key...\n-----END PUBLIC KEY-----\n" - * } - */ - "application/json": { - /** @description RSA-PSS 2048-bit public key in PEM format. Use this key to verify webhook signatures with RSA-PSS / SHA-256. */ - publicKey: string; - }; - }; + "/v1/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/quotes": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Create a new quote - * @description Generates a quote for a specified ramp transaction, detailing input and output amounts, fees, and expiration. - */ - post: operations["createQuote"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/quotes/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get existing quote - * @description Get a quote by ID. - * - * **Auth:** none. This endpoint is fully public; anyone with the quote ID can read it. - */ - get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Quote Id. */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["QuoteResponse"]; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/quotes/best": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Create a quote for the best network - * @description Generates a new quote for the network that yields the highest output amount for the given parameters. This endpoint compares the output for a given input amount over all supported networks and returns the 'best' quote, defined as the one with the highest output. - */ - post: operations["createBestQuote"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/ramp/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get ramp status - * @description Fetches an updated ramp process. - */ - get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ramp ID. */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - anchorFeeFiat: string; - anchorFeeUSD: string; - countryCode?: components["schemas"]["CountryCode"]; - /** - * Format: date-time - * @description Timestamp of when the ramp process was created. - */ - createdAt?: string; - currentPhase?: components["schemas"]["RampPhase"]; - /** @description BR Code for PIX payment, if applicable. */ - depositQrCode?: string | null; - feeCurrency: components["schemas"]["RampCurrency"]; - /** @description The source network or payment method. */ - from?: components["schemas"]["DestinationType"]; - /** @description Unique identifier for the ramp process. */ - id?: string; - inputAmount: string; - inputCurrency: string; - network?: components["schemas"]["Networks"]; - networkFeeFiat: string; - networkFeeUSD: string; - outputAmount: string; - outputCurrency: string; - partnerFeeFiat: string; - partnerFeeUSD: string; - paymentMethod: components["schemas"]["PaymentMethod"]; - processingFeeFiat: string; - processingFeeUSD: string; - /** - * Format: uuid - * @description The quote ID associated with this ramp process. - */ - quoteId?: string; - /** @description The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API. */ - sessionId?: string; - status?: components["schemas"]["SimpleStatus"]; - /** @description The destination network or payment method. */ - to?: components["schemas"]["DestinationType"]; - totalFeeFiat: string; - totalFeeUSD: string; - /** @description (BUY-only) A link to a block explorer showing the details for the transaction hash. */ - transactionExplorerLink?: string; - /** @description (BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. */ - transactionHash?: string; - /** @description Type of ramp process. */ - type?: components["schemas"]["RampDirection"]; - /** @description Array of unsigned transactions that need to be signed by the user. */ - unsignedTxs?: components["schemas"]["UnsignedTx"][]; - /** - * Format: date-time - * @description Timestamp of the last update to the ramp process. - */ - updatedAt?: string; - vortexFeeFiat: string; - vortexFeeUSD: string; - /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ - walletAddress?: string; - }; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/ramp/{id}/errors": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get ramp error logs - * @description Returns the chronological error log for a ramp. - * - * **Auth:** requires either `X-API-Key: sk_*` (partner) OR `Authorization: Bearer ` (user). Ownership is enforced. - */ - get: operations["getRampErrorLogs"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/ramp/history/{walletAddress}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get ramp history for wallet address - * @description Fetches the transaction history for a given wallet address. The response returns the last 20 items by default. This can be adjusted by using the `limit` and `offset` query parameters. - */ - get: { - parameters: { - query?: { - /** @description The maximum count of transaction items returned in this query. The maximum value is `100`. */ - limit?: number; - /** @description The offset for querying the transactions. Necessary if the number of transaction items of the address is larger than the maximum limit. A larger value will return older transaction items. */ - offset?: number; - }; - header?: never; - path: { - /** @description The wallet address for which the ramp history is queried for. */ - walletAddress: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["GetRampHistoryResponse"]; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/ramp/register": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Register new ramp process - * @description Initiates a new on-ramp or off-ramp process by providing quote details, signing accounts, and additional data. - */ - post: operations["registerRamp"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/ramp/start": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Start ramp process - * @description Starts a ramp process. - * - * It is assumed all required information from the client has already been sent using the `update` endpoint. This endpoint is only used to tell the backend any external operation (like a bank transfer) has been completed, and the ramp can start. - */ - post: operations["startRamp"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/ramp/update": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Update ramp process - * @description Submits presigned transactions and additional data to an existing ramp process before starting it. - * This endpoint can be called many times, and data can be incrementally added to the ramp. - * - * Note: For both pre-signed transactions and the generic `additionalData` object, existing properties will be overriden by new values. - * - * ### Required data for ramps. - * The signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object. - * For offramps, the `additionalData` field must contain the confirmation hash corresponding to the inital transaction in which the user sends the funds. - * If the originating chain is `Assethub`, then `assetHubToPendulumHash` must be provided. - * If the originating chain is any `EVM` chain, then `squidRouterApproveHash` and `squidRouterSwapHash` must be provided. - * - * For onramps, no additional data is required after registering the ramp. - */ - post: operations["startRamp"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/session/create": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Create widget session - * @description Creates a hosted Vortex Widget session and returns the URL to open for the user. - * - * This single endpoint supports two mutually exclusive request shapes: - * - * - **Fixed quote** (`GetWidgetUrlLocked`) — pass a `quoteId` you created via `POST /v1/quotes`. The widget uses that exact quote and does not refresh it. If the quote expires before the user finishes, they must close the window and start over. - * - * - **Auto-refresh** (`GetWidgetUrlRefresh`) — pass the route parameters (`network`, `rampType`, `inputAmount`, plus `fiat` / `cryptoLocked` / `paymentMethod` as relevant for the direction). The widget creates and refreshes quotes on demand for the user. - * - * Use the example switcher below to see the request shape for each mode. `externalSessionId` is required in both modes and is echoed back in webhook payloads. - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["GetWidgetUrlLocked"] | components["schemas"]["GetWidgetUrlRefresh"]; - }; - }; - responses: { - /** @description Returned when a fixed-quote session was created. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "url": "https://www.vortexfinance.co/widget?externalSessionId=my-session-id"eId=quote_01HXY..." - * } - */ - "application/json": { - /** @description The widget URL to open for the user. */ - url: string; - }; - }; - }; - /** @description Returned when an auto-refresh session was created. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "url": "https://www.vortexfinance.co/widget?externalSessionId=my-session-id&rampType=BUY&network=polygon&inputAmount=150&fiat=BRL&cryptoLocked=USDC&paymentMethod=pix" - * } - */ - "application/json": { - /** @description The widget URL to open for the user. */ - url: string; - }; - }; - }; - /** @description Missing required fields, or `quoteId` not provided for fixed-quote mode and route fields not provided for auto-refresh mode. */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Quote not found or expired (fixed-quote mode only). */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/supported-countries": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Supported Countries */ - get: { - parameters: { - query?: { - /** - * @description ISO code: "BR", "AR", etc. - * @example - */ - countryCode?: string; - /** @description e.g. "Brazil", "Germany" */ - name?: string; - /** @description e.g. "BRL". All the supported currencies you can get from `supported-fiat-currencies` endpoint. */ - fiatCurrency?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - countries: { - /** @description e.g. `DE` */ - countryCode: string; - }[]; - /** @description e.g. 🇩🇪 */ - emoji: string; - /** @description e.g. `Germany` */ - name: string; - support: { - /** @description e.g. `true` */ - buy: boolean; - /** @description e.g. `true` */ - sell: boolean; - }; - /** @description All the supported currencies you can get from `supported-fiat-currencies` endpoint. */ - supportedCurrencies: string[]; - }; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/supported-cryptocurrencies": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Supported Cryptocurrencies - * @description Retrieve all supported cryptocurrencies, filtered by network. - */ - get: { - parameters: { - query?: { - /** - * @description Filter supported cryptocurrencies by network. Allowed values: `assethub`, `avalanche`, `base`, `bsc`, `ethereum`, `polygon` - * @example - */ - network?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - cryptocurrencies: { - /** @description Defined if network is EVM. */ - assetContractAddress?: string | null; - assetDecimals: number; - /** @description Defined if network is Assethub. */ - assetForeignAssetId?: string | null; - assetNetwork: components["schemas"]["Networks"]; - assetSymbol: string; - }[]; - }; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/supported-fiat-currencies": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Supported Fiat Currencies */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - currencies: { - /** @description e.g. `2` */ - decimals: number; - /** @description e.g. `Brazilian Real` */ - name: string; - /** @description e.g. `BRL` */ - symbol: string; - }[]; + /** + * List the user's API keys + * @description Lists the authenticated user's active API keys. Public key values are included; secret key values are never returned. + * + * **Auth:** requires `Authorization: Bearer ` obtained from `POST /v1/auth/verify-otp`. Partner `sk_*`/`pk_*` keys are not accepted. + */ + get: operations["listUserApiKeys"]; + put?: never; + /** + * Create a user-linked API key pair + * @description Creates a public + secret API key pair bound to the authenticated user. The secret key value is returned only in this response; Vortex stores a hash and cannot show it again. + * + * Keys expire after one year by default; `expiresAt` may extend this to at most two years from now. A user may hold at most 10 active keys (a pair counts as two). + * + * Sandbox mints `pk_test_*`/`sk_test_*`; production mints `pk_live_*`/`sk_live_*`. + * + * **Auth:** requires `Authorization: Bearer ` obtained from `POST /v1/auth/verify-otp`. Partner `sk_*`/`pk_*` keys are not accepted. + */ + post: operations["createUserApiKey"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/api-keys/{keyId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke an API key + * @description Revokes (soft-deletes) an API key owned by the authenticated user. Pass `pairedKeyId` in the body to revoke both halves of a pair together; the two keys must be of opposite types (one public, one secret) and share the same base name. The legacy `publicKeyId` body field is accepted as an alias. + * + * **Auth:** requires `Authorization: Bearer ` obtained from `POST /v1/auth/verify-otp`. Partner `sk_*`/`pk_*` keys are not accepted. + */ + delete: operations["revokeUserApiKey"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/auth/request-otp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request an email OTP + * @description Sends a 6-digit one-time password to the given email address. Use it with `POST /v1/auth/verify-otp` to obtain a user session. + * + * **Auth:** none. + */ + post: operations["requestOTP"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/auth/verify-otp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Verify an email OTP + * @description Verifies the emailed one-time password and returns a user session. First-time sign-ins create the user profile; `user_id` identifies the profile that API keys minted with this session are linked to. + * + * **Auth:** none. + */ + post: operations["verifyOTP"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/createSubaccount": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create user or retry KYC + * @description `companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ` + * + * `quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists. + * + * **Auth:** uses `optionalAuth` — accepts a Supabase Bearer token if present but does not require one. + */ + post: operations["createSubaccount"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/getKycStatus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user's KYC status + * @description **Auth:** requires `Authorization: Bearer `. + */ + get: operations["fetchSubaccountKycStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/getSelfieLivenessUrl": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get selfie liveness URL + * @description Returns the Avenia selfie/liveness-check URL for the subaccount associated with this tax ID. + * + * **Auth:** requires `Authorization: Bearer `. + */ + get: operations["brlaGetSelfieLivenessUrl"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/getUploadUrls": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get KYC document upload URLs + * @description Returns presigned upload URLs for the user's ID document and selfie. Only `ID` and `DRIVERS-LICENSE` are accepted for `documentType` (passport not supported here). + * + * **Auth:** uses `optionalAuth` — accepts a Supabase Bearer token if present but does not require one. + */ + post: operations["brlaGetUploadUrls"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/getUser": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user information + * @description Fetches a user's subaccount information. The response contains only the EVM wallet address and KYC level. + * + * **Auth:** requires `Authorization: Bearer `. + */ + get: operations["getBrlaUser"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/getUserRemainingLimit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user's remaining transaction limits + * @description **Auth:** requires `Authorization: Bearer `. + */ + get: operations["getBrlaUserRemainingLimit"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/newKyc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Submit KYC level 1 data + * @description Submits the user's KYC level 1 payload to Avenia after documents have been uploaded via `/v1/brla/getUploadUrls`. Includes a built-in 5-second delay to allow upstream document propagation. + * + * **Auth:** uses `optionalAuth`. + */ + post: operations["brlaNewKyc"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/validatePixKey": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Validate Pix key + * @description Checks whether a Pix key exists and is valid. The key value itself is intentionally not echoed back in the response for security. + * + * **Auth:** requires `Authorization: Bearer `. + */ + get: operations["brlaValidatePixKey"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/public-key": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Public Key + * @description Returns the RSA-PSS 2048 / SHA-256 public key used to verify Vortex webhook signatures. This is NOT a partner `pk_*` API key. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description RSA-PSS public key in PEM format. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...replace-with-actual-key...\n-----END PUBLIC KEY-----\n" + * } + */ + "application/json": { + /** @description RSA-PSS 2048-bit public key in PEM format. Use this key to verify webhook signatures with RSA-PSS / SHA-256. */ + publicKey: string; + }; + }; + }; }; - }; }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/supported-payment-methods": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Supported Payment Methods - * @description Retrieve all available payment methods, filtered by type or fiat. - */ - get: { - parameters: { - query?: { - /** - * @description Filter supported payment methods by the ramp type. Allowed values: `sell` or `buy`. - * @example - */ - type?: string; - /** - * @description Filter supported payment methods Allowed values: `ars`, `brl`, `eur` - * @example - */ - fiat?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @description Array of supported payment methods matching the params. */ - "paymentMethods:": { - /** @description Unique identifier of the payment method: `sepa`, `pix`, `cbu` */ - id: string; - /** @description Payment method limits in USD */ - limits: { - max: number; - min: number; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/quotes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a new quote + * @description Generates a quote for a specified ramp transaction, detailing input and output amounts, fees, and expiration. + */ + post: operations["createQuote"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/quotes/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get existing quote + * @description Get a quote by ID. + * + * **Auth:** none. This endpoint is fully public; anyone with the quote ID can read it. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Quote Id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuoteResponse"]; + }; }; - /** @description Unique name of the payment method: `SEPA`, `PIX`, `CBU` */ - name: string; - /** @description Array of supported fiat currencies by payment method. */ - supportedFiats: string[]; - }[]; }; - }; }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/webhook": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Register Webhook - * @description Register a new webhook to receive event notifications. - * - * **Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints. - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": { - events?: string[]; - /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific quote */ - quoteId?: string; - /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific session */ - sessionId?: string; - /** @description Your HTTPS webhook endpoint URL */ - url: string; - }; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "createdAt": "2025-10-01T16:21:04.648Z", - * "events": [ - * "TRANSACTION_CREATED", - * "STATUS_CHANGE" - * ], - * "id": "340ba946-f3f3-4007-893c-3374bfcd096b", - * "isActive": true, - * "quoteId": "3258910e-93ee-443e-b793-28cc1d4ccdf3", - * "sessionId": null, - * "url": "https://your-website.com" - * } - */ - "application/json": { - /** @description The creation date of the webhook */ - createdAt: string; - /** @description The events the webhook is subscribed for */ - events: string[]; - /** @description Webhook UUID */ - id: string; - /** @description Is the webhook active */ - isActive: boolean; - /** @description (optional): The specific transactionId that the events are subscribed for */ - quoteId?: string; - /** @description (optional): The specific sessionId that the events are subscribed for */ - sessionId?: string; - /** @description Your HTTPS webhook endpoint URL */ - url: string; - }; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/webhook/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** - * Delete Webhook - * @description Remove a webhook subscription. - * - * **Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints. - */ - delete: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - message: string; - success: boolean; - }; - }; - }; - }; - }; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - AccountMeta: { - /** @description The account address. */ - address: string; - /** - * @description The type of the account. - * @enum {string} - */ - type: "EVM" | "Stellar" | "Substrate"; - }; - /** @enum {string} */ - AveniaDocumentType: "ID" | "DRIVERS-LICENSE" | "PASSPORT" | "SELFIE" | "SELFIE-FROM-LIVENESS"; - AveniaKYCDataUploadRequest: { - documentType: components["schemas"]["AveniaDocumentType"]; - /** @description CPF or CNPJ. */ - taxId: string; - }; - AveniaKYCDataUploadResponse: { - idUpload: components["schemas"]["DocumentUploadEntry"]; - selfieUpload: components["schemas"]["DocumentUploadEntry"]; - }; - BrlaAddress: { - cep: string; - city: string; - complement?: string | null; - district: string; - number: string; - state: string; - street: string; - }; - BrlaErrorResponse: { - /** @description Detailed error message or object from BRLA API or server. */ - details?: null & - ( - | string - | { - [key: string]: unknown; - } - ); - /** @description A summary of the error. */ - error?: string; - }; - BrlaGetSelfieLivenessUrlResponse: { - id: string; - livenessUrl: string; - uploadURLFront: string; - validateLivenessToken: string; - }; - BrlaValidatePixKeyResponse: { - valid: boolean; - }; - CleanupPhase: { - /** @enum {string} */ - string?: "moonbeamCleanup" | "pendulumCleanup" | "stellarCleanup"; - }; - /** @description Allowed values: `AR`, `BR`, `EU` */ - CountryCode: string; - CreateBestQuoteRequest: { - /** @description Your api key, if available. */ - apiKey?: string; - countryCode?: components["schemas"]["CountryCode"]; - /** @description `PIX`, `SEPA`, `CBU`. Only required if `rampType` is "BUY". */ - from?: components["schemas"]["PaymentMethod"]; - /** - * @description The amount of currency to be input. - * @example 100.00 - */ - inputAmount: string; - /** @description The currency type for the input amount. */ - inputCurrency: components["schemas"]["RampCurrency"]; - /** @description Optional whitelist of networks to evaluate when searching for the best quote. If omitted or empty, all eligible networks for the corridor are considered. */ - networks?: components["schemas"]["Networks"][]; - /** @description The desired currency type for the output amount. */ - outputCurrency: components["schemas"]["RampCurrency"]; - /** @description Your partner ID, if available. */ - partnerId?: string; - paymentMethod?: components["schemas"]["PaymentMethod"]; - /** @description The type of ramp process (on-ramp or off-ramp). */ - rampType: components["schemas"]["RampDirection"]; - /** @description `PIX`, `SEPA`, `CBU`. Only required if `rampType` is "SELL". */ - to?: components["schemas"]["PaymentMethod"]; - }; - CreateQuoteRequest: { - /** @description Your api key, if available. */ - apiKey?: string; - countryCode?: components["schemas"]["CountryCode"]; - /** @description From destination */ - from: components["schemas"]["DestinationType"]; - /** - * @description The amount of currency to be input. - * @example 100.00 - */ - inputAmount: string; - /** @description The currency type for the input amount. */ - inputCurrency: components["schemas"]["RampCurrency"]; - network?: components["schemas"]["Networks"]; - /** @description The desired currency type for the output amount. */ - outputCurrency: components["schemas"]["RampCurrency"]; - /** @description Your partner ID, if available. */ - partnerId?: string; - paymentMethod?: components["schemas"]["PaymentMethod"]; - /** @description The type of ramp process (on-ramp or off-ramp). */ - rampType: components["schemas"]["RampDirection"]; - /** @description To destination */ - to: components["schemas"]["DestinationType"]; - }; - CreateSubaccountRequest: { - address: components["schemas"]["BrlaAddress"]; - /** - * Format: date - * @description Date must be in format YYYY-MMM-DD. - */ - birthdate: string; - cnpj?: string | null; - companyName?: string | null; - cpf: string; - fullName: string; - phone: string; - /** - * Format: date - * @description Date must be in format YYYY-MMM-DD. - */ - startDate?: string | null; - taxIdType: components["schemas"]["TaxIdType"]; - }; - CreateSubaccountResponse: { - /** @description The ID of the created or processed subaccount. */ - subaccountId?: string; - }; - /** - * @description Represents either a blockchain network or a traditional payment method. - * @enum {string} - */ - DestinationType: - | "assethub" - | "arbitrum" - | "avalanche" - | "base" - | "bsc" - | "ethereum" - | "polygon" - | "moonbeam" - | "pendulum" - | "stellar" - | "pix" - | "sepa" - | "cbu"; - DocumentUploadEntry: { - id: string; - livenessUrl?: string; - uploadURLBack?: string; - uploadURLFront: string; - validateLivenessToken?: string; - }; - ErrorResponse: { - /** @description HTTP status code returned by the API error handler. */ - code?: number; - /** @description Validation error details, when the request fails schema or input validation. */ - errors?: Record[]; - /** @description A human-readable error message. */ - message?: string; - /** @description HTTP status code included by selected provider-style error responses. */ - statusCode?: number; - /** @description Provider-style error category, when available. */ - type?: string; - }; - /** @enum {string} */ - FiatToken: "EUR" | "ARS" | "BRL"; - GetKycStatusResponse: { - /** @description The KYC level achieved. */ - level?: number; - /** - * @description The KYC status. - * @enum {string} - */ - status?: "PENDING" | "APPROVED" | "REJECTED"; - /** - * @description Event type, typically "KYC". - * @enum {string} - */ - type?: "KYC"; - }; - GetRampErrorLogsResponse: components["schemas"]["RampErrorLog"][]; - GetRampHistoryResponse: { - totalCount: string; - transactions: components["schemas"]["GetRampHistoryTransaction"]; - }; - GetRampHistoryTransaction: { - date: string; - /** @description A link to the transaction explorer of the blockchain showing the details of the transaction sending the tokens to the user's wallet address. Only available for 'BUY' ramps. */ - externalTxExplorerLink?: string; - /** @description The hash of the blockchain transaction sending the tokens to the user's wallet address. Only available for 'BUY' ramps. */ - externalTxHash?: string; - from: components["schemas"]["DestinationType"]; - fromAmount: string; - fromCurrency: components["schemas"]["RampCurrency"]; - id: string; - status: components["schemas"]["SimpleStatus"]; - to: components["schemas"]["DestinationType"]; - toAmount: string; - toCurrency: components["schemas"]["RampCurrency"]; - type: components["schemas"]["RampDirection"]; - }; - GetUserRemainingLimitResponse: { - /** - * Format: double - * @description The remaining limit for offramp operations. - */ - remainingLimitOfframp?: number; - /** - * Format: double - * @description The remaining limit for onramp operations. - */ - remainingLimitOnramp?: number; - }; - GetUserResponse: { - /** @description The user's EVM wallet address. */ - evmAddress?: string; - /** - * @description The user's KYC level. - * @enum {number} - */ - kycLevel?: 1 | 2; - }; - GetWidgetUrlLocked: { - /** @description The widget will redirect to this callbackUrl after the user successfully created the transaction. */ - callbackUrl?: string; - /** @description A unique identifier for yourself to keep track of the widget session. Returned in the responses of webhooks, if registered. */ - externalSessionId?: string; - /** @description Pass the ID of an existing quote to make the widget lock in that particular quote without allowing to change it. */ - quoteId: string; - /** @description Pass this parameter if you want to lock the wallet address for the user. It will not be editable in the widget. */ - walletAddressLocked?: string; - }; - GetWidgetUrlRefresh: { - /** @description Your api key, if available. This is passed to all the quotes generated in this widget session. */ - apiKey?: string; - /** @description The widget will redirect to this callbackUrl after the user successfully created the transaction. */ - callbackUrl?: string; - countryCode?: components["schemas"]["CountryCode"]; - cryptoLocked?: components["schemas"]["OnChainToken"]; - /** @description A unique identifier for yourself to keep track of the widget session. Returned in the responses of webhooks, if registered. */ - externalSessionId: string; - fiat?: components["schemas"]["FiatToken"]; - inputAmount: string; - network: components["schemas"]["Networks"]; - /** @description The identifier of a partner. */ - partnerId?: string; - paymentMethod?: components["schemas"]["PaymentMethod"]; - rampType: components["schemas"]["RampDirection"]; - /** @description Pass this parameter if you want to lock the wallet address for the user. It will not be editable in the widget. */ - walletAddressLocked?: string; - }; - KYCDataUploadFileFiles: { - /** Format: url */ - CNHUploadUrl?: string; - /** Format: url */ - RGBackUploadUrl?: string; - /** Format: url */ - RGFrontUploadUrl?: string; - /** Format: url */ - selfieUploadUrl?: string; - }; - /** @enum {string} */ - KYCDocType: "RG" | "CNH"; - KycLevel1Payload: { - city: string; - country: string; - countryOfTaxId: string; - /** @description ISO date (YYYY-MM-DD). */ - dateOfBirth: string; - /** Format: email */ - email: string; - fullName: string; - state: string; - streetAddress: string; - subAccountId: string; - taxIdNumber: string; - uploadedDocumentId: string; - uploadedSelfieId: string; - zipCode: string; - }; - KycLevel1Response: { - id: string; - }; - /** - * @description Supported blockchain networks. - * @enum {string} - */ - Networks: "assethub" | "arbitrum" | "avalanche" | "base" | "bsc" | "ethereum" | "polygon" | "moonbeam"; - /** @enum {string} */ - OnChainToken: "USDC" | "USDT" | "ETH" | "USDC.E"; - /** @description Data related to the payment for the ramp transaction. */ - PaymentData: { - /** - * @description The amount for the payment. - * @example 0.05 - */ - amount?: string; - /** - * @description The target account for an anchor operation. - * @example GDSDQLBVDD5RZYKNDM2LAX5JDNNQOTSZOKECUYEXYMUZMAPXTMDUJCVF - */ - anchorTargetAccount?: string; - /** - * @description The memo content. - * @example 1204asjfnaksf10982e4 - */ - memo?: string; - /** - * @description Type of memo (e.g., text, id). - * @example text - */ - memoType?: string; - }; - /** @description `PIX`, `SEPA`, `CBU` */ - PaymentMethod: string; - /** @description Represents a transaction that has been presigned. Based on UnsignedTx structure. */ - PresignedTx: { - /** @description Any additional metadata associated with the transaction. Can be an empty object. */ - meta?: { - [key: string]: unknown; - }; - /** - * Format: int64 - * @description Nonce for the transaction, if applicable. - */ - nonce?: number; - /** - * @description The phase this transaction belongs to within the ramp logic. - * @enum {string} - */ - phase?: "RampPhase" | "CleanupPhase"; - /** @description Address of the account that signed/will sign this transaction. */ - signer?: string; - /** - * @description The presigned transaction payload or relevant data. - * @example AAAAAKg... - */ - txData?: string; - } & { - [key: string]: unknown; - }; - QuoteResponse: { - anchorFeeFiat: string; - anchorFeeUSD: string; - /** - * Format: date-time - * @description The timestamp when this quote expires. - */ - expiresAt?: string; - feeCurrency: components["schemas"]["RampCurrency"]; - from?: components["schemas"]["DestinationType"]; - /** - * Format: uuid - * @description Unique identifier for the quote. - */ - id?: string; - /** @description The input amount specified in the request. */ - inputAmount?: string; - inputCurrency?: components["schemas"]["RampCurrency"]; - networkFeeFiat: string; - networkFeeUSD: string; - /** @description The calculated output amount after fees and conversions. */ - outputAmount?: string; - outputCurrency?: components["schemas"]["RampCurrency"]; - partnerFeeFiat: string; - partnerFeeUSD: string; - processingFeeFiat: string; - processingFeeUSD: string; - /** @description The type of ramp process. */ - rampType?: components["schemas"]["RampDirection"]; - to?: components["schemas"]["DestinationType"]; - totalFeeFiat: string; - totalFeeUSD: string; - vortexFeeFiat: string; - vortexFeeUSD: string; - }; - /** - * @description Represents supported currencies for ramp operations, including fiat and on-chain tokens. - * @example USDC - * @enum {string} - */ - RampCurrency: "EUR" | "ARS" | "BRL" | "USDC" | "USDT" | "USDC.E"; - /** @enum {string} */ - RampDirection: "BUY" | "SELL"; - RampErrorLog: { - details?: string; - error: string; - phase: components["schemas"]["RampPhase"]; - recoverable?: boolean; - /** Format: date-time */ - timestamp: string; - }; - /** - * @description The current phase of the ramp process. - * @enum {string} - */ - RampPhase: - | "initial" - | "timedOut" - | "stellarCreateAccount" - | "squidrouterApprove" - | "squidrouterSwap" - | "fundEphemeral" - | "nablaApprove" - | "nablaSwap" - | "moonbeamToPendulum" - | "moonbeamToPendulumXcm" - | "pendulumToMoonbeam" - | "assethubToPendulum" - | "pendulumToAssethub" - | "spacewalkRedeem" - | "stellarPayment" - | "subsidizePreSwap" - | "subsidizePostSwap" - | "brlaTeleport" - | "onHoldForComplianceCheck" - | "brlaPayoutOnMoonbeam" - | "failed"; - RampProcess: { - anchorFeeFiat: string; - anchorFeeUSD: string; - countryCode?: components["schemas"]["CountryCode"]; - /** - * Format: date-time - * @description Timestamp of when the ramp process was created. - */ - createdAt?: string; - currentPhase?: components["schemas"]["RampPhase"]; - /** @description BR Code for PIX payment, if applicable. */ - depositQrCode?: string | null; - feeCurrency: components["schemas"]["RampCurrency"]; - /** @description The source network or payment method. */ - from?: components["schemas"]["DestinationType"]; - /** @description Unique identifier for the ramp process. */ - id?: string; - inputAmount: string; - inputCurrency: string; - network?: components["schemas"]["Networks"]; - networkFeeFiat: string; - networkFeeUSD: string; - outputAmount: string; - outputCurrency: string; - partnerFeeFiat: string; - partnerFeeUSD: string; - paymentMethod: components["schemas"]["PaymentMethod"]; - processingFeeFiat: string; - processingFeeUSD: string; - /** - * Format: uuid - * @description The quote ID associated with this ramp process. - */ - quoteId?: string; - /** @description The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API. */ - sessionId?: string; - status?: components["schemas"]["SimpleStatus"]; - /** @description The destination network or payment method. */ - to?: components["schemas"]["DestinationType"]; - totalFeeFiat: string; - totalFeeUSD: string; - /** @description (BUY-only) A link to a block explorer showing the details for the transaction hash. */ - transactionExplorerLink?: string; - /** @description (BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. */ - transactionHash?: string; - /** @description Type of ramp process. */ - type?: components["schemas"]["RampDirection"]; - /** @description Array of unsigned transactions that need to be signed by the user. */ - unsignedTxs?: components["schemas"]["UnsignedTx"][]; - /** - * Format: date-time - * @description Timestamp of the last update to the ramp process. - */ - updatedAt?: string; - vortexFeeFiat: string; - vortexFeeUSD: string; - /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ - walletAddress?: string; - }; - RegisterRampRequest: { - /** - * @description Optional additional data for the ramp process. - * - * For Stellar offramps, paymentData is required. - * - * For Brazil onramps, destinationAddress and taxId arerequired. - * - * For Brazil offramps, pixDestination, taxId and receiverTaxId are required. - */ - additionalData?: { - /** @description Destination address, used for onramp. */ - destinationAddress?: string; - /** @description Auth token obtained from Monerium's API, for the current user. Only required for Monerium-related ramps. */ - moneriumAuthToken: string; - paymentData?: components["schemas"]["PaymentData"]; - /** @description PIX key for the destination account in an onramp. */ - pixDestination?: string; - /** @description Tax ID of the receiver for onramp. */ - receiverTaxId?: string; - /** @description Tax ID of the user. */ - taxId?: string; - /** @description Wallet address initiating the offramp. */ - walletAddress: string; - } & { - [key: string]: unknown; - }; - /** - * Format: uuid - * @description The unique identifier for the quote. - */ - quoteId: string; - /** - * @description Array of accounts that will be used for signing transactions. - * - * For Stellar offramps, Stellar and Pendulum ephemerals are required. - * For Brazil on/off ramps, Moonbeam and Pendulum ephemerals are required. - */ - signingAccounts: { - /** @description The account address. */ - address: string; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/quotes/best": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; /** - * @description The type of the account. - * @enum {string} + * Create a quote for the best network + * @description Generates a new quote for the network that yields the highest output amount for the given parameters. This endpoint compares the output for a given input amount over all supported networks and returns the 'best' quote, defined as the one with the highest output. */ - type: "EVM" | "Stellar" | "Substrate"; - }[]; - }; - /** @description `PENDING`, `FAILED`, `COMPLETED` */ - SimpleStatus: string; - StartKYC2Request: { - documentType: components["schemas"]["KYCDocType"]; - taxId: string; - }; - StartKYC2Response: { - uploadUrls?: components["schemas"]["KYCDataUploadFileFiles"]; - }; - StartRampRequest: { - rampId: string; - }; - /** @enum {string} */ - TaxIdType: "CPF" | "CNPJ"; - TriggerOfframpRequest: { - /** - * @description The amount to offramp. - * @example 100.50 - */ - amount: string; - /** @description The recipient's PIX key. */ - pixKey: string; - /** @description The recipient's Tax ID for validation. */ - receiverTaxId: string; - /** @description The sender's Tax ID. */ - taxId: string; - }; - TriggerOfframpResponse: { - /** @description The ID of the triggered offramp transaction. */ - offrampId?: string; - }; - /** @description Represents an unsigned transaction that requires user signature. Actual properties will depend on the transaction type and network. */ - UnsignedTx: { - meta?: Record; - nonce?: number; - /** @enum {string} */ - phase?: "RampPhase" | "CleanupPhase"; - signer?: string; - /** - * @description The unsigned transaction payload or relevant data. - * @example AAAAAKu... - */ - txData?: string; - } & { - [key: string]: unknown; - }; - UpdateRampRequest: { - /** @description Optional additional data, like transaction hashes from external services. */ - additionalData?: - | ({ - /** @description Transaction hash for AssetHub to Pendulum transfer, if applicable. */ - assetHubToPendulumHash?: string | null; - /** @description Signed message to trigger a Monerium offramp. */ - moneriumOfframpSignature: string; - /** @description Transaction hash for Squid Router approval, if applicable. */ - squidRouterApproveHash?: string | null; - /** @description Transaction hash for Squid Router swap, if applicable. */ - squidRouterSwapHash?: string | null; - } & { - [key: string]: unknown; - }) - | null; - /** @description An array of transactions that have been pre-signed by the user. */ - presignedTxs: components["schemas"]["PresignedTx"][]; - /** - * @description The unique identifier of the ramp process to start. - * @example proc_12345 - */ - rampId: string; - }; - ValidatePixKeyResponse: { - /** @description Indicates if the PIX key is valid. */ - valid?: boolean; - }; - }; - responses: { - "Invalid input": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - code: number; - message: string; - }; - }; - }; - "Record not found": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - code: number; - message: string; - }; - }; - }; - }; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - createSubaccount: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": components["schemas"]["CreateSubaccountRequest"]; - }; - }; - responses: { - /** @description Subaccount created or KYC retry initiated successfully. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CreateSubaccountResponse"]; - }; - }; - /** - * @description Bad Request. Possible reasons: - * - Missing required fields (cpf, cnpj, companyName, startDate) - * - Subaccount already created and KYC level > 0 - * - Other invalid request details - */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description Internal Server Error. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - }; - }; - fetchSubaccountKycStatus: { - parameters: { - query: { - /** @description The user's Tax ID. */ - taxId: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully retrieved KYC status. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["GetKycStatusResponse"]; - }; - }; - /** @description Missing taxId or subaccount not found (returned as 400 from code). */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description No KYC process started. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description Internal Server Error (e.g., no KYC events found when expected). */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - }; - }; - brlaGetSelfieLivenessUrl: { - parameters: { - query: { - /** @description CPF or CNPJ. */ - taxId: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Liveness URL returned. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaGetSelfieLivenessUrlResponse"]; - }; - }; - /** @description Missing taxId or ramp disabled. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description Supabase Bearer required. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Internal server error. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - }; - }; - brlaGetUploadUrls: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AveniaKYCDataUploadRequest"]; - }; - }; - responses: { - /** @description Upload URLs returned. */ - 200: { - headers: { - [name: string]: unknown; + post: operations["createBestQuote"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/ramp/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - content: { - "application/json": components["schemas"]["AveniaKYCDataUploadResponse"]; + /** + * Get ramp status + * @description Fetches an updated ramp process. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ramp ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + anchorFeeFiat: string; + anchorFeeUSD: string; + countryCode?: components["schemas"]["CountryCode"]; + /** + * Format: date-time + * @description Timestamp of when the ramp process was created. + */ + createdAt?: string; + currentPhase?: components["schemas"]["RampPhase"]; + /** @description BR Code for PIX payment, if applicable. */ + depositQrCode?: string | null; + feeCurrency: components["schemas"]["RampCurrency"]; + /** @description The source network or payment method. */ + from?: components["schemas"]["DestinationType"]; + /** @description Unique identifier for the ramp process. */ + id?: string; + inputAmount: string; + inputCurrency: string; + network?: components["schemas"]["Networks"]; + networkFeeFiat: string; + networkFeeUSD: string; + outputAmount: string; + outputCurrency: string; + partnerFeeFiat: string; + partnerFeeUSD: string; + paymentMethod: components["schemas"]["PaymentMethod"]; + processingFeeFiat: string; + processingFeeUSD: string; + /** + * Format: uuid + * @description The quote ID associated with this ramp process. + */ + quoteId?: string; + /** @description The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API. */ + sessionId?: string; + status?: components["schemas"]["SimpleStatus"]; + /** @description The destination network or payment method. */ + to?: components["schemas"]["DestinationType"]; + totalFeeFiat: string; + totalFeeUSD: string; + /** @description (BUY-only) A link to a block explorer showing the details for the transaction hash. */ + transactionExplorerLink?: string; + /** @description (BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. */ + transactionHash?: string; + /** @description Type of ramp process. */ + type?: components["schemas"]["RampDirection"]; + /** @description Array of unsigned transactions that need to be signed by the user. */ + unsignedTxs?: components["schemas"]["UnsignedTx"][]; + /** + * Format: date-time + * @description Timestamp of the last update to the ramp process. + */ + updatedAt?: string; + vortexFeeFiat: string; + vortexFeeUSD: string; + /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ + walletAddress?: string; + }; + }; + }; + }; }; - }; - /** @description Missing/invalid documentType or taxId; or ramp disabled for this tax ID. */ - 400: { - headers: { - [name: string]: unknown; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/ramp/{id}/errors": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; + /** + * Get ramp error logs + * @description Returns the chronological error log for a ramp. + * + * **Auth:** requires either `X-API-Key: sk_*` (partner) OR `Authorization: Bearer ` (user). Ownership is enforced. + */ + get: operations["getRampErrorLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/ramp/history/{walletAddress}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - }; - /** @description Internal server error. */ - 500: { - headers: { - [name: string]: unknown; + /** + * Get ramp history for wallet address + * @description Fetches the transaction history for a given wallet address. The response returns the last 20 items by default. This can be adjusted by using the `limit` and `offset` query parameters. + */ + get: { + parameters: { + query?: { + /** @description The maximum count of transaction items returned in this query. The maximum value is `100`. */ + limit?: number; + /** @description The offset for querying the transactions. Necessary if the number of transaction items of the address is larger than the maximum limit. A larger value will return older transaction items. */ + offset?: number; + }; + header?: never; + path: { + /** @description The wallet address for which the ramp history is queried for. */ + walletAddress: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetRampHistoryResponse"]; + }; + }; + }; }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/ramp/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Register new ramp process + * @description Initiates a new on-ramp or off-ramp process by providing quote details, signing accounts, and additional data. + */ + post: operations["registerRamp"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/ramp/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Start ramp process + * @description Starts a ramp process. + * + * It is assumed all required information from the client has already been sent using the `update` endpoint. This endpoint is only used to tell the backend any external operation (like a bank transfer) has been completed, and the ramp can start. + */ + post: operations["startRamp"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/ramp/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Update ramp process + * @description Submits presigned transactions and additional data to an existing ramp process before starting it. + * This endpoint can be called many times, and data can be incrementally added to the ramp. + * + * Note: For both pre-signed transactions and the generic `additionalData` object, existing properties will be overriden by new values. + * + * ### Required data for ramps. + * The signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object. + * For offramps, the `additionalData` field must contain the confirmation hash corresponding to the inital transaction in which the user sends the funds. + * If the originating chain is `Assethub`, then `assetHubToPendulumHash` must be provided. + * If the originating chain is any `EVM` chain, then `squidRouterApproveHash` and `squidRouterSwapHash` must be provided. + * + * For onramps, no additional data is required after registering the ramp. + */ + post: operations["updateRamp"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/session/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create widget session + * @description Creates a hosted Vortex Widget session and returns the URL to open for the user. + * + * This single endpoint supports two mutually exclusive request shapes: + * + * - **Fixed quote** (`GetWidgetUrlLocked`) — pass a `quoteId` you created via `POST /v1/quotes`. The widget uses that exact quote and does not refresh it. If the quote expires before the user finishes, they must close the window and start over. + * + * - **Auto-refresh** (`GetWidgetUrlRefresh`) — pass the route parameters (`network`, `rampType`, `inputAmount`, plus `fiat` / `cryptoLocked` / `paymentMethod` as relevant for the direction). The widget creates and refreshes quotes on demand for the user. + * + * Use the example switcher below to see the request shape for each mode. `externalSessionId` is required in both modes and is echoed back in webhook payloads. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GetWidgetUrlLocked"] | components["schemas"]["GetWidgetUrlRefresh"]; + }; + }; + responses: { + /** @description Returned when a fixed-quote session was created. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "url": "https://www.vortexfinance.co/widget?externalSessionId=my-session-id"eId=quote_01HXY..." + * } + */ + "application/json": { + /** @description The widget URL to open for the user. */ + url: string; + }; + }; + }; + /** @description Returned when an auto-refresh session was created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "url": "https://www.vortexfinance.co/widget?externalSessionId=my-session-id&rampType=BUY&network=polygon&inputAmount=150&fiat=BRL&cryptoLocked=USDC&paymentMethod=pix" + * } + */ + "application/json": { + /** @description The widget URL to open for the user. */ + url: string; + }; + }; + }; + /** @description Missing required fields, or `quoteId` not provided for fixed-quote mode and route fields not provided for auto-refresh mode. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Quote not found or expired (fixed-quote mode only). */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; }; - }; - }; - }; - getBrlaUser: { - parameters: { - query: { - /** @description The user's Tax ID. */ - taxId: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully retrieved user information. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["GetUserResponse"]; - }; - }; - /** - * @description Bad Request. Possible reasons: - * - Missing taxId query parameter - * - KYC invalid - */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description Subaccount not found. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description Internal Server Error. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - }; - }; - getBrlaUserRemainingLimit: { - parameters: { - query: { - /** @description The user's Tax ID. */ - taxId: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully retrieved user's remaining limits. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["GetUserRemainingLimitResponse"]; - }; - }; - /** @description Missing taxId query parameter or other invalid request. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description Subaccount not found or limits not found. */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description Internal Server Error. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - }; - }; - brlaNewKyc: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["KycLevel1Payload"]; - }; - }; - responses: { - /** @description KYC submission accepted. */ - 200: { - headers: { - [name: string]: unknown; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/supported-countries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Supported Countries */ + get: { + parameters: { + query?: { + /** + * @description ISO code: "BR", "AR", etc. + * @example + */ + countryCode?: string; + /** @description e.g. "Brazil", "Germany" */ + name?: string; + /** @description e.g. "BRL". All the supported currencies you can get from `supported-fiat-currencies` endpoint. */ + fiatCurrency?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + countries: { + /** @description e.g. `DE` */ + countryCode: string; + }[]; + /** @description e.g. 🇩🇪 */ + emoji: string; + /** @description e.g. `Germany` */ + name: string; + support: { + /** @description e.g. `true` */ + buy: boolean; + /** @description e.g. `true` */ + sell: boolean; + }; + /** @description All the supported currencies you can get from `supported-fiat-currencies` endpoint. */ + supportedCurrencies: string[]; + }; + }; + }; + }; }; - content: { - "application/json": components["schemas"]["KycLevel1Response"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/supported-cryptocurrencies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - }; - /** @description Validation failure. */ - 400: { - headers: { - [name: string]: unknown; + /** + * Supported Cryptocurrencies + * @description Retrieve all supported cryptocurrencies, filtered by network. + */ + get: { + parameters: { + query?: { + /** + * @description Filter supported cryptocurrencies by network. Allowed values: `assethub`, `avalanche`, `base`, `bsc`, `ethereum`, `polygon` + * @example + */ + network?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + cryptocurrencies: { + /** @description Defined if network is EVM. */ + assetContractAddress?: string | null; + assetDecimals: number; + /** @description Defined if network is Assethub. */ + assetForeignAssetId?: string | null; + assetNetwork: components["schemas"]["Networks"]; + assetSymbol: string; + }[]; + }; + }; + }; + }; }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/supported-fiat-currencies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Supported Fiat Currencies */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + currencies: { + /** @description e.g. `2` */ + decimals: number; + /** @description e.g. `Brazilian Real` */ + name: string; + /** @description e.g. `BRL` */ + symbol: string; + }[]; + }; + }; + }; + }; }; - }; - /** @description Internal server error. */ - 500: { - headers: { - [name: string]: unknown; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/supported-payment-methods": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; + /** + * Supported Payment Methods + * @description Retrieve all available payment methods, filtered by type or fiat. + */ + get: { + parameters: { + query?: { + /** + * @description Filter supported payment methods by the ramp type. Allowed values: `sell` or `buy`. + * @example + */ + type?: string; + /** + * @description Filter supported payment methods by fiat currency. Allowed values: `EUR`, `ARS`, `BRL`, `USD`, `MXN`, `COP`. + * @example + */ + fiat?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Array of supported payment methods matching the params. */ + "paymentMethods:": { + /** @description Unique identifier of the payment method: `sepa`, `pix`, `cbu` */ + id: string; + /** @description Payment method limits in USD */ + limits: { + max: number; + min: number; + }; + /** @description Unique name of the payment method: `SEPA`, `PIX`, `CBU` */ + name: string; + /** @description Array of supported fiat currencies by payment method. */ + supportedFiats: string[]; + }[]; + }; + }; + }; + }; }; - }; - }; - }; - brlaValidatePixKey: { - parameters: { - query: { - /** @description Pix key to validate (CPF, CNPJ, email, phone, or random key). */ - pixKey: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Validation result. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaValidatePixKeyResponse"]; - }; - }; - /** @description Missing or invalid pix key. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description Supabase Bearer required. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Internal server error. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - }; - }; - createQuote: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhook": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Register Webhook + * @description Register a new webhook to receive event notifications. + * + * **Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + events?: string[]; + /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific quote */ + quoteId?: string; + /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific session */ + sessionId?: string; + /** @description Your HTTPS webhook endpoint URL */ + url: string; + }; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "createdAt": "2025-10-01T16:21:04.648Z", + * "events": [ + * "TRANSACTION_CREATED", + * "STATUS_CHANGE" + * ], + * "id": "340ba946-f3f3-4007-893c-3374bfcd096b", + * "isActive": true, + * "quoteId": "3258910e-93ee-443e-b793-28cc1d4ccdf3", + * "sessionId": null, + * "url": "https://your-website.com" + * } + */ + "application/json": { + /** @description The creation date of the webhook */ + createdAt: string; + /** @description The events the webhook is subscribed for */ + events: string[]; + /** @description Webhook UUID */ + id: string; + /** @description Is the webhook active */ + isActive: boolean; + /** @description (optional): The specific transactionId that the events are subscribed for */ + quoteId?: string; + /** @description (optional): The specific sessionId that the events are subscribed for */ + sessionId?: string; + /** @description Your HTTPS webhook endpoint URL */ + url: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhook/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; /** - * @example { - * "from": "pix", - * "inputAmount": "33", - * "inputCurrency": "BRL", - * "outputCurrency": "USDC", - * "partnerId": "myPartnerId", - * "rampType": "BUY", - * "to": "polygon" - * } + * Delete Webhook + * @description Remove a webhook subscription. + * + * **Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints. */ - "application/json": { - /** @description Your api key, if available. */ - apiKey?: string; - countryCode?: components["schemas"]["CountryCode"]; - /** @description From destination */ - from: components["schemas"]["DestinationType"]; - /** - * @description The amount of currency to be input. - * @example 100.00 - */ - inputAmount: string; - /** @description The currency type for the input amount. */ - inputCurrency: components["schemas"]["RampCurrency"]; - network?: components["schemas"]["Networks"]; - /** @description The desired currency type for the output amount. */ - outputCurrency: components["schemas"]["RampCurrency"]; - /** @description Your partner ID, if available. */ - partnerId?: string; - paymentMethod?: components["schemas"]["PaymentMethod"]; - /** @description The type of ramp process (on-ramp or off-ramp). */ - rampType: components["schemas"]["RampDirection"]; - /** @description To destination */ - to: components["schemas"]["DestinationType"]; - }; - }; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + success: boolean; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - responses: { - /** @description Quote successfully created. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "expiresAt": "2025-05-16T12:30:00Z", - * "fee": "0.50", - * "from": "polygon", - * "id": "quote_7af7171e-aa42-49a2-80c2-9e18483bad38", - * "inputAmount": "33", - * "inputCurrency": "usdc", - * "outputAmount": "32500.50", - * "outputCurrency": "ars", - * "rampType": "sell", - * "to": "cbu" - * } - */ - "application/json": { - anchorFeeFiat: string; - anchorFeeUSD: string; +} +export type webhooks = Record; +export interface components { + schemas: { + AccountMeta: { + /** @description The account address. */ + address: string; /** - * Format: date-time - * @description The timestamp when this quote expires. + * @description The type of the account. + * @enum {string} */ - expiresAt?: string; - feeCurrency: components["schemas"]["RampCurrency"]; - from?: components["schemas"]["DestinationType"]; + type: "EVM" | "Stellar" | "Substrate"; + }; + /** @enum {string} */ + AveniaDocumentType: "ID" | "DRIVERS-LICENSE" | "PASSPORT" | "SELFIE" | "SELFIE-FROM-LIVENESS"; + AveniaKYCDataUploadRequest: { + documentType: components["schemas"]["AveniaDocumentType"]; + /** @description CPF or CNPJ. */ + taxId: string; + }; + AveniaKYCDataUploadResponse: { + idUpload: components["schemas"]["DocumentUploadEntry"]; + selfieUpload: components["schemas"]["DocumentUploadEntry"]; + }; + BrlaAddress: { + cep: string; + city: string; + complement?: string | null; + district: string; + number: string; + state: string; + street: string; + }; + BrlaErrorResponse: { + /** @description Detailed error message or object from BRLA API or server. */ + details?: null & (string | { + [key: string]: unknown; + }); + /** @description A summary of the error. */ + error?: string; + }; + BrlaGetSelfieLivenessUrlResponse: { + id: string; + livenessUrl: string; + uploadURLFront: string; + validateLivenessToken: string; + }; + BrlaValidatePixKeyResponse: { + valid: boolean; + }; + CleanupPhase: { + /** @enum {string} */ + string?: "moonbeamCleanup" | "pendulumCleanup" | "stellarCleanup"; + }; + /** @description Allowed values: `AR`, `BR`, `EU` */ + CountryCode: string; + CreateBestQuoteRequest: { + /** @description Your api key, if available. */ + apiKey?: string; + countryCode?: components["schemas"]["CountryCode"]; + /** @description `PIX`, `SEPA`, `CBU`. Only required if `rampType` is "BUY". */ + from?: components["schemas"]["PaymentMethod"]; /** - * Format: uuid - * @description Unique identifier for the quote. + * @description The amount of currency to be input. + * @example 100.00 */ - id?: string; - /** @description The input amount specified in the request. */ - inputAmount?: string; - inputCurrency?: components["schemas"]["RampCurrency"]; - networkFeeFiat: string; - networkFeeUSD: string; - /** @description The calculated output amount after fees and conversions. */ - outputAmount?: string; - outputCurrency?: components["schemas"]["RampCurrency"]; - partnerFeeFiat: string; - partnerFeeUSD: string; - processingFeeFiat: string; - processingFeeUSD: string; - /** @description The type of ramp process. */ - rampType?: components["schemas"]["RampDirection"]; - to?: components["schemas"]["DestinationType"]; - totalFeeFiat: string; - totalFeeUSD: string; - vortexFeeFiat: string; - vortexFeeUSD: string; - }; - }; - }; - /** - * @description Bad Request. Possible reasons: - * - Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency) - * - Invalid ramp type (must be "BUY" or "SELL") - */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message; unexpected internal failures remain masked. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - }; - }; - createBestQuote: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { + inputAmount: string; + /** @description The currency type for the input amount. */ + inputCurrency: components["schemas"]["RampCurrency"]; + /** @description Optional whitelist of networks to evaluate when searching for the best quote. If omitted or empty, all eligible networks for the corridor are considered. */ + networks?: components["schemas"]["Networks"][]; + /** @description The desired currency type for the output amount. */ + outputCurrency: components["schemas"]["RampCurrency"]; + /** @description Your partner ID, if available. */ + partnerId?: string; + paymentMethod?: components["schemas"]["PaymentMethod"]; + /** @description The type of ramp process (on-ramp or off-ramp). */ + rampType: components["schemas"]["RampDirection"]; + /** @description `PIX`, `SEPA`, `CBU`. Only required if `rampType` is "SELL". */ + to?: components["schemas"]["PaymentMethod"]; + }; + CreateQuoteRequest: { + /** @description Your api key, if available. */ + apiKey?: string; + countryCode?: components["schemas"]["CountryCode"]; + /** @description From destination */ + from: components["schemas"]["DestinationType"]; + /** + * @description The amount of currency to be input. + * @example 100.00 + */ + inputAmount: string; + /** @description The currency type for the input amount. */ + inputCurrency: components["schemas"]["RampCurrency"]; + network?: components["schemas"]["Networks"]; + /** @description The desired currency type for the output amount. */ + outputCurrency: components["schemas"]["RampCurrency"]; + /** @description Your partner ID, if available. */ + partnerId?: string; + paymentMethod?: components["schemas"]["PaymentMethod"]; + /** @description The type of ramp process (on-ramp or off-ramp). */ + rampType: components["schemas"]["RampDirection"]; + /** @description To destination */ + to: components["schemas"]["DestinationType"]; + }; + CreateSubaccountRequest: { + address: components["schemas"]["BrlaAddress"]; + /** + * Format: date + * @description Date must be in format YYYY-MMM-DD. + */ + birthdate: string; + cnpj?: string | null; + companyName?: string | null; + cpf: string; + fullName: string; + phone: string; + /** @description Optional. The quote that triggered onboarding. Omit it for the quote-less KYB deep link (`?kyb` / `?kybLocked` widget entry), where business verification starts before any quote exists. Stored only as onboarding provenance; it is not an authorization input. */ + quoteId?: string | null; + /** + * Format: date + * @description Date must be in format YYYY-MMM-DD. + */ + startDate?: string | null; + taxIdType: components["schemas"]["TaxIdType"]; + }; + CreateSubaccountResponse: { + /** @description The ID of the created or processed subaccount. */ + subaccountId?: string; + }; /** - * @example { - * "from": "pix", - * "inputAmount": "30", - * "inputCurrency": "BRL", - * "outputCurrency": "USDC", - * "partnerId": "myPartnerId", - * "rampType": "BUY" - * } + * @description Represents either a blockchain network or a traditional payment method. + * @enum {string} */ - "application/json": components["schemas"]["CreateBestQuoteRequest"]; - }; - }; - responses: { - /** @description Quote successfully created. */ - 201: { - headers: { - [name: string]: unknown; + DestinationType: "assethub" | "arbitrum" | "avalanche" | "base" | "bsc" | "ethereum" | "polygon" | "moonbeam" | "pendulum" | "stellar" | "pix" | "sepa" | "cbu" | "ach" | "spei"; + DocumentUploadEntry: { + id: string; + livenessUrl?: string; + uploadURLBack?: string; + uploadURLFront: string; + validateLivenessToken?: string; + }; + ErrorResponse: { + /** @description HTTP status code returned by the API error handler. */ + code?: number; + /** @description Validation error details, when the request fails schema or input validation. */ + errors?: Record[]; + /** @description A human-readable error message. */ + message?: string; + /** @description HTTP status code included by selected provider-style error responses. */ + statusCode?: number; + /** @description Provider-style error category, when available. */ + type?: string; + }; + /** @enum {string} */ + FiatToken: "EUR" | "ARS" | "BRL" | "USD" | "MXN" | "COP"; + GetKycStatusResponse: { + /** @description The KYC level achieved. */ + level?: number; + /** + * @description The KYC status. + * @enum {string} + */ + status?: "PENDING" | "APPROVED" | "REJECTED"; + /** + * @description Event type, typically "KYC". + * @enum {string} + */ + type?: "KYC"; + }; + GetRampErrorLogsResponse: components["schemas"]["RampErrorLog"][]; + GetRampHistoryResponse: { + totalCount: string; + transactions: components["schemas"]["GetRampHistoryTransaction"]; + }; + GetRampHistoryTransaction: { + date: string; + /** @description A link to the transaction explorer of the blockchain showing the details of the transaction sending the tokens to the user's wallet address. Only available for 'BUY' ramps. */ + externalTxExplorerLink?: string; + /** @description The hash of the blockchain transaction sending the tokens to the user's wallet address. Only available for 'BUY' ramps. */ + externalTxHash?: string; + from: components["schemas"]["DestinationType"]; + fromAmount: string; + fromCurrency: components["schemas"]["RampCurrency"]; + id: string; + status: components["schemas"]["SimpleStatus"]; + to: components["schemas"]["DestinationType"]; + toAmount: string; + toCurrency: components["schemas"]["RampCurrency"]; + type: components["schemas"]["RampDirection"]; + }; + GetUserRemainingLimitResponse: { + /** + * Format: double + * @description The remaining limit for offramp operations. + */ + remainingLimitOfframp?: number; + /** + * Format: double + * @description The remaining limit for onramp operations. + */ + remainingLimitOnramp?: number; + }; + GetUserResponse: { + /** @description The user's EVM wallet address. */ + evmAddress?: string; + /** + * @description The user's KYC level. + * @enum {number} + */ + kycLevel?: 1 | 2; + }; + GetWidgetUrlLocked: { + /** @description The widget will redirect to this callbackUrl after the user successfully created the transaction. */ + callbackUrl?: string; + /** @description A unique identifier for yourself to keep track of the widget session. Returned in the responses of webhooks, if registered. */ + externalSessionId?: string; + /** @description Pass the ID of an existing quote to make the widget lock in that particular quote without allowing to change it. */ + quoteId: string; + /** @description Pass this parameter if you want to lock the wallet address for the user. It will not be editable in the widget. */ + walletAddressLocked?: string; + }; + GetWidgetUrlRefresh: { + /** @description Your api key, if available. This is passed to all the quotes generated in this widget session. */ + apiKey?: string; + /** @description The widget will redirect to this callbackUrl after the user successfully created the transaction. */ + callbackUrl?: string; + countryCode?: components["schemas"]["CountryCode"]; + cryptoLocked?: components["schemas"]["OnChainToken"]; + /** @description A unique identifier for yourself to keep track of the widget session. Returned in the responses of webhooks, if registered. */ + externalSessionId: string; + fiat?: components["schemas"]["FiatToken"]; + inputAmount: string; + network: components["schemas"]["Networks"]; + /** @description The identifier of a partner. */ + partnerId?: string; + paymentMethod?: components["schemas"]["PaymentMethod"]; + rampType: components["schemas"]["RampDirection"]; + /** @description Pass this parameter if you want to lock the wallet address for the user. It will not be editable in the widget. */ + walletAddressLocked?: string; + }; + KYCDataUploadFileFiles: { + /** Format: url */ + CNHUploadUrl?: string; + /** Format: url */ + RGBackUploadUrl?: string; + /** Format: url */ + RGFrontUploadUrl?: string; + /** Format: url */ + selfieUploadUrl?: string; + }; + /** @enum {string} */ + KYCDocType: "RG" | "CNH"; + KycLevel1Payload: { + city: string; + country: string; + countryOfTaxId: string; + /** @description ISO date (YYYY-MM-DD). */ + dateOfBirth: string; + /** Format: email */ + email: string; + fullName: string; + state: string; + streetAddress: string; + subAccountId: string; + taxIdNumber: string; + uploadedDocumentId: string; + uploadedSelfieId: string; + zipCode: string; + }; + KycLevel1Response: { + id: string; + }; + ListUserApiKeysResponse: { + apiKeys: { + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + expiresAt: string; + id: string; + isActive: boolean; + /** @description Full key value; present for public keys only. Secret key values are never returned after creation. */ + key?: string; + keyPrefix: string; + /** + * Format: date-time + * @description Null until the key is first used. + */ + lastUsedAt?: string; + name: string; + /** @enum {string} */ + type: "public" | "secret"; + /** Format: date-time */ + updatedAt: string; + }[]; + }; + /** + * @description Supported blockchain networks. + * @enum {string} + */ + Networks: "assethub" | "arbitrum" | "avalanche" | "base" | "bsc" | "ethereum" | "polygon" | "moonbeam"; + /** @enum {string} */ + OnChainToken: "USDC" | "USDT" | "ETH" | "USDC.E"; + /** @description Data related to the payment for the ramp transaction. */ + PaymentData: { + /** + * @description The amount for the payment. + * @example 0.05 + */ + amount?: string; + /** + * @description The target account for an anchor operation. + * @example GDSDQLBVDD5RZYKNDM2LAX5JDNNQOTSZOKECUYEXYMUZMAPXTMDUJCVF + */ + anchorTargetAccount?: string; + /** + * @description The memo content. + * @example 1204asjfnaksf10982e4 + */ + memo?: string; + /** + * @description Type of memo (e.g., text, id). + * @example text + */ + memoType?: string; + }; + /** @description `PIX`, `SEPA`, `CBU` */ + PaymentMethod: string; + /** @description Represents a transaction that has been presigned. Based on UnsignedTx structure. */ + PresignedTx: { + /** @description Any additional metadata associated with the transaction. Can be an empty object. */ + meta?: { + [key: string]: unknown; + }; + /** + * Format: int64 + * @description Nonce for the transaction, if applicable. + */ + nonce?: number; + /** + * @description The phase this transaction belongs to within the ramp logic. + * @enum {string} + */ + phase?: "RampPhase" | "CleanupPhase"; + /** @description Address of the account that signed/will sign this transaction. */ + signer?: string; + /** + * @description The presigned transaction payload or relevant data. + * @example AAAAAKg... + */ + txData?: string; + } & { + [key: string]: unknown; }; - content: { - "application/json": { + QuoteResponse: { anchorFeeFiat: string; anchorFeeUSD: string; /** @@ -2194,184 +1474,29 @@ export interface operations { totalFeeUSD: string; vortexFeeFiat: string; vortexFeeUSD: string; - }; - }; - }; - /** - * @description Bad Request. Possible reasons: - * - Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency) - * - Invalid ramp type (must be "BUY" or "SELL") - */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message when every eligible route cannot serve the requested amount; unexpected internal failures remain masked. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - }; - }; - getRampErrorLogs: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ramp ID. */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Error log array (empty if no errors). */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["GetRampErrorLogsResponse"]; - }; - }; - /** @description Authentication required. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Ramp does not belong to authenticated principal. */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Ramp not found. */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - registerRamp: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { + }; /** - * @example { - * "additionalData": { - * "pixDestination": "711.711.011-11", - * "receiverTaxId": "0x7b79995e5f793a07bc00c21412e50ecae098e7f9", - * "taxId": "711.711.011-11" - * }, - * "quoteId": "8e4bca04-aa22-4f86-9ce5-80aaef58ef83", - * "signingAccounts": [ - * { - * "address": "0x7b79995e5f793a07bc00c21412e50ecae098e7f9", - * "network": "moonbeam" - * }, - * { - * "address": "6ftBYTotU4mmCuvUqJvk6qEP7uCzzz771pTMoxcbHFb9rcPv", - * "network": "pendulum" - * } - * ] - * } + * @description Represents supported currencies for ramp operations, including fiat and on-chain tokens. + * @example USDC + * @enum {string} */ - "application/json": { - /** - * @description Optional additional data for the ramp process. - * - * For Stellar offramps, paymentData is required. - * - * For Brazil onramps, destinationAddress and taxId arerequired. - * - * For Brazil offramps, pixDestination, taxId and receiverTaxId are required. - */ - additionalData?: { - /** @description Destination address, used for onramp. */ - destinationAddress?: string; - /** @description Auth token obtained from Monerium's API, for the current user. Only required for Monerium-related ramps. */ - moneriumAuthToken: string; - paymentData?: components["schemas"]["PaymentData"]; - /** @description PIX key for the destination account in an onramp. */ - pixDestination?: string; - /** @description Tax ID of the receiver for onramp. */ - receiverTaxId?: string; - sessionId?: string; - /** @description Tax ID of the user. */ - taxId?: string; - /** @description Wallet address initiating the offramp. */ - walletAddress: string; - } & { - [key: string]: unknown; - }; - /** - * Format: uuid - * @description The unique identifier for the quote. - */ - quoteId: string; - /** - * @description Array of accounts that will be used for signing transactions. - * - * For Stellar offramps, Stellar and Pendulum ephemerals are required. - * For Brazil on/off ramps, Moonbeam and Pendulum ephemerals are required. - */ - signingAccounts: { - /** @description The account address. */ - address: string; - /** - * @description The type of the account. - * @enum {string} - */ - type: "EVM" | "Stellar" | "Substrate"; - }[]; + RampCurrency: "EUR" | "ARS" | "BRL" | "USD" | "MXN" | "COP" | "USDC" | "USDT" | "USDC.E"; + /** @enum {string} */ + RampDirection: "BUY" | "SELL"; + RampErrorLog: { + details?: string; + error: string; + phase: components["schemas"]["RampPhase"]; + recoverable?: boolean; + /** Format: date-time */ + timestamp: string; }; - }; - }; - responses: { - /** @description Ramp process successfully registered. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "brCode": "00020126...", - * "createdAt": "2024-05-16T10:00:00Z", - * "currentPhase": "pending_signature", - * "from": "stellar", - * "id": "proc_12345", - * "quoteId": "41a756dc-04e4-4e4b-b243-9c8f977c24d6", - * "to": "pix", - * "type": "off", - * "unsignedTxs": [ - * { - * "data": "AAAA...", - * "type": "stellar_payment" - * } - * ], - * "updatedAt": "2024-05-16T10:00:00Z" - * } - */ - "application/json": { + /** + * @description The current phase of the ramp process. + * @enum {string} + */ + RampPhase: "initial" | "timedOut" | "stellarCreateAccount" | "squidrouterApprove" | "squidrouterSwap" | "fundEphemeral" | "nablaApprove" | "nablaSwap" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "pendulumToMoonbeam" | "assethubToPendulum" | "pendulumToAssethub" | "spacewalkRedeem" | "stellarPayment" | "subsidizePreSwap" | "subsidizePostSwap" | "brlaTeleport" | "onHoldForComplianceCheck" | "brlaPayoutOnMoonbeam" | "failed"; + RampProcess: { anchorFeeFiat: string; anchorFeeUSD: string; countryCode?: components["schemas"]["CountryCode"]; @@ -2429,306 +1554,1593 @@ export interface operations { vortexFeeUSD: string; /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ walletAddress?: string; - }; - }; - }; - /** @description Bad Request - Invalid input, missing required fields, or validation error. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "message": "Missing required fields" - * } - */ - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Internal Server Error. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "message": "An unexpected error occurred." - * } - */ - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - }; - }; - startRamp: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - /** - * @example { - * "rampId": "proc_12345" - * } - */ - "application/json": components["schemas"]["StartRampRequest"]; - }; - }; - responses: { - /** @description Ramp process successfully started or updated. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "createdAt": "2024-05-16T10:00:00Z", - * "currentPhase": "processing", - * "depositQrCode": "00020126...", - * "from": "stellar", - * "id": "proc_12345", - * "quoteId": "quote_7af7171e-aa42-49a2-80c2-9e18483bad38", - * "to": "pix", - * "type": "sell", - * "unsignedTxs": [], - * "updatedAt": "2024-05-16T12:30:00Z" - * } - */ - "application/json": { - anchorFeeFiat: string; - anchorFeeUSD: string; - countryCode?: components["schemas"]["CountryCode"]; + }; + RegisterRampRequest: { /** - * Format: date-time - * @description Timestamp of when the ramp process was created. + * @description Optional additional data for the ramp process. + * + * For Stellar offramps, paymentData is required. + * + * For Brazil onramps, destinationAddress and taxId arerequired. + * + * For Brazil offramps, pixDestination, taxId and receiverTaxId are required. */ - createdAt?: string; - currentPhase?: components["schemas"]["RampPhase"]; - /** @description BR Code for PIX payment, if applicable. */ - depositQrCode?: string | null; - feeCurrency: components["schemas"]["RampCurrency"]; - /** @description The source network or payment method. */ - from?: components["schemas"]["DestinationType"]; - /** @description Unique identifier for the ramp process. */ - id?: string; - inputAmount: string; - inputCurrency: string; - network?: components["schemas"]["Networks"]; - networkFeeFiat: string; - networkFeeUSD: string; - outputAmount: string; - outputCurrency: string; - partnerFeeFiat: string; - partnerFeeUSD: string; - paymentMethod: components["schemas"]["PaymentMethod"]; - processingFeeFiat: string; - processingFeeUSD: string; + additionalData?: { + /** @description Destination address, used for onramp. */ + destinationAddress?: string; + /** @description Auth token obtained from Monerium's API, for the current user. Only required for Monerium-related ramps. */ + moneriumAuthToken: string; + paymentData?: components["schemas"]["PaymentData"]; + /** @description PIX key for the destination account in an onramp. */ + pixDestination?: string; + /** @description Tax ID of the receiver for onramp. */ + receiverTaxId?: string; + /** @description Tax ID of the user. */ + taxId?: string; + /** @description Wallet address initiating the offramp. */ + walletAddress: string; + } & { + [key: string]: unknown; + }; /** * Format: uuid - * @description The quote ID associated with this ramp process. + * @description The unique identifier for the quote. */ - quoteId?: string; - /** @description The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API. */ - sessionId?: string; - status?: components["schemas"]["SimpleStatus"]; - /** @description The destination network or payment method. */ - to?: components["schemas"]["DestinationType"]; - totalFeeFiat: string; - totalFeeUSD: string; - /** @description (BUY-only) A link to a block explorer showing the details for the transaction hash. */ - transactionExplorerLink?: string; - /** @description (BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. */ - transactionHash?: string; - /** @description Type of ramp process. */ - type?: components["schemas"]["RampDirection"]; - /** @description Array of unsigned transactions that need to be signed by the user. */ - unsignedTxs?: components["schemas"]["UnsignedTx"][]; + quoteId: string; /** - * Format: date-time - * @description Timestamp of the last update to the ramp process. + * @description Array of accounts that will be used for signing transactions. + * + * For Stellar offramps, Stellar and Pendulum ephemerals are required. + * For Brazil on/off ramps, Moonbeam and Pendulum ephemerals are required. */ - updatedAt?: string; - vortexFeeFiat: string; - vortexFeeUSD: string; - /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ - walletAddress?: string; - }; - }; - }; - /** - * @description Bad Request. Possible reasons: - * - Missing required fields (rampId, presignedTxs) - * - Invalid additional data format (if provided, must be an object) - */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - /** @description Internal Server Error. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "message": "An unexpected error occurred." - * } - */ - "application/json": Record; - }; - }; + signingAccounts: { + /** @description The account address. */ + address: string; + /** + * @description The type of the account. + * @enum {string} + */ + type: "EVM" | "Stellar" | "Substrate"; + }[]; + }; + /** @description `PENDING`, `FAILED`, `COMPLETED` */ + SimpleStatus: string; + StartKYC2Request: { + documentType: components["schemas"]["KYCDocType"]; + taxId: string; + }; + StartKYC2Response: { + uploadUrls?: components["schemas"]["KYCDataUploadFileFiles"]; + }; + StartRampRequest: { + rampId: string; + }; + /** @enum {string} */ + TaxIdType: "CPF" | "CNPJ"; + TriggerOfframpRequest: { + /** + * @description The amount to offramp. + * @example 100.50 + */ + amount: string; + /** @description The recipient's PIX key. */ + pixKey: string; + /** @description The recipient's Tax ID for validation. */ + receiverTaxId: string; + /** @description The sender's Tax ID. */ + taxId: string; + }; + TriggerOfframpResponse: { + /** @description The ID of the triggered offramp transaction. */ + offrampId?: string; + }; + /** @description Represents an unsigned transaction that requires user signature. Actual properties will depend on the transaction type and network. */ + UnsignedTx: { + meta?: Record; + nonce?: number; + /** @enum {string} */ + phase?: "RampPhase" | "CleanupPhase"; + signer?: string; + /** + * @description The unsigned transaction payload or relevant data. + * @example AAAAAKu... + */ + txData?: string; + } & { + [key: string]: unknown; + }; + UpdateRampRequest: { + /** @description Optional additional data, like transaction hashes from external services. */ + additionalData?: ({ + /** @description Transaction hash for AssetHub to Pendulum transfer, if applicable. */ + assetHubToPendulumHash?: string | null; + /** @description Signed message to trigger a Monerium offramp. */ + moneriumOfframpSignature: string; + /** @description Transaction hash for Squid Router approval, if applicable. */ + squidRouterApproveHash?: string | null; + /** @description Transaction hash for Squid Router swap, if applicable. */ + squidRouterSwapHash?: string | null; + } & { + [key: string]: unknown; + }) | null; + /** @description An array of transactions that have been pre-signed by the user. */ + presignedTxs: components["schemas"]["PresignedTx"][]; + /** + * @description The unique identifier of the ramp process to start. + * @example proc_12345 + */ + rampId: string; + }; + UserApiKeyErrorResponse: { + error: { + /** @description Machine-readable error code, e.g. `AUTHENTICATION_REQUIRED`, `API_KEY_LIMIT_REACHED`, `INVALID_EXPIRES_AT`, `API_KEY_NOT_FOUND`. */ + code: string; + message: string; + status: number; + }; + }; + UserApiKeyPairResponse: { + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + expiresAt: string; + isActive: boolean; + publicKey: { + id: string; + /** @description The full key value. For the secret key this is returned only in this response. */ + key: string; + /** @description Constant 8-character prefix, e.g. `pk_live_` or `sk_test_`. */ + keyPrefix: string; + name: string; + /** @enum {string} */ + type: "public" | "secret"; + }; + secretKey: { + id: string; + /** @description The full key value. For the secret key this is returned only in this response. */ + key: string; + /** @description Constant 8-character prefix, e.g. `pk_live_` or `sk_test_`. */ + keyPrefix: string; + name: string; + /** @enum {string} */ + type: "public" | "secret"; + }; + }; + ValidatePixKeyResponse: { + /** @description Indicates if the PIX key is valid. */ + valid?: boolean; + }; }; - }; - startRamp: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + responses: { + InvalidInput: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + code: number; + message: string; + }; + }; + }; + RecordNotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + code: number; + message: string; + }; + }; + }; }; - requestBody?: { - content: { - /** - * @example { - * "additionalData": { - * "squidRouterApproveHash": "0x123...", - * "squidRouterSwapHash": "0x456..." - * }, - * "presignedTxs": [ - * { - * "meta": {}, - * "nonce": 1, - * "phase": "RampPhase", - * "signer": "GB2TP24WCY6BPGFX4SOGDHT7IGJRR7HCDQT2VL2MVCZJTJCGKMVGQGQB", - * "txData": "AAAAAKu..." - * } - * ], - * "rampId": "proc_12345" - * } - */ - "application/json": components["schemas"]["UpdateRampRequest"]; - }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + listUserApiKeys: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Active keys, newest first. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListUserApiKeysResponse"]; + }; + }; + /** @description Missing or invalid Bearer token. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + }; }; - responses: { - /** @description Ramp process successfully started or updated. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "createdAt": "2024-05-16T10:00:00Z", - * "currentPhase": "processing", - * "depositQrCode": "00020126...", - * "from": "stellar", - * "id": "proc_12345", - * "quoteId": "quote_7af7171e-aa42-49a2-80c2-9e18483bad38", - * "to": "pix", - * "type": "off", - * "unsignedTxs": [], - * "updatedAt": "2024-05-16T12:30:00Z" - * } - */ - "application/json": { - anchorFeeFiat: string; - anchorFeeUSD: string; - countryCode?: components["schemas"]["CountryCode"]; + createUserApiKey: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + /** + * @example { + * "expiresAt": "2027-07-06T00:00:00.000Z", + * "name": "my-backend" + * } + */ + "application/json": { + /** + * Format: date-time + * @description Optional ISO-8601 expiry, at most 2 years from now. Defaults to 1 year. + */ + expiresAt?: string; + /** @description Optional label; defaults to "API Key". */ + name?: string; + }; + }; + }; + responses: { + /** @description Key pair created. Persist `secretKey.key` immediately; it cannot be retrieved again. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyPairResponse"]; + }; + }; + /** @description `INVALID_EXPIRES_AT`: expiresAt is not a valid ISO-8601 date or is more than 2 years from now. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + /** @description Missing or invalid Bearer token. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + /** @description `API_KEY_LIMIT_REACHED`: the user already holds the maximum of 10 active keys. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + }; + }; + revokeUserApiKey: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the key to revoke. */ + keyId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + /** + * @example { + * "pairedKeyId": "00000000-0000-0000-0000-000000000000" + * } + */ + "application/json": { + /** @description Optional ID of the other half of the pair, to revoke both keys together. */ + pairedKeyId?: string; + }; + }; + }; + responses: { + /** @description Key(s) revoked. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `INVALID_KEY_PAIR` or `KEY_PAIR_MISMATCH`: the two keys are not opposite halves of the same pair. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + /** @description Missing or invalid Bearer token. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + /** @description `API_KEY_NOT_FOUND` or `PAIRED_PUBLIC_KEY_NOT_FOUND`: key missing, already revoked, or not owned by the user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + }; + }; + }; + }; + requestOTP: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "email": "user@example.com" + * } + */ + "application/json": { + /** Format: email */ + email: string; + /** @description Optional locale for the email, e.g. `pt-BR`. */ + locale?: string; + }; + }; + }; + responses: { + /** @description OTP sent. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + success: boolean; + }; + }; + }; + /** @description Email missing or locale not a string. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Failed to send the OTP email. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + verifyOTP: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "email": "user@example.com", + * "token": "123456" + * } + */ + "application/json": { + /** Format: email */ + email: string; + /** @description The 6-digit code from the email. */ + token: string; + }; + }; + }; + responses: { + /** @description Session created. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + access_token: string; + refresh_token: string; + success: boolean; + /** Format: uuid */ + user_id: string; + }; + }; + }; + /** @description Missing fields, or the OTP is invalid or expired. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + createSubaccount: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateSubaccountRequest"]; + }; + }; + responses: { + /** @description Subaccount created or KYC retry initiated successfully. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateSubaccountResponse"]; + }; + }; /** - * Format: date-time - * @description Timestamp of when the ramp process was created. + * @description Bad Request. Possible reasons: + * - Missing required fields (cpf, cnpj, companyName, startDate) + * - Subaccount already created and KYC level > 0 + * - Other invalid request details */ - createdAt?: string; - currentPhase?: components["schemas"]["RampPhase"]; - /** @description BR Code for PIX payment, if applicable. */ - depositQrCode?: string | null; - feeCurrency: components["schemas"]["RampCurrency"]; - /** @description The source network or payment method. */ - from?: components["schemas"]["DestinationType"]; - /** @description Unique identifier for the ramp process. */ - id?: string; - inputAmount: string; - inputCurrency: string; - network?: components["schemas"]["Networks"]; - networkFeeFiat: string; - networkFeeUSD: string; - outputAmount: string; - outputCurrency: string; - partnerFeeFiat: string; - partnerFeeUSD: string; - paymentMethod: components["schemas"]["PaymentMethod"]; - processingFeeFiat: string; - processingFeeUSD: string; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Internal Server Error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + fetchSubaccountKycStatus: { + parameters: { + query: { + /** @description The user's Tax ID. */ + taxId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully retrieved KYC status. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetKycStatusResponse"]; + }; + }; + /** @description Missing taxId or subaccount not found (returned as 400 from code). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description No KYC process started. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Internal Server Error (e.g., no KYC events found when expected). */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + brlaGetSelfieLivenessUrl: { + parameters: { + query: { + /** @description CPF or CNPJ. */ + taxId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liveness URL returned. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaGetSelfieLivenessUrlResponse"]; + }; + }; + /** @description Missing taxId or ramp disabled. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Supabase Bearer required. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + brlaGetUploadUrls: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AveniaKYCDataUploadRequest"]; + }; + }; + responses: { + /** @description Upload URLs returned. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AveniaKYCDataUploadResponse"]; + }; + }; + /** @description Missing/invalid documentType or taxId; or ramp disabled for this tax ID. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + getBrlaUser: { + parameters: { + query: { + /** @description The user's Tax ID. */ + taxId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully retrieved user information. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetUserResponse"]; + }; + }; /** - * Format: uuid - * @description The quote ID associated with this ramp process. + * @description Bad Request. Possible reasons: + * - Missing taxId query parameter + * - KYC invalid */ - quoteId?: string; - /** @description The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API. */ - sessionId?: string; - status?: components["schemas"]["SimpleStatus"]; - /** @description The destination network or payment method. */ - to?: components["schemas"]["DestinationType"]; - totalFeeFiat: string; - totalFeeUSD: string; - /** @description (BUY-only) A link to a block explorer showing the details for the transaction hash. */ - transactionExplorerLink?: string; - /** @description (BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. */ - transactionHash?: string; - /** @description Type of ramp process. */ - type?: components["schemas"]["RampDirection"]; - /** @description Array of unsigned transactions that need to be signed by the user. */ - unsignedTxs?: components["schemas"]["UnsignedTx"][]; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Subaccount not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Internal Server Error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + getBrlaUserRemainingLimit: { + parameters: { + query: { + /** @description The user's Tax ID. */ + taxId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully retrieved user's remaining limits. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetUserRemainingLimitResponse"]; + }; + }; + /** @description Missing taxId query parameter or other invalid request. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Subaccount not found or limits not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Internal Server Error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + brlaNewKyc: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["KycLevel1Payload"]; + }; + }; + responses: { + /** @description KYC submission accepted. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KycLevel1Response"]; + }; + }; + /** @description Validation failure. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + brlaValidatePixKey: { + parameters: { + query: { + /** @description Pix key to validate (CPF, CNPJ, email, phone, or random key). */ + pixKey: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Validation result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaValidatePixKeyResponse"]; + }; + }; + /** @description Missing or invalid pix key. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Supabase Bearer required. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + createQuote: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + /** + * @example { + * "from": "pix", + * "inputAmount": "33", + * "inputCurrency": "BRL", + * "outputCurrency": "USDC", + * "partnerId": "myPartnerId", + * "rampType": "BUY", + * "to": "polygon" + * } + */ + "application/json": { + /** @description Your api key, if available. */ + apiKey?: string; + countryCode?: components["schemas"]["CountryCode"]; + /** @description From destination */ + from: components["schemas"]["DestinationType"]; + /** + * @description The amount of currency to be input. + * @example 100.00 + */ + inputAmount: string; + /** @description The currency type for the input amount. */ + inputCurrency: components["schemas"]["RampCurrency"]; + network?: components["schemas"]["Networks"]; + /** @description The desired currency type for the output amount. */ + outputCurrency: components["schemas"]["RampCurrency"]; + /** @description Your partner ID, if available. */ + partnerId?: string; + paymentMethod?: components["schemas"]["PaymentMethod"]; + /** @description The type of ramp process (on-ramp or off-ramp). */ + rampType: components["schemas"]["RampDirection"]; + /** @description To destination */ + to: components["schemas"]["DestinationType"]; + }; + }; + }; + responses: { + /** @description Quote successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "expiresAt": "2025-05-16T12:30:00Z", + * "fee": "0.50", + * "from": "polygon", + * "id": "quote_7af7171e-aa42-49a2-80c2-9e18483bad38", + * "inputAmount": "33", + * "inputCurrency": "usdc", + * "outputAmount": "32500.50", + * "outputCurrency": "ars", + * "rampType": "sell", + * "to": "cbu" + * } + */ + "application/json": { + anchorFeeFiat: string; + anchorFeeUSD: string; + /** + * Format: date-time + * @description The timestamp when this quote expires. + */ + expiresAt?: string; + feeCurrency: components["schemas"]["RampCurrency"]; + from?: components["schemas"]["DestinationType"]; + /** + * Format: uuid + * @description Unique identifier for the quote. + */ + id?: string; + /** @description The input amount specified in the request. */ + inputAmount?: string; + inputCurrency?: components["schemas"]["RampCurrency"]; + networkFeeFiat: string; + networkFeeUSD: string; + /** @description The calculated output amount after fees and conversions. */ + outputAmount?: string; + outputCurrency?: components["schemas"]["RampCurrency"]; + partnerFeeFiat: string; + partnerFeeUSD: string; + processingFeeFiat: string; + processingFeeUSD: string; + /** @description The type of ramp process. */ + rampType?: components["schemas"]["RampDirection"]; + to?: components["schemas"]["DestinationType"]; + totalFeeFiat: string; + totalFeeUSD: string; + vortexFeeFiat: string; + vortexFeeUSD: string; + }; + }; + }; /** - * Format: date-time - * @description Timestamp of the last update to the ramp process. + * @description Bad Request. Possible reasons: + * - Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency) + * - Invalid ramp type (must be "BUY" or "SELL") */ - updatedAt?: string; - vortexFeeFiat: string; - vortexFeeUSD: string; - /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ - walletAddress?: string; - }; - }; - }; - /** - * @description Bad Request. Possible reasons: - * - Missing required fields (rampId, presignedTxs) - * - Invalid additional data format (if provided, must be an object) - */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - /** @description Internal Server Error. */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "message": "An unexpected error occurred." - * } - */ - "application/json": Record; - }; - }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message; unexpected internal failures remain masked. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createBestQuote: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + /** + * @example { + * "from": "pix", + * "inputAmount": "30", + * "inputCurrency": "BRL", + * "outputCurrency": "USDC", + * "partnerId": "myPartnerId", + * "rampType": "BUY" + * } + */ + "application/json": components["schemas"]["CreateBestQuoteRequest"]; + }; + }; + responses: { + /** @description Quote successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + anchorFeeFiat: string; + anchorFeeUSD: string; + /** + * Format: date-time + * @description The timestamp when this quote expires. + */ + expiresAt?: string; + feeCurrency: components["schemas"]["RampCurrency"]; + from?: components["schemas"]["DestinationType"]; + /** + * Format: uuid + * @description Unique identifier for the quote. + */ + id?: string; + /** @description The input amount specified in the request. */ + inputAmount?: string; + inputCurrency?: components["schemas"]["RampCurrency"]; + networkFeeFiat: string; + networkFeeUSD: string; + /** @description The calculated output amount after fees and conversions. */ + outputAmount?: string; + outputCurrency?: components["schemas"]["RampCurrency"]; + partnerFeeFiat: string; + partnerFeeUSD: string; + processingFeeFiat: string; + processingFeeUSD: string; + /** @description The type of ramp process. */ + rampType?: components["schemas"]["RampDirection"]; + to?: components["schemas"]["DestinationType"]; + totalFeeFiat: string; + totalFeeUSD: string; + vortexFeeFiat: string; + vortexFeeUSD: string; + }; + }; + }; + /** + * @description Bad Request. Possible reasons: + * - Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency) + * - Invalid ramp type (must be "BUY" or "SELL") + */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message when every eligible route cannot serve the requested amount; unexpected internal failures remain masked. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getRampErrorLogs: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ramp ID. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Error log array (empty if no errors). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetRampErrorLogsResponse"]; + }; + }; + /** @description Authentication required. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Ramp does not belong to authenticated principal. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Ramp not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + registerRamp: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + /** + * @example { + * "additionalData": { + * "pixDestination": "711.711.011-11", + * "receiverTaxId": "0x7b79995e5f793a07bc00c21412e50ecae098e7f9", + * "taxId": "711.711.011-11" + * }, + * "quoteId": "8e4bca04-aa22-4f86-9ce5-80aaef58ef83", + * "signingAccounts": [ + * { + * "address": "0x7b79995e5f793a07bc00c21412e50ecae098e7f9", + * "network": "moonbeam" + * }, + * { + * "address": "6ftBYTotU4mmCuvUqJvk6qEP7uCzzz771pTMoxcbHFb9rcPv", + * "network": "pendulum" + * } + * ] + * } + */ + "application/json": { + /** + * @description Optional additional data for the ramp process. + * + * For Stellar offramps, paymentData is required. + * + * For Brazil onramps, destinationAddress and taxId arerequired. + * + * For Brazil offramps, pixDestination, taxId and receiverTaxId are required. + */ + additionalData?: { + /** @description Destination address, used for onramp. */ + destinationAddress?: string; + /** @description Auth token obtained from Monerium's API, for the current user. Only required for Monerium-related ramps. */ + moneriumAuthToken: string; + paymentData?: components["schemas"]["PaymentData"]; + /** @description PIX key for the destination account in an onramp. */ + pixDestination?: string; + /** @description Tax ID of the receiver for onramp. */ + receiverTaxId?: string; + sessionId?: string; + /** @description Tax ID of the user. */ + taxId?: string; + /** @description Wallet address initiating the offramp. */ + walletAddress: string; + } & { + [key: string]: unknown; + }; + /** + * Format: uuid + * @description The unique identifier for the quote. + */ + quoteId: string; + /** + * @description Array of accounts that will be used for signing transactions. + * + * For Stellar offramps, Stellar and Pendulum ephemerals are required. + * For Brazil on/off ramps, Moonbeam and Pendulum ephemerals are required. + */ + signingAccounts: { + /** @description The account address. */ + address: string; + /** + * @description The type of the account. + * @enum {string} + */ + type: "EVM" | "Stellar" | "Substrate"; + }[]; + }; + }; + }; + responses: { + /** @description Ramp process successfully registered. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "brCode": "00020126...", + * "createdAt": "2024-05-16T10:00:00Z", + * "currentPhase": "pending_signature", + * "from": "stellar", + * "id": "proc_12345", + * "quoteId": "41a756dc-04e4-4e4b-b243-9c8f977c24d6", + * "to": "pix", + * "type": "off", + * "unsignedTxs": [ + * { + * "data": "AAAA...", + * "type": "stellar_payment" + * } + * ], + * "updatedAt": "2024-05-16T10:00:00Z" + * } + */ + "application/json": { + anchorFeeFiat: string; + anchorFeeUSD: string; + countryCode?: components["schemas"]["CountryCode"]; + /** + * Format: date-time + * @description Timestamp of when the ramp process was created. + */ + createdAt?: string; + currentPhase?: components["schemas"]["RampPhase"]; + /** @description BR Code for PIX payment, if applicable. */ + depositQrCode?: string | null; + feeCurrency: components["schemas"]["RampCurrency"]; + /** @description The source network or payment method. */ + from?: components["schemas"]["DestinationType"]; + /** @description Unique identifier for the ramp process. */ + id?: string; + inputAmount: string; + inputCurrency: string; + network?: components["schemas"]["Networks"]; + networkFeeFiat: string; + networkFeeUSD: string; + outputAmount: string; + outputCurrency: string; + partnerFeeFiat: string; + partnerFeeUSD: string; + paymentMethod: components["schemas"]["PaymentMethod"]; + processingFeeFiat: string; + processingFeeUSD: string; + /** + * Format: uuid + * @description The quote ID associated with this ramp process. + */ + quoteId?: string; + /** @description The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API. */ + sessionId?: string; + status?: components["schemas"]["SimpleStatus"]; + /** @description The destination network or payment method. */ + to?: components["schemas"]["DestinationType"]; + totalFeeFiat: string; + totalFeeUSD: string; + /** @description (BUY-only) A link to a block explorer showing the details for the transaction hash. */ + transactionExplorerLink?: string; + /** @description (BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. */ + transactionHash?: string; + /** @description Type of ramp process. */ + type?: components["schemas"]["RampDirection"]; + /** @description Array of unsigned transactions that need to be signed by the user. */ + unsignedTxs?: components["schemas"]["UnsignedTx"][]; + /** + * Format: date-time + * @description Timestamp of the last update to the ramp process. + */ + updatedAt?: string; + vortexFeeFiat: string; + vortexFeeUSD: string; + /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ + walletAddress?: string; + }; + }; + }; + /** @description Bad Request - Invalid input, missing required fields, or validation error. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Missing required fields" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Internal Server Error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "An unexpected error occurred." + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + startRamp: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + /** + * @example { + * "rampId": "proc_12345" + * } + */ + "application/json": components["schemas"]["StartRampRequest"]; + }; + }; + responses: { + /** @description Ramp process successfully started or updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "createdAt": "2024-05-16T10:00:00Z", + * "currentPhase": "processing", + * "depositQrCode": "00020126...", + * "from": "stellar", + * "id": "proc_12345", + * "quoteId": "quote_7af7171e-aa42-49a2-80c2-9e18483bad38", + * "to": "pix", + * "type": "sell", + * "unsignedTxs": [], + * "updatedAt": "2024-05-16T12:30:00Z" + * } + */ + "application/json": { + anchorFeeFiat: string; + anchorFeeUSD: string; + countryCode?: components["schemas"]["CountryCode"]; + /** + * Format: date-time + * @description Timestamp of when the ramp process was created. + */ + createdAt?: string; + currentPhase?: components["schemas"]["RampPhase"]; + /** @description BR Code for PIX payment, if applicable. */ + depositQrCode?: string | null; + feeCurrency: components["schemas"]["RampCurrency"]; + /** @description The source network or payment method. */ + from?: components["schemas"]["DestinationType"]; + /** @description Unique identifier for the ramp process. */ + id?: string; + inputAmount: string; + inputCurrency: string; + network?: components["schemas"]["Networks"]; + networkFeeFiat: string; + networkFeeUSD: string; + outputAmount: string; + outputCurrency: string; + partnerFeeFiat: string; + partnerFeeUSD: string; + paymentMethod: components["schemas"]["PaymentMethod"]; + processingFeeFiat: string; + processingFeeUSD: string; + /** + * Format: uuid + * @description The quote ID associated with this ramp process. + */ + quoteId?: string; + /** @description The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API. */ + sessionId?: string; + status?: components["schemas"]["SimpleStatus"]; + /** @description The destination network or payment method. */ + to?: components["schemas"]["DestinationType"]; + totalFeeFiat: string; + totalFeeUSD: string; + /** @description (BUY-only) A link to a block explorer showing the details for the transaction hash. */ + transactionExplorerLink?: string; + /** @description (BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. */ + transactionHash?: string; + /** @description Type of ramp process. */ + type?: components["schemas"]["RampDirection"]; + /** @description Array of unsigned transactions that need to be signed by the user. */ + unsignedTxs?: components["schemas"]["UnsignedTx"][]; + /** + * Format: date-time + * @description Timestamp of the last update to the ramp process. + */ + updatedAt?: string; + vortexFeeFiat: string; + vortexFeeUSD: string; + /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ + walletAddress?: string; + }; + }; + }; + /** + * @description Bad Request. Possible reasons: + * - Missing required fields (rampId, presignedTxs) + * - Invalid additional data format (if provided, must be an object) + */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Internal Server Error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "An unexpected error occurred." + * } + */ + "application/json": Record; + }; + }; + }; + }; + updateRamp: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + /** + * @example { + * "additionalData": { + * "squidRouterApproveHash": "0x123...", + * "squidRouterSwapHash": "0x456..." + * }, + * "presignedTxs": [ + * { + * "meta": {}, + * "nonce": 1, + * "phase": "RampPhase", + * "signer": "GB2TP24WCY6BPGFX4SOGDHT7IGJRR7HCDQT2VL2MVCZJTJCGKMVGQGQB", + * "txData": "AAAAAKu..." + * } + * ], + * "rampId": "proc_12345" + * } + */ + "application/json": components["schemas"]["UpdateRampRequest"]; + }; + }; + responses: { + /** @description Ramp process successfully started or updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "createdAt": "2024-05-16T10:00:00Z", + * "currentPhase": "processing", + * "depositQrCode": "00020126...", + * "from": "stellar", + * "id": "proc_12345", + * "quoteId": "quote_7af7171e-aa42-49a2-80c2-9e18483bad38", + * "to": "pix", + * "type": "off", + * "unsignedTxs": [], + * "updatedAt": "2024-05-16T12:30:00Z" + * } + */ + "application/json": { + anchorFeeFiat: string; + anchorFeeUSD: string; + countryCode?: components["schemas"]["CountryCode"]; + /** + * Format: date-time + * @description Timestamp of when the ramp process was created. + */ + createdAt?: string; + currentPhase?: components["schemas"]["RampPhase"]; + /** @description BR Code for PIX payment, if applicable. */ + depositQrCode?: string | null; + feeCurrency: components["schemas"]["RampCurrency"]; + /** @description The source network or payment method. */ + from?: components["schemas"]["DestinationType"]; + /** @description Unique identifier for the ramp process. */ + id?: string; + inputAmount: string; + inputCurrency: string; + network?: components["schemas"]["Networks"]; + networkFeeFiat: string; + networkFeeUSD: string; + outputAmount: string; + outputCurrency: string; + partnerFeeFiat: string; + partnerFeeUSD: string; + paymentMethod: components["schemas"]["PaymentMethod"]; + processingFeeFiat: string; + processingFeeUSD: string; + /** + * Format: uuid + * @description The quote ID associated with this ramp process. + */ + quoteId?: string; + /** @description The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API. */ + sessionId?: string; + status?: components["schemas"]["SimpleStatus"]; + /** @description The destination network or payment method. */ + to?: components["schemas"]["DestinationType"]; + totalFeeFiat: string; + totalFeeUSD: string; + /** @description (BUY-only) A link to a block explorer showing the details for the transaction hash. */ + transactionExplorerLink?: string; + /** @description (BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. */ + transactionHash?: string; + /** @description Type of ramp process. */ + type?: components["schemas"]["RampDirection"]; + /** @description Array of unsigned transactions that need to be signed by the user. */ + unsignedTxs?: components["schemas"]["UnsignedTx"][]; + /** + * Format: date-time + * @description Timestamp of the last update to the ramp process. + */ + updatedAt?: string; + vortexFeeFiat: string; + vortexFeeUSD: string; + /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ + walletAddress?: string; + }; + }; + }; + /** + * @description Bad Request. Possible reasons: + * - Missing required fields (rampId, presignedTxs) + * - Invalid additional data format (if provided, must be an object) + */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Internal Server Error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "An unexpected error occurred." + * } + */ + "application/json": Record; + }; + }; + }; }; - }; } diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 8ac48e0c2..7f5845a30 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -1,7 +1,7 @@ { "components": { "responses": { - "Invalid input": { + "InvalidInput": { "content": { "application/json": { "schema": { @@ -20,7 +20,7 @@ }, "description": "" }, - "Record not found": { + "RecordNotFound": { "content": { "application/json": { "schema": { @@ -339,7 +339,9 @@ "stellar", "pix", "sepa", - "cbu" + "cbu", + "ach", + "spei" ], "type": "string" }, @@ -393,7 +395,7 @@ "type": "object" }, "FiatToken": { - "enum": ["EUR", "ARS", "BRL"], + "enum": ["EUR", "ARS", "BRL", "USD", "MXN", "COP"], "type": "string" }, "GetKycStatusResponse": { @@ -670,6 +672,58 @@ "required": ["id"], "type": "object" }, + "ListUserApiKeysResponse": { + "properties": { + "apiKeys": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "expiresAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "key": { + "description": "Full key value; present for public keys only. Secret key values are never returned after creation.", + "type": "string" + }, + "keyPrefix": { + "type": "string" + }, + "lastUsedAt": { + "description": "Null until the key is first used.", + "format": "date-time", + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "enum": ["public", "secret"], + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": ["createdAt", "expiresAt", "id", "isActive", "keyPrefix", "name", "type", "updatedAt"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["apiKeys"], + "type": "object" + }, "Networks": { "description": "Supported blockchain networks.", "enum": ["assethub", "arbitrum", "avalanche", "base", "bsc", "ethereum", "polygon", "moonbeam"], @@ -836,7 +890,7 @@ }, "RampCurrency": { "description": "Represents supported currencies for ramp operations, including fiat and on-chain tokens.", - "enum": ["EUR", "ARS", "BRL", "USDC", "USDT", "USDC.E"], + "enum": ["EUR", "ARS", "BRL", "USD", "MXN", "COP", "USDC", "USDT", "USDC.E"], "examples": ["USDC"], "type": "string" }, @@ -1245,6 +1299,93 @@ "required": ["rampId", "presignedTxs"], "type": "object" }, + "UserApiKeyErrorResponse": { + "properties": { + "error": { + "properties": { + "code": { + "description": "Machine-readable error code, e.g. `AUTHENTICATION_REQUIRED`, `API_KEY_LIMIT_REACHED`, `INVALID_EXPIRES_AT`, `API_KEY_NOT_FOUND`.", + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": ["code", "message", "status"], + "type": "object" + } + }, + "required": ["error"], + "type": "object" + }, + "UserApiKeyPairResponse": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "expiresAt": { + "format": "date-time", + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "publicKey": { + "properties": { + "id": { + "type": "string" + }, + "key": { + "description": "The full key value. For the secret key this is returned only in this response.", + "type": "string" + }, + "keyPrefix": { + "description": "Constant 8-character prefix, e.g. `pk_live_` or `sk_test_`.", + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "enum": ["public", "secret"], + "type": "string" + } + }, + "required": ["id", "key", "keyPrefix", "name", "type"], + "type": "object" + }, + "secretKey": { + "properties": { + "id": { + "type": "string" + }, + "key": { + "description": "The full key value. For the secret key this is returned only in this response.", + "type": "string" + }, + "keyPrefix": { + "description": "Constant 8-character prefix, e.g. `pk_live_` or `sk_test_`.", + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "enum": ["public", "secret"], + "type": "string" + } + }, + "required": ["id", "key", "keyPrefix", "name", "type"], + "type": "object" + } + }, + "required": ["createdAt", "expiresAt", "isActive", "publicKey", "secretKey"], + "type": "object" + }, "ValidatePixKeyResponse": { "properties": { "valid": { @@ -1264,6 +1405,406 @@ }, "openapi": "3.1.0", "paths": { + "/v1/api-keys": { + "get": { + "deprecated": false, + "description": "Lists the authenticated user's active API keys. Public key values are included; secret key values are never returned.\n\n**Auth:** requires `Authorization: Bearer ` obtained from `POST /v1/auth/verify-otp`. Partner `sk_*`/`pk_*` keys are not accepted.", + "operationId": "listUserApiKeys", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListUserApiKeysResponse" + } + } + }, + "description": "Active keys, newest first.", + "headers": {} + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "Missing or invalid Bearer token.", + "headers": {} + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "Internal server error.", + "headers": {} + } + }, + "security": [], + "summary": "List the user's API keys", + "tags": ["Authentication"] + }, + "post": { + "deprecated": false, + "description": "Creates a public + secret API key pair bound to the authenticated user. The secret key value is returned only in this response; Vortex stores a hash and cannot show it again.\n\nKeys expire after one year by default; `expiresAt` may extend this to at most two years from now. A user may hold at most 10 active keys (a pair counts as two).\n\nSandbox mints `pk_test_*`/`sk_test_*`; production mints `pk_live_*`/`sk_live_*`.\n\n**Auth:** requires `Authorization: Bearer ` obtained from `POST /v1/auth/verify-otp`. Partner `sk_*`/`pk_*` keys are not accepted.", + "operationId": "createUserApiKey", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "example": { + "expiresAt": "2027-07-06T00:00:00.000Z", + "name": "my-backend" + }, + "schema": { + "properties": { + "expiresAt": { + "description": "Optional ISO-8601 expiry, at most 2 years from now. Defaults to 1 year.", + "format": "date-time", + "type": "string" + }, + "name": { + "description": "Optional label; defaults to \"API Key\".", + "type": "string" + } + }, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyPairResponse" + } + } + }, + "description": "Key pair created. Persist `secretKey.key` immediately; it cannot be retrieved again.", + "headers": {} + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "`INVALID_EXPIRES_AT`: expiresAt is not a valid ISO-8601 date or is more than 2 years from now.", + "headers": {} + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "Missing or invalid Bearer token.", + "headers": {} + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "`API_KEY_LIMIT_REACHED`: the user already holds the maximum of 10 active keys.", + "headers": {} + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "Internal server error.", + "headers": {} + } + }, + "security": [], + "summary": "Create a user-linked API key pair", + "tags": ["Authentication"] + } + }, + "/v1/api-keys/{keyId}": { + "delete": { + "deprecated": false, + "description": "Revokes (soft-deletes) an API key owned by the authenticated user. Pass `pairedKeyId` in the body to revoke both halves of a pair together; the two keys must be of opposite types (one public, one secret) and share the same base name. The legacy `publicKeyId` body field is accepted as an alias.\n\n**Auth:** requires `Authorization: Bearer ` obtained from `POST /v1/auth/verify-otp`. Partner `sk_*`/`pk_*` keys are not accepted.", + "operationId": "revokeUserApiKey", + "parameters": [ + { + "description": "ID of the key to revoke.", + "in": "path", + "name": "keyId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "example": { + "pairedKeyId": "00000000-0000-0000-0000-000000000000" + }, + "schema": { + "properties": { + "pairedKeyId": { + "description": "Optional ID of the other half of the pair, to revoke both keys together.", + "type": "string" + } + }, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "204": { + "description": "Key(s) revoked.", + "headers": {} + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "`INVALID_KEY_PAIR` or `KEY_PAIR_MISMATCH`: the two keys are not opposite halves of the same pair.", + "headers": {} + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "Missing or invalid Bearer token.", + "headers": {} + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "`API_KEY_NOT_FOUND` or `PAIRED_PUBLIC_KEY_NOT_FOUND`: key missing, already revoked, or not owned by the user.", + "headers": {} + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserApiKeyErrorResponse" + } + } + }, + "description": "Internal server error.", + "headers": {} + } + }, + "security": [], + "summary": "Revoke an API key", + "tags": ["Authentication"] + } + }, + "/v1/auth/request-otp": { + "post": { + "deprecated": false, + "description": "Sends a 6-digit one-time password to the given email address. Use it with `POST /v1/auth/verify-otp` to obtain a user session.\n\n**Auth:** none.", + "operationId": "requestOTP", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "example": { + "email": "user@example.com" + }, + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "locale": { + "description": "Optional locale for the email, e.g. `pt-BR`.", + "type": "string" + } + }, + "required": ["email"], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + }, + "required": ["message", "success"], + "type": "object" + } + } + }, + "description": "OTP sent.", + "headers": {} + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Email missing or locale not a string.", + "headers": {} + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Failed to send the OTP email.", + "headers": {} + } + }, + "security": [], + "summary": "Request an email OTP", + "tags": ["Authentication"] + } + }, + "/v1/auth/verify-otp": { + "post": { + "deprecated": false, + "description": "Verifies the emailed one-time password and returns a user session. First-time sign-ins create the user profile; `user_id` identifies the profile that API keys minted with this session are linked to.\n\n**Auth:** none.", + "operationId": "verifyOTP", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "example": { + "email": "user@example.com", + "token": "123456" + }, + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "token": { + "description": "The 6-digit code from the email.", + "type": "string" + } + }, + "required": ["email", "token"], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "user_id": { + "format": "uuid", + "type": "string" + } + }, + "required": ["access_token", "refresh_token", "success", "user_id"], + "type": "object" + } + } + }, + "description": "Session created.", + "headers": {} + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Missing fields, or the OTP is invalid or expired.", + "headers": {} + } + }, + "security": [], + "summary": "Verify an email OTP", + "tags": ["Authentication"] + } + }, "/v1/brla/createSubaccount": { "post": { "deprecated": false, @@ -3085,7 +3626,7 @@ "post": { "deprecated": false, "description": "Submits presigned transactions and additional data to an existing ramp process before starting it. \nThis endpoint can be called many times, and data can be incrementally added to the ramp. \n\nNote: For both pre-signed transactions and the generic `additionalData` object, existing properties will be overriden by new values.\n\n### Required data for ramps.\nThe signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object.\nFor offramps, the `additionalData` field must contain the confirmation hash corresponding to the inital transaction in which the user sends the funds. \nIf the originating chain is `Assethub`, then `assetHubToPendulumHash` must be provided. \nIf the originating chain is any `EVM` chain, then `squidRouterApproveHash` and `squidRouterSwapHash` must be provided. \n\nFor onramps, no additional data is required after registering the ramp.", - "operationId": "startRamp", + "operationId": "updateRamp", "parameters": [], "requestBody": { "content": { @@ -3657,7 +4198,7 @@ } }, { - "description": "Filter supported payment methods Allowed values: `ars`, `brl`, `eur` ", + "description": "Filter supported payment methods by fiat currency. Allowed values: `EUR`, `ARS`, `BRL`, `USD`, `MXN`, `COP`.", "example": "", "in": "query", "name": "fiat", @@ -3870,7 +4411,16 @@ } }, "security": [], - "servers": [], + "servers": [ + { + "description": "Production", + "url": "https://api.vortexfinance.co" + }, + { + "description": "Sandbox", + "url": "https://api-sandbox.vortexfinance.co" + } + ], "tags": [ { "description": "Create and retrieve cross-border payment quotes.", @@ -3888,6 +4438,10 @@ "description": "User account, KYC, and BRLA subaccount operations.", "name": "Account Management" }, + { + "description": "Email OTP sign-in and user-linked API key provisioning.", + "name": "Authentication" + }, { "description": "Register and remove webhook endpoints for ramp events.", "name": "Webhooks" diff --git a/docs/api/pages/01-overview.md b/docs/api/pages/01-overview.md index ab2f6d9cb..5609f8070 100644 --- a/docs/api/pages/01-overview.md +++ b/docs/api/pages/01-overview.md @@ -6,7 +6,7 @@ These docs are written for partner developers integrating Vortex into a backend, ## Supported Corridors -The current SDK release is centered on **BRL/PIX** for both buy (onramp) and sell (offramp) flows. EUR onramp endpoints exist on the API surface but the SDK throws `"Euro onramp handler not implemented yet"`; SEPA buy flows are not production-ready today. Other fiat currencies are exposed through reference data endpoints and are added incrementally. +The current SDK release supports BRL/PIX flows, EUR/SEPA flows, and bank-transfer corridors for USD (ACH), MXN (SPEI), COP (ACH), and ARS (CBU) through Vortex's local payment partners, where enabled by country and route configuration. The EUR and bank-transfer corridors support buys and sells on EVM networks; AssetHub is not available for them. Other fiat currencies are exposed through reference data endpoints and are added incrementally. For crypto, Vortex supports USDC and USDT across the listed EVM networks plus USDC on AssetHub. Stablecoin pegs and routes are subject to liquidity on the Nabla AMM and the wider Pendulum/Hydration corridor. diff --git a/docs/api/pages/02-quick-start-with-the-sdk.md b/docs/api/pages/02-quick-start-with-the-sdk.md index 9b9f796e9..23578ab16 100644 --- a/docs/api/pages/02-quick-start-with-the-sdk.md +++ b/docs/api/pages/02-quick-start-with-the-sdk.md @@ -1,6 +1,6 @@ # Quick Start With The SDK -This page walks through a complete BRL ramp end-to-end using `@vortexfi/sdk`. The SDK is for trusted Node.js environments only. +This page walks through complete BRL and bank-transfer-corridor (USD, MXN, COP, ARS) ramps end-to-end using `@vortexfi/sdk`. The SDK is for trusted Node.js environments only. ## Install @@ -76,38 +76,122 @@ const quote = await sdk.createQuote({ outputCurrency: FiatToken.BRL }); -const { rampProcess, userTransactions } = await sdk.registerRamp(quote, { - userAddress: "0xUSER...", - pixKey: "user@example.com", - taxId: "12345678900" +const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { + pixDestination: "user@example.com", + receiverTaxId: "12345678900", + taxId: "12345678900", + walletAddress: "0xUSER..." }); -// userTransactions contains the transactions the SDK could not sign on the +// unsignedTransactions contains the transactions the SDK could not sign on the // user's behalf. Route them to the user's wallet (see below). ``` ### Signing The User Transaction With Wagmi -The user-owned transactions are EVM typed-data payloads. With wagmi: +The user-owned transactions are EVM typed-data payloads or EVM transactions. Keep wallet prompts in your application and let the SDK handle classification and submission: ```js import { signTypedData, sendTransaction } from "@wagmi/core"; -for (const tx of userTransactions) { - if (tx.type === "evm-typed-data") { - const signature = await signTypedData(wagmiConfig, tx.payload); - await sdk.submitUserSignature(rampProcess.id, tx.id, signature); - } else if (tx.type === "evm-transaction") { - const hash = await sendTransaction(wagmiConfig, tx.payload); - await sdk.submitUserTxHash(rampProcess.id, tx.id, hash); - } -} +await sdk.submitUserTransactions(rampProcess.id, unsignedTransactions, { + signTypedData: payload => signTypedData(wagmiConfig, payload), + sendTransaction: tx => sendTransaction(wagmiConfig, tx) +}); const started = await sdk.startRamp(rampProcess.id); ``` Validate every field before signing: `chainId`, `verifyingContract`, `value`, `to`, and `data` must match what your application requested. Never sign payloads blindly. +## USD, MXN, COP And ARS Ramps + +USD, MXN, COP, and ARS settle through Vortex's local payment partners over the user's domestic banking rail. Pass the rail identifier as `from` (buy) or `to` (sell): + +| Fiat currency | Rail identifier | Payment rail | +|---|---|---| +| `USD` | `"ach"` | ACH bank transfer | +| `MXN` | `"spei"` | SPEI transfer | +| `COP` | `"ach"` | Colombian bank transfer | +| `ARS` | `"cbu"` | CBU bank transfer | + +All four corridors support buys and sells on EVM networks; AssetHub is not available for these corridors. The examples below use MXN — for the other currencies, substitute the fiat token and the rail identifier from the table. See [Fiat Corridors](https://api-docs.vortexfinance.co/fiat-corridors) for onboarding, fiat accounts, and limits. + +### Onramp (Buy) + +The user pays fiat off-chain; crypto is delivered to `destinationAddress` on the quoted network. + +```js +import { EPaymentMethod } from "@vortexfi/sdk"; + +const quote = await sdk.createQuote({ + rampType: RampDirection.BUY, + from: EPaymentMethod.SPEI, + to: Networks.Polygon, + network: Networks.Polygon, + inputAmount: "201", // 201 MXN + inputCurrency: FiatToken.MXN, + outputCurrency: EvmToken.USDC +}); + +const { rampProcess } = await sdk.registerRamp(quote, { + destinationAddress: "0x1234567890123456789012345678901234567890", + walletAddress: "0x1234567890123456789012345678901234567890" + // fiatAccountId is optional for onramp +}); + +const started = await sdk.startRamp(rampProcess.id); + +// Show the user how to pay via SPEI +console.log(started.achPaymentData); +``` + +No user-signed on-chain transactions are required for onramp. The SDK signs ephemeral transactions during `registerRamp`. + +Quotes can be requested without any key (anonymous rate discovery). Registering the ramp requires the user to be onboarded first: authenticate the SDK with that user's own **user-linked** `secretKey` (the `sk_*` key created by that user), and the same user must have completed KYC for the corridor's country. The key and the KYC record belong to the same account, so registration resolves to the user's verified payment profile automatically. A `publicKey`-only registration, or a partner-scoped `sk_*` with no user, is rejected. + +Partner `sk_*` keys cannot drive this KYC, and the SDK cannot mint keys or run KYC — onboard the user through the Vortex app or Widget first, then use their `sk_*` key (shown only once, at creation; see [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys) for minting it programmatically). This applies to buys and sells in all four corridors. + +### Offramp (Sell) + +Selling crypto for fiat in these corridors requires the user to sign one or more on-chain transactions with their own wallet. The SDK returns those transactions in `unsignedTransactions`. + +```js +const quote = await sdk.createQuote({ + rampType: RampDirection.SELL, + from: Networks.Polygon, + to: EPaymentMethod.SPEI, + network: Networks.Polygon, + inputAmount: "10", // 10 USDC + inputCurrency: EvmToken.USDC, + outputCurrency: FiatToken.MXN +}); + +const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { + fiatAccountId: "00000000-0000-0000-0000-000000000000", // user's fiat account + walletAddress: "0xUSER..." +}); +``` + +`fiatAccountId` is opaque to the SDK. Create or look up the user's fiat account out-of-band and pass the ID here. It is required for offramp and optional for onramp. + +### Signing Offramp User Transactions + +Use the SDK helper to classify, sign, broadcast, and submit each entry in `unsignedTransactions`: + +```js +import { signTypedData, sendTransaction } from "@wagmi/core"; + +await sdk.submitUserTransactions(rampProcess.id, unsignedTransactions, { + signTypedData: payload => signTypedData(wagmiConfig, payload), + sendTransaction: tx => sendTransaction(wagmiConfig, tx) +}); + +await sdk.startRamp(rampProcess.id); +``` + +For wallets that call `eth_signTypedData_v4` directly, set `includeDomainType: true` on `submitUserTransactions` or pass `{ includeDomainType: true }` to `getTypedDataToSign` when using the lower-level helpers. + ## Tracking Status Poll for user-facing screens, use webhooks for back-office reconciliation: diff --git a/docs/api/pages/03-authentication-and-partner-keys.md b/docs/api/pages/03-authentication-and-partner-keys.md index 1b0d5c83c..fb8e5fafa 100644 --- a/docs/api/pages/03-authentication-and-partner-keys.md +++ b/docs/api/pages/03-authentication-and-partner-keys.md @@ -1,6 +1,16 @@ -# Authentication And Partner Keys +# Authentication And API Keys -Vortex authenticates partners with two key types and also accepts Supabase Bearer tokens for first-party user flows. +Vortex authenticates API clients with public/secret key pairs, and accepts Supabase Bearer session tokens for first-party user flows and key management. + +## Which Credential Do I Need? + +| Task | Credential | +|---|---| +| Quote attribution and partner pricing | `pk_*` public key (optional on quotes) | +| BRL and EUR ramps from a partner backend | Partner-scoped or user-linked `sk_*` secret key | +| USD, MXN, COP, ARS ramps | **User-linked** `sk_*` secret key | +| Webhook management | Partner secret key | +| Minting and managing user API keys | `Authorization: Bearer` session token | ## Public Keys @@ -10,19 +20,137 @@ Public keys do not authenticate sensitive partner operations. An invalid or expi ## Secret Keys -Secret keys use the `sk_live_*` or `sk_test_*` prefix. They authenticate partner operations through the `X-API-Key` header. +Secret keys use the `sk_live_*` or `sk_test_*` prefix. They authenticate operations through the `X-API-Key` header. + +Secret keys come in two scopes: + +- **Partner-scoped** keys are issued to a partner organization. They are sufficient for BRL and EUR ramps, where the user is identified by request fields such as the tax ID, and they are required for webhook management. +- **User-linked** keys are minted by a user's own Vortex account (see the *Provisioning User-Linked Keys* section below). Requests authenticated with them act as that user, so KYC completed by the same account applies automatically. The USD, MXN, COP, and ARS corridors require a user-linked key at ramp registration; see [Fiat Corridors](https://api-docs.vortexfinance.co/fiat-corridors). A user-linked key works in every corridor, so integrations can use it uniformly. Secret keys must be treated as server-side credentials. Do not expose them in browser bundles, mobile app binaries, URLs, screenshots, analytics tools, logs, or support tickets. When a request includes `partnerId`, the API may require the secret key to authenticate the matching partner. If the authenticated partner does not match the requested partner, Vortex rejects the request. -Ramp endpoints, including register, update, start, status, history, and error logs, require authentication through either a partner secret key or a Supabase Bearer token. +Ramp endpoints, including register, update, start, status, history, and error logs, require authentication through either a secret key or a Supabase Bearer token. Webhook endpoints require a partner secret key and do not accept Supabase Bearer tokens. ## Supabase Bearer Tokens -BRLA account-management endpoints are first-party, user-oriented flows. Partner `sk_*` and `pk_*` keys do not authenticate a BRL KYC flow. Partners that need BRL ramps should onboard users through the Vortex application or hosted widget, or design the integration so the user has completed the required onboarding before the partner backend starts a ramp. +Bearer tokens represent a signed-in Vortex user. They are used for first-party account-management flows (such as the BRLA KYC endpoints) and for minting and managing user API keys. Partner `sk_*` and `pk_*` keys do not authenticate these flows. + +Partners that need BRL ramps should onboard users through the Vortex application or hosted widget, or design the integration so the user has completed the required onboarding before the partner backend starts a ramp. + +## Provisioning User-Linked Keys + +A user-linked key pair can be provisioned programmatically, without contacting Vortex support: sign the user in with an email one-time password (OTP), then mint the key pair with the resulting session token. + +### 1. Request An Email OTP + +```http +POST /v1/auth/request-otp +Content-Type: application/json +``` + +```json +{ + "email": "user@example.com" +} +``` + +Vortex emails a 6-digit code to the address. An optional `locale` string localizes the email. The response is `{ "success": true, "message": "OTP sent to email" }`. + +### 2. Verify The OTP + +```http +POST /v1/auth/verify-otp +Content-Type: application/json +``` + +```json +{ + "email": "user@example.com", + "token": "123456" +} +``` + +```json +{ + "success": true, + "access_token": "eyJ...", + "refresh_token": "...", + "user_id": "00000000-0000-0000-0000-000000000000" +} +``` + +An invalid or expired code returns `400`. Verification creates the user profile on first sign-in; `user_id` identifies the profile the keys will be linked to. If the user has already completed KYC in the Vortex app or Widget under the same email, this is the same profile — no extra linking step is needed. + +`POST /v1/auth/refresh` with `{ "refresh_token": "..." }` returns a fresh token pair when the access token expires. + +### 3. Create The Key Pair + +```http +POST /v1/api-keys +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "name": "my-backend", + "expiresAt": "2027-07-06T00:00:00.000Z" +} +``` + +Both body fields are optional. Response (`201`): + +```json +{ + "createdAt": "2026-07-06T12:00:00.000Z", + "expiresAt": "2027-07-06T00:00:00.000Z", + "isActive": true, + "publicKey": { + "id": "...", + "key": "pk_live_...", + "keyPrefix": "pk_live_", + "name": "my-backend (Public)", + "type": "public" + }, + "secretKey": { + "id": "...", + "key": "sk_live_...", + "keyPrefix": "sk_live_", + "name": "my-backend (Secret)", + "type": "secret" + } +} +``` + +- **The secret key value is returned only in this response.** Vortex stores a hash; it cannot be retrieved again. Persist it to your secret manager immediately. +- Keys expire after one year by default; `expiresAt` may extend this to at most two years from creation. +- A user may hold at most 10 active keys (a pair counts as two). Exceeding the cap returns `409 API_KEY_LIMIT_REACHED`; revoke unused keys first. +- Sandbox mints `pk_test_*` / `sk_test_*`; production mints `pk_live_*` / `sk_live_*`. + +The `/v1/api-keys` endpoints accept only `Authorization: Bearer` session tokens — an `X-API-Key` secret key cannot mint or revoke keys. + +### 4. Use The Keys + +Configure the SDK (or send `X-API-Key` directly) with the minted pair: + +```js +const sdk = new VortexSdk({ + apiBaseUrl: "https://api.vortexfinance.co", + publicKey: "pk_live_...", + secretKey: "sk_live_..." +}); +``` + +The bearer token is only needed for key management; day-to-day quoting and ramping authenticate with the secret key. See [Quick Start With The SDK](https://api-docs.vortexfinance.co/quick-start-with-the-sdk). + +### Managing Keys + +- `GET /v1/api-keys` — lists the user's active keys (public key values are included; secret values are never returned). +- `DELETE /v1/api-keys/{keyId}` — revokes a key; returns `204`. Pass `{ "pairedKeyId": "..." }` in the body to revoke both halves of a pair together. ## Webhook Signing Key @@ -30,6 +158,6 @@ BRLA account-management endpoints are first-party, user-oriented flows. Partner ## Recommended Handling -Store secret keys in a secret manager or encrypted environment configuration. Rotate keys if they are exposed, no longer needed, or tied to a retired integration. Use test keys in sandbox and live keys only in production. +Store secret keys in a secret manager or encrypted environment configuration. Rotate keys if they are exposed, no longer needed, or tied to a retired integration — for user-linked keys, revoke and re-mint through the endpoints above. Use test keys in sandbox and live keys only in production. --- diff --git a/docs/api/pages/06-quotes-and-pricing.md b/docs/api/pages/06-quotes-and-pricing.md index 5bbac3f3b..42584554b 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -30,7 +30,7 @@ Content-Type: application/json ``` - `rampType` is `"BUY"` (onramp, fiat → crypto) or `"SELL"` (offramp, crypto → fiat). -- `from` / `to` are either a fiat rail (`"pix"`, `"sepa"`) or a network identifier (`"polygon"`, `"base"`, `"ethereum"`, `"arbitrum"`, `"bsc"`, `"avalanche"`, `"assethub"`, `"stellar"`, `"moonbeam"`). +- `from` / `to` are either a fiat rail (`"pix"`, `"sepa"`, `"ach"`, `"spei"`, `"cbu"`) or a network identifier (`"polygon"`, `"base"`, `"ethereum"`, `"arbitrum"`, `"bsc"`, `"avalanche"`, `"assethub"`, `"stellar"`, `"moonbeam"`). `"ach"` serves USD and COP, `"spei"` serves MXN, and `"cbu"` serves ARS; see [Fiat Corridors](https://api-docs.vortexfinance.co/fiat-corridors). - `inputAmount` is a decimal string in the smallest commonly used unit of `inputCurrency` (e.g. `"150"` for 150 BRL, `"100"` for 100 USDC). Do not pass raw chain base units. - `apiKey` (optional) is the partner public key `pk_live_*` / `pk_test_*`. Required for partner attribution and discount eligibility. @@ -102,6 +102,6 @@ Quotes are immutable and short-lived. If the user takes too long to confirm, or ## Partner Pricing -Pass the partner public key as `apiKey` in the quote body to apply partner pricing and attribution. When a ramp later specifies a `partnerId`, the request must be authenticated with the matching partner secret key in `X-API-Key`. See [Authentication And Partner Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). +Pass the partner public key as `apiKey` in the quote body to apply partner pricing and attribution. When a ramp later specifies a `partnerId`, the request must be authenticated with the matching partner secret key in `X-API-Key`. See [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). --- diff --git a/docs/api/pages/09-brl-kyc-notes.md b/docs/api/pages/09-brl-kyc-notes.md deleted file mode 100644 index 8d81e0bba..000000000 --- a/docs/api/pages/09-brl-kyc-notes.md +++ /dev/null @@ -1,13 +0,0 @@ -# BRL / KYC Notes - -BRL routes require user onboarding with Vortex's local payment partner before ramping. The user's Brazilian tax ID, either CPF for individuals or CNPJ for businesses, is used as the primary identifier. - -Level 1 onboarding collects basic identity information and enables lower-limit BRL flows. Level 2 adds document and liveness verification and may be required for higher limits or stricter compliance rules. - -The SDK ramp flow assumes that the user is eligible for the selected corridor. If the user has not completed the required onboarding, the ramp may fail or require additional account-management steps. - -Partner integrations cannot drive BRLA KYC directly with only `sk_*` or `pk_*` keys. BRLA endpoints are first-party, user-oriented flows and rely on a Vortex-authenticated user context rather than partner key authentication. - -KYC endpoints are documented for first-party flows and account-management integrations. They should not be treated as the primary SDK ramp flow. When possible, use the Vortex application or hosted widget to complete onboarding before ramp execution. - ---- diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md new file mode 100644 index 000000000..9b50270f4 --- /dev/null +++ b/docs/api/pages/09-fiat-corridors.md @@ -0,0 +1,52 @@ +# Fiat Corridors + +This page collects what each fiat corridor requires before a ramp can be registered: the payment rail, the identity and KYC prerequisites, and the payout details. For the quote request shape see [Quotes And Pricing](https://api-docs.vortexfinance.co/quotes-and-pricing); for runnable examples see [Quick Start With The SDK](https://api-docs.vortexfinance.co/quick-start-with-the-sdk). + +## BRL (PIX) + +BRL routes settle over PIX and require user onboarding with Vortex's local payment partner before ramping. The user's Brazilian tax ID — CPF for individuals, CNPJ for businesses — is the primary identifier, so ramp registration may be authenticated with a partner-scoped `sk_*` key: the `taxId` in the request identifies the user. + +Level 1 onboarding collects basic identity information and enables lower-limit BRL flows. Level 2 adds document and liveness verification and may be required for higher limits or stricter compliance rules. The user must have completed KYC under the same `taxId` used in the ramp; otherwise the ramp may fail or require additional account-management steps. + +Partner integrations cannot drive BRL KYC with only `sk_*` or `pk_*` keys. The BRLA endpoints are first-party, user-oriented flows that rely on a Vortex-authenticated user context. When possible, use the Vortex application or hosted widget to complete onboarding before ramp execution. Business users can be sent straight into verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). + +## USD, MXN, COP, ARS (Bank Transfers) + +These corridors settle through Vortex's local payment partners over domestic banking rails. In quote requests, the rail identifier takes the place of a network in `from` (buy) or `to` (sell): + +| Fiat currency | Rail identifier | Payment rail | +|---|---|---| +| `USD` | `"ach"` | ACH bank transfer | +| `MXN` | `"spei"` | SPEI transfer | +| `COP` | `"ach"` | Colombian bank transfer | +| `ARS` | `"cbu"` | CBU bank transfer | + +All four corridors support buys and sells on EVM networks; AssetHub is not available for these corridors. + +### Onboarding And KYC + +Each corridor requires the user to complete KYC for the corridor's country before a ramp can be registered. Onboard the user through the Vortex app or hosted Widget; the identity documents collected differ per country (for example INE, resident card, or passport in Mexico; cédula in Colombia; DNI in Argentina). Business users can be sent straight into verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). + +Unlike BRL, ramp registration must be authenticated with the user's own **user-linked** `sk_*` key — the ramp resolves the user's KYC and payment profile from the authenticated account, not from request fields. Your integration can mint that key programmatically after an email OTP sign-in; see [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). Partner-scoped keys cannot register ramps in these corridors and cannot drive KYC on a user's behalf. Quotes remain available anonymously for rate discovery; eligibility is enforced at registration time, not quote time. + +### Fiat Accounts + +Sells pay out to a saved bank account referenced by `fiatAccountId` in the register call. It is required for sells and optional for buys. The account is created during onboarding in the Vortex app or Widget; the ID is opaque to the SDK and the API client. + +### Payment Instructions On Buys + +After `POST /v1/ramp/start`, the response's `achPaymentData` contains the bank transfer instructions the user must pay (beneficiary, account, and reference details for the corridor's rail). Display them to the user verbatim; the ramp continues automatically once the fiat deposit is confirmed. + +### Limits + +Per-currency minimum and maximum amounts are enforced at quote time and refreshed periodically from the payment partner. A quote outside the limits fails with a descriptive error; prompt the user to adjust the amount. + +## EUR (SEPA) + +EUR routes settle over SEPA using the `"sepa"` rail identifier and support both buys and sells. EUR onramps deliver to EVM networks; AssetHub is not available as a destination. + +On a buy, register the ramp with `destinationAddress`, `email`, and `ipAddress`. The SEPA transfer instructions are returned in the ramp's `ibanPaymentData` — IBAN, receiver name, and payment reference. Display them to the user, and start the ramp once the user has completed the SEPA transfer. No user-signed on-chain transactions are required for buys. + +EUR onboarding is individual KYC only and requires a connected wallet, so it is completed through the Vortex application or hosted widget; there is no quote-less KYB deep link for Europe. + +--- diff --git a/docs/api/pages/12-ai-agent-integration.md b/docs/api/pages/12-ai-agent-integration.md index c7c583d31..1866dd93a 100644 --- a/docs/api/pages/12-ai-agent-integration.md +++ b/docs/api/pages/12-ai-agent-integration.md @@ -169,8 +169,7 @@ These are not optional. The SDK handles them for you; a custom client must imple - It does not poll ramp status; you must poll or use webhooks. - It does not encrypt ephemeral backups at rest. - It does not delete ephemeral backups after success. -- It does not drive BRLA KYC; the user must be onboarded through the Vortex app or Widget before a BRL ramp. -- It does not support EUR onramp today (throws `"Euro onramp handler not implemented yet"`). +- It does not drive KYC for any corridor; the user must be onboarded through the Vortex app or Widget before ramping. Mirror those gaps deliberately. If your integration adds behavior the SDK lacks (encryption at rest, backup rotation, idempotency keys, retries), document it for your operators. diff --git a/docs/api/pages/13-kyb-deep-link.md b/docs/api/pages/13-kyb-deep-link.md index 9b042e05c..373c0fd99 100644 --- a/docs/api/pages/13-kyb-deep-link.md +++ b/docs/api/pages/13-kyb-deep-link.md @@ -11,7 +11,7 @@ It is a variant of the [hosted widget](https://api-docs.vortexfinance.co/widget- ``` - **Brazil** routes to Avenia KYB. The user enters the company name and CNPJ together on the company form, then completes Avenia's hosted company and representative verification. -- **Mexico / Colombia / USA** route to the Alfredpay business KYB form (the business customer type is preselected). +- **Mexico / Colombia / USA** route to the local payment partner's business KYB form (the business customer type is preselected). - Europe is intentionally excluded — it is individual KYC only and requires a connected wallet, so it cannot complete a quote-less KYB deep link. After verification the user lands on a **KYB Completed** screen. *Continue* returns them to the standard quote form with the session still authenticated and the deep-link parameters stripped from the URL. @@ -62,6 +62,6 @@ window.open( ## Underlying KYB Onboarding -For Brazil, the deep link drives `POST /v1/brla/createSubaccount` **without** a `quoteId` — the subaccount is created from the company name and CNPJ collected on the form, and the optional quote association is simply omitted. See [BRL / KYC Notes](https://api-docs.vortexfinance.co/brl-kyc-notes) for how BRLA onboarding relates to the ramp flow. +For Brazil, the deep link drives `POST /v1/brla/createSubaccount` **without** a `quoteId` — the subaccount is created from the company name and CNPJ collected on the form, and the optional quote association is simply omitted. See [Fiat Corridors](https://api-docs.vortexfinance.co/fiat-corridors) for how BRLA onboarding relates to the ramp flow. --- diff --git a/docs/architecture/user-gated-ramp-registration.md b/docs/architecture/user-gated-ramp-registration.md new file mode 100644 index 000000000..ab3ffa9b1 --- /dev/null +++ b/docs/architecture/user-gated-ramp-registration.md @@ -0,0 +1,95 @@ +# ADR: User-Gated Ramp Registration (Anonymous Quotes, Authenticated Ramps) + +Last updated: 2026-07-02 + +Status: Accepted + +Related: [`api-key-authentication-complete.md`](./api-key-authentication-complete.md), +[`supabase-auth.md`](./supabase-auth.md), +security spec [`01-auth/api-keys.md`](../security-spec/01-auth/api-keys.md), +[`03-ramp-engine/quote-lifecycle.md`](../security-spec/03-ramp-engine/quote-lifecycle.md) + +## Context + +Every Vortex corridor settles through a regulated fiat provider — Avenia/BRLA (BRL), +Mykobo (EUR), or Alfredpay (USD/MXN/COP/ARS). Each provider requires a real, KYC-completed +customer to mint or pay out. Historically the API accepted provider identity (e.g. +`additionalData.taxId`, or `customerId: req.userId || "unknown"`) directly from the request +body and allowed ramp registration with only a partner API key. That let a caller: + +- Register a ramp on top of an arbitrary `taxId` / customer they did not own. +- Create upstream provider resources with a placeholder (`"unknown"`) customer identity. +- Drive provider-backed flows with no link to a verified profile. + +At the same time, we want unauthenticated clients to be able to fetch a **quote** so they +can preview rates before signing up with Vortex. + +## Decision + +Split the trust boundary between quoting and ramping: + +1. **Quotes stay anonymous-eligible, for every corridor.** `POST /v1/quotes` and `/quotes/best` + accept anonymous callers (and partner keys with or without a user binding). For Alfredpay + corridors the `customerId` sent on *quote* requests lives in the tracking-only `metadata` + object — Alfredpay validates the top-level `customerId` only on order creation. Anonymous + or non-KYC'd callers get the sentinel `"anonymous"` in quote metadata + (`resolveAlfredpayQuoteCustomerId`); KYC-completed users get their real customer id. This + keeps the web-app funnel working (quote before login, KYC after quote confirm). + +2. **Ramp registration requires an effective user, for every corridor.** `RampService.registerRamp` + derives an **effective user** (`req.userId` from Supabase, else `api_keys.user_id` from a linked + secret key) and rejects when none is present: + - `400 Invalid quote` when no effective user can be resolved. + - `403` when a linked user tries to register a quote owned by a different user. + - An **anonymous quote may be claimed** by any authenticated caller: it carries no owner, so + claiming is not an escalation, and provider identity is still derived from the claimer's + own KYC records. This is the normal web-app flow (anonymous quote → login → register). + +3. **Provider identity is derived server-side, never trusted from the body.** The sender `taxId` + (Avenia) and `alfredPayId` (Alfredpay) are resolved from the effective user's KYC records + (`resolveAveniaAccountForRamp`, `resolveAlfredpayCustomerId`). A client-supplied `taxId` is + accepted only when it matches the derived value (enforced on both the BRL onramp and offramp + paths); mismatches return `400`. (The PIX `receiverTaxId`, which may legitimately differ from + the sender, stays client-supplied and is validated downstream against the PIX key owner.) + +## Identity model + +A secret API key now has two independent, nullable axes: + +| `partner_name` | `user_id` | Meaning | +|---|---|---| +| set | null | Partner key, no bound user. **Can quote, cannot register** (no effective user). | +| set | set | Partner key bound to one profile. Quotes with partner pricing; registers ramps for that one user. | +| null | set | User-scoped key (self-serve `/v1/api-keys`). Registers ramps for that user; **no** partner pricing (defaults to the `vortex` fee rows). | +| null | null | Unusable; rejected as invalid. | + +A single key binds to **at most one** profile. A partner serving many end users therefore either +(a) has each end user authenticate via Supabase, (b) has each end user mint their own user-scoped +key via the self-serve endpoint, or (c) provisions one partner-bound key per user through the admin +endpoint. There is intentionally no "one partner key acts for any user" path. + +## Consequences + +- **Breaking change for unlinked partner-key integrations.** A partner key with `user_id = NULL` + can no longer register ramps. Existing production keys must be bound to a profile (admin + `POST /v1/admin/partners/:partnerName/api-keys` accepts an optional `userId`) or callers must + switch to per-user authentication. This requires partner communication ahead of deploy. +- **Anonymous rate discovery is preserved for all corridors**, which keeps the pre-signup funnel + working (including the web app's anonymous quote form for Alfredpay currencies). +- **Provider fraud surface shrinks**: no arbitrary `taxId`, no placeholder customer on order + creation, no claiming another user's quote/subaccount. (The `"anonymous"` sentinel appears + only in tracking metadata on quote requests, never on orders.) +- **Self-serve key creation is capped** (`MAX_ACTIVE_KEYS_PER_USER` in + `userApiKeys.controller.ts`): secret-key validation bcrypt-compares against every active key + sharing the constant 8-char prefix, so unbounded key creation would degrade auth latency + system-wide. +- `ON DELETE SET NULL` on `api_keys.user_id` is deliberate: deleting a profile must not silently + revoke a partner's operational keys; the binding is soft state. + +## Alternatives considered + +- **Per-corridor gating** (only provider-backed corridors require a user). Rejected: every active + corridor is provider-backed, so a global check in `registerRamp` is simpler and removes the risk + of a future corridor forgetting the guard. If a non-provider corridor is ever added, revisit. +- **Trusting body-supplied provider identity with an ownership check.** Rejected: deriving from the + authenticated profile is strictly safer and removes an entire class of IDOR. diff --git a/docs/security-spec/01-auth/api-keys.md b/docs/security-spec/01-auth/api-keys.md index a41b85ad6..86797ae4d 100644 --- a/docs/security-spec/01-auth/api-keys.md +++ b/docs/security-spec/01-auth/api-keys.md @@ -14,6 +14,22 @@ Three middleware components: - **`validatePublicKey()`** — Validates public keys from query params or body. For tracking only, not authentication. - **`enforcePartnerAuth()`** — When `partnerId` is in the request body, enforces that the request is authenticated and the partner matches. +### Optional user binding (`api_keys.user_id`) + +A nullable `user_id` column on `api_keys` (FK to `profiles.id`, `ON DELETE SET NULL`) lets an admin bind a secret key to a specific profile. The binding is propagated to the request as `req.apiKeyUserId` (set by `setApiKeyUserId` in the auth middleware). Controllers and services derive the **effective user id** with `getEffectiveUserId(req)`, which prefers `req.userId` (Supabase) and falls back to `req.apiKeyUserId`. Public keys never populate `req.apiKeyUserId`. Use of the effective user is required for Alfredpay quote creation, ramp registration on Avenia/BRL or Alfredpay corridors, Alfredpay fiat-account management, and the BRLA pre-flight endpoints. + +### User-scoped keys (`api_keys.partner_name` nullable) + +`api_keys.partner_name` is nullable (migration `035-make-api-key-partner-name-nullable`). A key with `partner_name = NULL` is a **user-scoped key**: it authenticates purely as the linked `user_id` and never resolves to an `AuthenticatedPartner`. The self-serve endpoints under `POST/GET/DELETE /v1/api-keys` (guarded by `requireAuth`) let any Supabase-authenticated user mint a public + secret pair bound to their own `req.userId` with `partner_name = NULL`. The admin endpoints under `/v1/admin/partners/:partnerName/api-keys` continue to require an active `Partner` row matching `partner_name`. + +### Self-serve API key endpoints + +`/v1/api-keys` is guarded by `requireAuth` (Supabase Bearer). The flow for a headless integrator is: +1. `POST /v1/auth/request-otp` with `{ email }` — Supabase sends a one-time code. +2. `POST /v1/auth/verify-otp` with `{ email, token }` — returns `{ access_token, refresh_token, user_id }`. +3. `POST /v1/api-keys` with `Authorization: Bearer ` — creates a `pk_*`/`sk_*` pair bound to `user_id`, with `partner_name = NULL`. The secret key is returned once. +4. Use `X-API-Key: ` on quote/ramp endpoints. The request authenticates as the linked user (no partner attribution, no partner discount — defaults to the `vortex` partner fee configuration). + ## Security Invariants 1. **Secret keys MUST be transmitted via the `X-API-Key` header only** — Never in query parameters, request body, or URL path. The middleware reads exclusively from `req.headers["x-api-key"]`. @@ -22,10 +38,17 @@ Three middleware components: 4. **Key format validation MUST precede database lookup** — Both `isValidSecretKeyFormat()` and `isValidApiKeyFormat()` use regex to reject malformed keys before any DB query, preventing injection and unnecessary load. 5. **Partner matching MUST compare names, not IDs** — When `validatePartnerMatch` is enabled, the middleware compares partner names (since one API key can work for multiple partner records with the same name). Both UUID and string name formats for `partnerId` are supported. 6. **Expired keys MUST be rejected** — Both public and secret key validation check `expiresAt` against the current time. Expired keys are treated as invalid. -7. **Key lookup MUST use prefix indexing** — Secret key validation first narrows by `keyPrefix` (first 8 chars), then iterates with bcrypt comparison. This bounds the cost of bcrypt comparisons. +7. **Key lookup narrows by prefix, but the prefix does NOT bound the scan** — Secret key validation narrows by `keyPrefix` (first 8 chars) and then iterates with bcrypt comparison. Since the first 8 chars are the constant `sk_live_`/`sk_test_` for every secret key, the scan covers **all** active secret keys in that environment; auth latency grows linearly with the number of active secret keys. This is why self-serve key creation is capped (see invariant 17). A future format change embedding a random key-id in the key string would restore O(1) lookup. 8. **`enforcePartnerAuth` MUST block unauthenticated requests when `partnerId` is present** — If a request includes `partnerId` but has no authenticated partner, it MUST be rejected with 403. 9. **`lastUsedAt` updates MUST be fire-and-forget** — The `keyRecord.update({ lastUsedAt })` call is intentionally not awaited, with errors caught and logged. This MUST NOT block or fail the auth flow. 10. **Key generation MUST use cryptographically secure randomness** — `crypto.randomBytes(32)` is the source. Base64 encoding with character stripping is used to produce the 32-char alphanumeric portion. +11. **Secret keys MAY carry a nullable `api_keys.user_id` to identify a delegated user context** — The binding is consumed by the `apiKeyUserId` request field and is the only path for partner secret keys to provide a non-Supabase user identity. Public keys never carry or surface a user binding. +12. **`ON DELETE SET NULL` for `api_keys.user_id` is intentional** — Deleting a profile must not silently revoke partner keys; partner keys are operational assets and binding loss is a soft-state change. +13. **All ramp registration MUST be rejected when no effective user is present** — `POST /v1/ramp/register` requires a Supabase user or linked secret key and `RampService.registerRamp` rejects missing effective users with `400 Invalid quote: this route requires an API key linked to a user or Supabase user authentication.`. Quote creation remains anonymous-eligible for every corridor: Alfredpay quote engines use `resolveAlfredpayQuoteCustomerId`, which puts the sentinel `"anonymous"` (or the caller's real customer id when KYC-completed) into the tracking-only quote `metadata`; the KYC-gated `resolveAlfredpayCustomerId` runs at registration before any Alfredpay *order* is created. An anonymous quote (`quote.userId = NULL`) may be claimed at registration by any authenticated caller — it carries no owner, and provider identity is derived from the claimer's own KYC records. Unlinked secret keys are not a valid identity for registration. +14. **`api_keys.partner_name` is nullable; a NULL `partner_name` marks a user-scoped key** — `validateSecretApiKey` skips the `Partner` lookup entirely for keys with `partner_name = NULL` and `user_id` set, returning `{ partner: null, apiKeyUserId }`. The middleware leaves `req.authenticatedPartner` unset, so the request authenticates purely as the linked user. A secret key with neither `partner_name` nor `user_id` is unusable and rejected as invalid. +15. **User-scoped keys MUST interpolate no partner pricing** — When `req.authenticatedPartner` is unset, `resolveQuotePartner` finds no partner (`source: "none"`), and `calculatePartnerAndVortexFees` falls through to the default `vortex` Partner fee rows. User-scoped keys never receive partner-specific discounts. +16. **`POST/GET/DELETE /v1/api-keys` MUST require a Supabase user (`requireAuth`)** — The endpoints bind the created keys to `req.userId`; partner keys (with `partner_name`) remain admin-only under `/v1/admin/partners/:partnerName/api-keys` (`adminAuth`). +17. **Self-serve key creation MUST be capped per user** — `createUserApiKey` rejects with `409 API_KEY_LIMIT_REACHED` when the user already has `MAX_ACTIVE_KEYS_PER_USER` (10) active keys, and rejects `expiresAt` values more than 2 years out. Because of the linear bcrypt scan (invariant 7), an unbounded self-serve endpoint would let any user degrade auth latency for the whole system. The key pair is created in a single DB transaction so a failure cannot leave an orphaned half. ## Threat Vectors & Mitigations @@ -37,10 +60,14 @@ Three middleware components: | **Partner impersonation** | Attacker uses one partner's API key with another partner's `partnerId` | `enforcePartnerAuth` compares authenticated partner name against requested partner name; rejects mismatches with 403 | | **Stale/revoked key usage** | Partner's key is deactivated but still being used | `isActive` flag checked on every validation; expired keys rejected by `expiresAt` check | | **Key hash enumeration** | Attacker with DB read access tries to use key hashes | bcrypt hashes are one-way; raw keys cannot be recovered from hashes | +| **Unlinked key creating provider resources anonymously** | Partner uses a generic (unbound) sk\_ key to mint provider-side resources, then registers with a linked secret key or Supabase session to claim them | Quotes are estimates and carry no provider resources beyond a tracking-metadata customer id (`"anonymous"` sentinel for non-KYC'd callers). All provider *orders* are created at registration, where `POST /v1/ramp/register` requires credentials, `RampService.registerRamp` rejects missing effective users, and provider identity is derived from the registering user's own KYC records — so claiming an anonymous quote yields no access to anyone else's resources. | +| **Self-serve key flooding (auth DoS)** | A user mints thousands of key pairs via `POST /v1/api-keys`, inflating the bcrypt scan for every secret-key auth | Per-user cap of `MAX_ACTIVE_KEYS_PER_USER` (10) active keys enforced with `409`; revocation frees slots. | +| **One linked key operating on another user's quote/ramp** | Partner with a valid linked key targets a different linked user's provider-bound quote | `assertQuoteOwnership`/`assertRampOwnership` enforce `quote.userId === req.apiKeyUserId` when a linked key is in scope. The `RampService.registerRamp` cross-user check rejects the same scenario at registration time with `403`. | +| **Anonymous subaccount creation DoS** | Unauthenticated caller hits `POST /v1/brla/createSubaccount` to spawn stranded Avenia subaccounts | The route now requires `requirePartnerOrUserAuth()`; controllers require an effective user id before calling the Avenia API. | ## Audit Checklist -- [x] All endpoints requiring partner auth use `apiKeyAuth({ required: true })` or `enforcePartnerAuth()` — **PASS: `enforcePartnerAuth()` is now active on `POST /v1/ramp/quotes` and `POST /v1/ramp/quotes/best`. Ramp endpoints additionally enforce sk_ OR Supabase via `requirePartnerOrUserAuth()` (history) or `optionalPartnerOrUserAuth()` (register/update/start/status/errors). Anonymous access is permitted on the optional set only when the underlying quote/ramp is fully anonymous (no partner, no user owner).** +- [x] All endpoints requiring partner auth use `apiKeyAuth({ required: true })` or `enforcePartnerAuth()` — **PASS: `enforcePartnerAuth()` is active on `POST /v1/ramp/quotes` and `POST /v1/ramp/quotes/best`. `POST /v1/ramp/register` now requires sk_ OR Supabase via `requirePartnerOrUserAuth()`. Update/start/status/errors still use `optionalPartnerOrUserAuth()` so legacy fully-anonymous ramps can be inspected or advanced only when ownership checks allow it.** - [x] Secret key validation (`validateSecretApiKey`) always uses bcrypt comparison, never plaintext comparison — **PASS** - [x] Public key validation (`validatePublicApiKey`) stores keys in plaintext (by design for lookup) but never returns auth credentials — **PASS** - [x] `getKeyType()` correctly identifies `pk_` as public, `sk_` as secret, and anything else as `null` — **PASS** @@ -52,3 +79,16 @@ Three middleware components: - [x] Partner name comparison is case-sensitive and exact (no normalization that could be exploited) — **PASS** - [x] No endpoint accepts secret keys from query parameters or request body — **PASS** - [x] Error responses from key validation use distinct error codes (`API_KEY_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `PARTNER_MISMATCH`) without revealing which step failed for valid key formats — **PARTIAL: `PARTNER_MISMATCH` leaks authenticated partner name in response details** +- [x] `api_keys.user_id` migration (`034-add-user-id-to-api-keys`) added with `ON DELETE SET NULL`, `idx_api_keys_user_id`, and `idx_api_keys_active_user_lookup`. — **PASS** +- [x] `api_keys.partner_name` is nullable (migration `035-make-api-key-partner-name-nullable`); user-scoped keys have `partner_name = NULL` and authenticate purely as `user_id`. — **PASS** +- [x] `validateSecretApiKey` returns a `ValidatedSecretKey` wrapper `{ apiKeyId, apiKeyUserId, partner: AuthenticatedPartner | null }`; `partner` is null for user-scoped keys. — **PASS** +- [x] `validatePublicApiKey` returns a `ValidatedPublicKey` wrapper `{ partnerName: string | null }`; `partnerName` is null for user-scoped public keys. — **PASS** +- [x] `apiKeyAuth` and `dualAuth` populate `req.apiKeyUserId` from the validated secret key; `req.authenticatedPartner` is left unset for user-scoped keys. Public keys do not populate `req.apiKeyUserId`. — **PASS** +- [x] `getEffectiveUserId` returns `req.userId ?? req.apiKeyUserId`. — **PASS** +- [x] User-scoped keys interpolate no partner pricing (`resolveQuotePartner` returns `source: "none"`, fee engine falls through to default `vortex` Partner rows). — **PASS** +- [x] `POST/GET/DELETE /v1/api-keys` require `requireAuth` (Supabase Bearer); bind created keys to `req.userId` with `partner_name = NULL`. Admin partner-key endpoints still require `adminAuth`. — **PASS** +- [x] Quote creation is anonymous-eligible for every corridor; Alfredpay quote engines use the sentinel `"anonymous"` in tracking-only quote metadata for non-KYC'd callers (`resolveAlfredpayQuoteCustomerId`), and provider orders always resolve via the strict `resolveAlfredpayCustomerId`. — **PASS** +- [x] `POST /v1/ramp/register` and `RampService.registerRamp` reject ramp registration without an effective user with `401` at the route or `400 Invalid quote` at the service boundary. — **PASS** +- [x] `RampService.registerRamp` rejects registration of a quote owned by a *different* user with `403`; anonymous quotes (no owner) may be claimed by any authenticated caller, with provider identity derived from the claimer's own KYC records. — **PASS** +- [x] `createUserApiKey` enforces `MAX_ACTIVE_KEYS_PER_USER` (10) with `409`, caps `expiresAt` at 2 years, and creates the key pair in a single transaction. — **PASS** +- [x] `assertQuoteOwnership` and `assertRampOwnership` reject linked-key callers who try to operate on a different linked user's quote/ramp. — **PASS** diff --git a/docs/security-spec/01-auth/supabase-otp.md b/docs/security-spec/01-auth/supabase-otp.md index 5a6a03752..62888870a 100644 --- a/docs/security-spec/01-auth/supabase-otp.md +++ b/docs/security-spec/01-auth/supabase-otp.md @@ -10,6 +10,7 @@ The flow: 3. Supabase verifies OTP and issues a JWT access token 4. Frontend includes JWT in `Authorization: Bearer ` header on API requests 5. API middleware (`supabaseAuth.ts`) verifies the JWT via `SupabaseAuthService.verifyToken()` and attaches `userId` to the request +6. Access tokens are short-lived. The frontend refreshes them via `POST /v1/auth/refresh` (`SupabaseAuthService.refreshToken()` → Supabase `refreshSession`), scheduled just before expiry and also triggered on a `401` (single-flight refresh + one retry). The frontend never calls Supabase `refreshSession` directly with the anon key. The endpoint returns `401` **only** when the refresh token is confirmed invalid/revoked; transient upstream failures (Supabase unreachable / 5xx) return `503` so the frontend keeps the session and retries. Two middleware variants exist: - **`requireAuth`** — Returns 401 if token is missing or invalid. Used on protected endpoints. @@ -25,6 +26,7 @@ Two middleware variants exist: 6. **Auth errors MUST NOT leak token content** — Error responses must use generic messages ("Invalid or expired token"). Tokens must be truncated in logs (as implemented: first 15 + last 4 chars). 7. **Supabase configuration MUST be present** — If `SUPABASE_URL`, `SUPABASE_ANON_KEY`, or `SUPABASE_SERVICE_KEY` are empty/missing, the auth system is non-functional. The service should fail to start rather than silently accept all tokens. 8. **JWT expiry MUST be enforced** — Supabase tokens have a configurable expiry. The verification MUST reject expired tokens, not just validate the signature. +9. **Session teardown MUST happen only on confirmed-invalid refresh** — The frontend clears the stored session (and forces re-login) only when `/v1/auth/refresh` returns `401` (refresh token invalid/revoked). Transient failures (network errors, 5xx, timeouts) MUST NOT clear the session; they are retried while the existing session is preserved. The backend enforces this contract: `/v1/auth/refresh` returns `401` only for a definite invalid-token error from Supabase and returns `503` for transient/transport failures (and any unexpected error), so a Supabase outage cannot masquerade as an invalid token and log users out. ## Threat Vectors & Mitigations @@ -48,4 +50,6 @@ Two middleware variants exist: - [x] `optionalAuth` truncates tokens in warning logs (first 15 + last 4 characters) — **PASS** - [x] `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_KEY` are validated at startup — empty strings are treated as missing — **FAIL: All default to "" with no startup validation (F-019)** - [x] Token expiry is enforced by the verification call (not just signature validity) — **PASS** +- [x] Frontend refresh goes through `/v1/auth/refresh` (not the anon-key client) and clears the session only on a `401`, retrying transient failures — **PASS** +- [x] `/v1/auth/refresh` returns `401` only for a confirmed-invalid refresh token and `503` for transient/unexpected failures (so an outage cannot force logout) — **PASS** - [x] No endpoint that should require auth is using `optionalAuth` as a shortcut — **PARTIAL: BRLA KYC endpoints use optionalAuth but create user-specific resources** diff --git a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md index 20e982479..a88e54d61 100644 --- a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md +++ b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md @@ -10,9 +10,9 @@ The cleanup process runs as a background worker (`cleanup.worker.ts`) on a 5-min Ephemeral accounts may be created on: - **Stellar** — For Spacewalk bridge operations and direct Stellar payments -- **Pendulum** — For Nabla swaps (Substrate-side), Spacewalk redeems, XCM transfers. **Not created** for BRL (BRLA) or EUR (Mykobo) Base-EVM corridors: `getRequiresPendulumEphemeralAddress` returns `false` when the input or output token is BRL or EURC, and registration skips Pendulum ephemeral creation + funding for those corridors. Only the Pendulum-routed corridors (Stellar/ARS, AssetHub, Hydration) still allocate a Pendulum ephemeral. -- **Moonbeam** — For legacy EVM operations (historical Monerium EUR → Moonbeam path is removed; still used for ARS-Stellar off-ramp's Moonbeam→Pendulum XCM hop, Alfredpay permit acquisition, and SquidRouter swaps), XCM to/from Pendulum -- **Polygon** — (Legacy) For Monerium EURe operations; no active corridor uses Polygon anymore but the post-process handler remains for safety on any still-in-flight legacy ramps +- **Pendulum** — For Nabla swaps (Substrate-side) and XCM transfers. **Not created** for BRL (BRLA), EUR (Mykobo), or Alfredpay Polygon/EVM corridors: `getRequiresPendulumEphemeralAddress` returns `false` when the route does not need Substrate-side movement, and registration skips Pendulum ephemeral creation + funding for those corridors. Only Pendulum-routed AssetHub/Hydration corridors allocate a Pendulum ephemeral. +- **Moonbeam** — For legacy EVM operations and XCM to/from Pendulum. Historical Monerium EUR → Moonbeam and ARS-via-Stellar paths are removed; current Alfredpay and Base EVM corridors should use their route source network rather than assuming Moonbeam. +- **Polygon** — Active for Alfredpay onramps/offramps. Alfredpay mints and receives `ALFREDPAY_EVM_TOKEN` on Polygon, and the Polygon post-process handler sweeps residual ERC-20 tokens after completion. - **AssetHub** — For XCM transfers to/from Pendulum and Hydration - **Hydration** — For Hydration DEX swaps and XCM transfers - **Base** — Hub for all BRL **and EUR** on/off-ramp flows. Hosts BRLA mint/burn (via Avenia), Mykobo EUR settlement (EURC on Base), Nabla-on-EVM swap (USDC↔BRLA, USDC↔EURC), and EVM fee distribution via Multicall3. @@ -24,7 +24,7 @@ Post-process handlers registered in `apps/api/src/api/services/phases/post-proce - **StellarPostProcessHandler** — Submits the `stellarCleanup` XDR to merge the Stellar ephemeral account back to the funding account. - **PendulumPostProcessHandler** — Submits the `pendulumCleanup` extrinsic to sweep Pendulum ephemeral tokens. - **MoonbeamPostProcessHandler** — Waits 3 hours for SquidRouter refunds to land, then submits `moonbeamCleanup` to sweep Moonbeam ephemeral tokens. -- **PolygonPostProcessHandler** — (Legacy Monerium support) On Monerium-onramp BUY ramps with a `polygonCleanup` presigned tx, broadcasts the user's pre-signed `approve` and then runs `transferFrom(ephemeral, fundingAccount, balance)` from the funding key to sweep residual ERC-20 tokens. Skipped when ephemeral balance is zero. **No active corridor produces new Polygon ephemerals; this handler exists for in-flight legacy ramps and can be retired once Monerium ramps are fully drained.** +- **PolygonPostProcessHandler** — On Polygon-routed ramps with a `polygonCleanup` presigned tx, broadcasts the user's pre-signed `approve` and then runs `transferFrom(ephemeral, fundingAccount, balance)` from the funding key to sweep residual ERC-20 tokens. Skipped when ephemeral balance is zero. This is active for Alfredpay corridors and also protects any still-in-flight legacy Polygon ramps. - **HydrationPostProcessHandler** — On BUY ramps with a `hydrationCleanup` presigned extrinsic, submits the cleanup extrinsic. - **AssetHubPostProcessHandler** — Registered but inert. `shouldProcess` returns `false` unconditionally; `process` returns `[true, null]`. No on-chain action is performed. Effectively a placeholder for future AssetHub cleanup. diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index 509e7b047..40daa7a63 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -14,6 +14,8 @@ Fee calculation determines how much the user pays for a ramp operation and how t This means the fees shown to the user (from the database system) may differ from the fees actually applied (from the token config system). This is documented in `docs/architecture/current-fee-derivation.md` as a partially-implemented refactor. +**FIXED (2026-07-05)**: on the direct fiat → own-stablecoin corridors (BRL→BRLA and EUR→EURC on Base), the displayed network fee previously priced a USDC→output-token Squid bridge that the direct route never executes, charges, or distributes — inflating `networkFeeFiat`/`totalFeeFiat` for a leg that does not exist. `OnRampAveniaToEvmFeeEngine` now reports zero network fee for these corridors (same `isFiatToOwnStablecoinBaseDirect` predicate as the squidrouter passthrough engines); output amounts were never affected. Pinned by the quote pricing goldens (`apps/api/src/tests/quote-pricing.golden.test.ts`). + ### Fee Application Points - **On-ramp:** Fees are deducted from the input amount BEFORE the swap. `inputAmountAfterFees = inputAmount - fees`. diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 323a1d9a1..a7a2ce535 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -67,10 +67,13 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 8. **Dynamic pricing state MUST NOT be externally modifiable** — The `partnerDiscountState` Map is in-memory and module-private. No API endpoint should expose or allow modification of discount state. 9. **Exchange rates MUST be sourced from authoritative on-chain data** — Swap rates should come from the actual DEX (Nabla) or routing protocol (Squid), not from stale caches or third-party price feeds that could be manipulated. 10. **Subsidy MUST only be applied when `targetDiscount > 0`** — If a partner has no target discount configured, the subsidy amount is always `0`, regardless of the shortfall. -11. **Quote output precision MUST match the final settlement token** — For EVM onramps whose final output comes from Squid, the stored `quote.outputAmount` must retain the destination token's precision, not a fixed source-token precision. This includes BRL/EURC Base→EVM routes and routed Alfredpay USD/MXN/COP Polygon→EVM routes. Direct same-chain same-token passthrough keeps the minted/source token's precision. +11. **Quote output precision MUST match the final settlement token** — For EVM onramps whose final output comes from Squid, the stored `quote.outputAmount` must retain the destination token's precision, not a fixed source-token precision. This includes BRL/EURC Base→EVM routes and routed Alfredpay USD/MXN/COP/ARS Polygon→EVM routes. Direct same-chain same-token passthrough keeps the minted/source token's precision. 12. **Quote creation MUST honor active maintenance windows server-side** — `POST /v1/quotes` and `POST /v1/quotes/best` must reject during active maintenance before quote calculation/persistence, including enough downtime metadata for direct API clients to retry after the window. 13. **Quote ownership MUST stay separate from pricing attribution** — Profile-assigned quotes MUST remain user-owned (`user_id = req.userId`, `partner_id = NULL`) while storing the applied partner pricing row in `pricing_partner_id`. 14. **Displayed discount MUST be a snapshot, not a live recomputation** — Public `QuoteResponse` discount fields MUST come from quote metadata captured at creation time. `GET /v1/quotes/:id` and ramp status responses MUST NOT recompute discount display amounts from live FX rates because quote economics are immutable after creation. +15. **Quote creation is anonymous-eligible for every corridor; provider *orders* MUST NOT carry placeholder identity** — Alfredpay quote requests carry the customer id only in the tracking-only `metadata` object; `resolveAlfredpayQuoteCustomerId` fills in the caller's real customer id when a KYC-completed customer resolves, and the `"anonymous"` sentinel otherwise. Alfredpay *order* creation (at register/start) always goes through the strict, KYC-gated `resolveAlfredpayCustomerId` and never uses a placeholder. BRL/Avenia quote creation remains anonymous-eligible because Avenia quotes do not require a user-bound provider identity. **Register/start remains blocked for all corridors** without an effective user via route-level auth and the universal `RampService.registerRamp` check. +16. **Provider-backed ramp registration MUST derive the sender's provider identity from the effective user, not from request body** — BRL/Avenia tax ID, Alfredpay `alfredPayId`, and the Mykobo (EUR) `email` are resolved server-side from the effective user: `api_keys.user_id -> tax_ids.user_id` (`resolveAveniaAccountForRamp`), `api_keys.user_id -> alfredpay_customers.user_id` (`resolveAlfredpayCustomerId`), and `api_keys.user_id -> profiles.email` (`resolveMykoboCustomerForUser`) respectively. The corresponding client-supplied field (`additionalData.taxId` / `additionalData.email`) is accepted only for backward compatibility and MUST match the derived sender value or the request is rejected with `400`. Each resolver also enforces the corridor's KYC-completion status (Avenia `Accepted`, Alfredpay `Success`, Mykobo `APPROVED`). The `receiverTaxId` (where it differs from the sender — e.g. third-party PIX recipient) is supplied by the client and is allowed to differ from the derived sender tax ID; it is validated downstream against the PIX key owner by `validateBrlaOfframpRequest` / `validateMaskedNumber`. The `RampService.registerRamp` quote/user consistency check ensures the caller cannot register a provider-backed quote using a different user context. +17. **User-owned quotes MUST only be registered by their owner; anonymous quotes MAY be claimed** — `RampService.registerRamp` rejects with `403` when `quote.userId` is set and differs from the authenticated caller. An anonymous quote (`quote.userId = null`) carries no owner and MAY be claimed by any authenticated caller — this is the normal web-app funnel (quote before login, register after). Claiming grants no access to anyone else's resources because provider identity is always derived from the claimer's own KYC records (inv. 16), never from the quote or the request body. ## Threat Vectors & Mitigations @@ -83,7 +86,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` | **Dynamic pricing farming** | Attacker rapidly requests quotes without consuming them to push `difference` toward `maxDynamicDifference`, then consumes at the best possible rate | Each quote request within the timeout window does NOT change the difference — only quotes **after** the timeout increase it. So the attacker would need to wait `discountStateTimeoutMinutes` between each step increase. With default `deltaD = 0.00003` and a 10-minute timeout, farming is slow. However, the `maxDynamicDifference` cap is the hard limit. | | **⚠️ In-memory state loss** | Server restart resets all partner discount states to `difference = 0`. Partners lose their accumulated rate adjustments. | **NO MITIGATION.** State is in-memory only. After restart, all partners start fresh. This could cause abrupt rate changes if a partner had a significant accumulated difference. | | **Subsidization abuse** | Attacker creates quotes during high volatility, forcing the platform to cover large subsidization amounts | Quote-time discount subsidy is capped by `maxSubsidy` per partner; EVM runtime top-ups are separately bounded by the pre/post-swap cap fractions; dynamic pricing adjusts rates over time; `maxDynamicDifference` bounds the maximum rate improvement | -| **Unauthorized quote consumption** | Attacker binds someone else's quote to their own ramp | Quotes carrying an owner (`partner_id` or `user_id`) are bound to that owner; ownership is verified at ramp registration via `assertQuoteOwnership`. `pricing_partner_id` is not an ownership credential. Fully-anonymous quotes (no `partner_id` and no `user_id`) are intentionally consumable by any caller — they cannot leak privileged data because they were created without a principal in the first place. | +| **Unauthorized quote consumption** | Attacker binds someone else's quote to their own ramp | Quotes carrying an owner (`partner_id` or `user_id`) are bound to that owner; ownership is verified at ramp registration via `assertQuoteOwnership` and the `registerRamp` cross-user check (inv. 17). `pricing_partner_id` is not an ownership credential. Anonymous quotes carry no owner and are claimable, but claiming one only consumes a rate estimate — provider identity and funds routing derive from the claimer's own KYC records, so nothing belonging to another user can be reached. | | **Pricing partner treated as owner** | A profile-assigned user receives partner pricing, then tries to access partner-owned quotes or ramps. | Profile assignments populate `pricing_partner_id` only; `partner_id` stays `NULL`, so ownership guards continue to authorize through the Supabase `user_id` path. | | **Negative `minDynamicDifference`** | If `minDynamicDifference` is set to a large negative value in the partner DB record, consuming quotes could push the rate below the base `targetDiscount`, potentially making the effective discount negative (user receives less than the oracle rate) | DB constraint: `minDynamicDifference` defaults to `0`. However, there is no DB-level CHECK constraint preventing negative values. If set manually, the clamping logic would allow `difference` to go negative. | | **Concurrent quote and consumption** | Two simultaneous requests — one quoting, one consuming — for the same partner could read stale `difference` values from the in-memory Map | JavaScript's single-threaded event loop prevents true concurrency for synchronous Map operations. However, the `async` functions in `compute()` could interleave if there are `await` points between reading and writing the Map. In practice, the read and write of `partnerDiscountState` in `getAdjustedDifference` are synchronous, so this is safe within a single process. | @@ -104,9 +107,9 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` - [x] Exchange rates used in quote calculation come from live on-chain sources (Nabla, Squid), not stale caches. **PASS** — verified: rates fetched from Nabla DEX and SquidRouter API at quote time. - [x] Quote response does not include internal implementation details (e.g., the `adjustedDifference` or `adjustedTargetDiscount` values). **PASS** — verified: response includes only user-facing fields (amounts, fees, expiry). - [x] Quote amounts (input, output, fees) are immutable once stored — no UPDATE endpoint modifies them. **PASS** — no quote mutation endpoints exist. -- [x] EVM onramp output precision follows destination token decimals where the quote output comes from Squid. **PASS** — BRL/EURC Base→EVM and routed Alfredpay USD/MXN/COP Polygon→EVM finalization preserve destination token precision before downstream raw transfer construction. Direct same-chain same-token passthrough remains at minted/source-token precision. -- [PARTIAL] Authentication is enforced on quote creation (verify which auth mechanisms protect `POST /v1/ramp/quotes`). **PARTIAL** — quote creation is accessible via API key auth or Supabase auth; the endpoint is optional-auth by design (public quotes allowed for some partners). -- [PARTIAL] Quote ownership is verified at ramp registration — the user/partner creating the ramp must match the quote creator. **PARTIAL** — no strict user-to-quote binding; mitigated by UUID unpredictability and 10-minute expiry. +- [x] EVM onramp output precision follows destination token decimals where the quote output comes from Squid. **PASS** — BRL/EURC Base→EVM and routed Alfredpay USD/MXN/COP/ARS Polygon→EVM finalization preserve destination token precision before downstream raw transfer construction. Direct same-chain same-token passthrough remains at minted/source-token precision. +- [PARTIAL] Authentication is enforced on quote creation (verify which auth mechanisms protect `POST /v1/ramp/quotes`). **PARTIAL** — quote creation is optional-auth by design for corridors that support public estimates (for example BRL). Alfredpay quote creation is user-gated inside the quote engines because upstream Alfredpay requires a real customer context; register/start require an effective user for all corridors. +- [x] Quote ownership is verified at ramp registration — the user/partner creating the ramp must match the quote creator. **PASS** — `assertQuoteOwnership` scopes by partner/user, and `RampService.registerRamp` additionally (a) rejects a linked user registering a quote owned by a different user with `403`, (b) rejects an authenticated caller claiming an anonymous (`quote.userId == null`) quote with `403`, and (c) requires an effective user for every corridor (inv. 15–17). UUID unpredictability and the 10-minute expiry remain as defense in depth. - [x] Profile-assigned quote pricing persists `pricing_partner_id` without granting partner ownership. **PASS** — profile-assigned quotes store `user_id`, leave `partner_id` `NULL`, and authorize through the user ownership path. - [x] Subsidy is only calculated when `targetDiscount > 0` — partners with no discount get `0` subsidy regardless of shortfall. **PASS** — verified in `calculateSubsidyAmount()`. - [x] `calculateSubsidyAmount` correctly caps at `maxSubsidy × expectedOutput` — verify the multiplication is the right semantic (fraction of expected, not absolute). **PASS** — confirmed: `maxSubsidy` is a fraction (0-1) multiplied by `expectedOutput`. diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index 6f43a4489..8e142d155 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -101,11 +101,10 @@ graph TD Backup -->|No| DestTransfer BackupSquid --> DestTransfer - %% --- Alfredpay branch: squidRouterSwap handler short-circuits to destinationTransfer --- - %% (squid-router-phase-handler.ts:72 detects isAlfredpayOnramp and skips squidRouterPay + finalSettlementSubsidy) + %% --- Alfredpay branch: direct-token Polygon case short-circuits the swap, then final settlement subsidy runs before destinationTransfer --- AfMint --> AfFund[fundEphemeral] AfFund --> AfSquidSwap[squidRouterSwap] - AfSquidSwap -.short-circuit.-> DestTransfer + AfSquidSwap -.direct-token short-circuit.-> FinalSubsidy %% --- Terminal --- DestTransfer --> Complete([complete]) @@ -115,7 +114,7 @@ graph TD > - **EUR onramp funds the ephemeral.** `mykoboOnrampDeposit` transitions to `fundEphemeral` (`mykobo-onramp-deposit-handler.ts`), which then transitions to `subsidizePreSwap` (`fund-ephemeral-handler.ts` `BUY && inputCurrency === EURC` branch). This matches BRL onramp behavior and ensures the Base ephemeral has ETH gas for `nablaApprove`/`nablaSwap`/squid txs. > - **EUR/BRL onramps skip Pendulum funding.** `getRequiresPendulumEphemeralAddress` returns `false` for EURC and BRL inputs, so the registration flow never creates or funds a Pendulum ephemeral for these corridors. All movement is Base-EVM only. See `ephemeral-accounts.md`. > - **SquidRouter RPC selection is sourced from `bridgeMeta.fromNetwork`, not the input currency.** `squid-router-phase-handler.ts` computes the source network from `bridgeMeta.fromNetwork` (set at registration time by the route builder) and passes it to `getClient(network)` for both approve and swap calls. The earlier heuristic that selected the RPC from `inputCurrency` was removed because EUR-onramp presigned transactions both carry `network: Networks.Base` (`mykobo-to-evm.ts`), which would have triggered a wrong-chain signer error on cross-chain destinations (e.g., `invalid chain id for signer: have 8453 want 137` for EUR → Polygon USDT). -> - **Alfredpay onramp short-circuits.** `squid-router-phase-handler.ts:72` detects `isAlfredpayOnramp` and transitions directly to `destinationTransfer`, skipping `squidRouterPay` and `finalSettlementSubsidy`. +> - **Alfredpay direct-token onramp short-circuits only the Squid swap.** When Alfredpay mints `ALFREDPAY_EVM_TOKEN` on Polygon and the requested output is that same token on Polygon, `squid-router-phase-handler.ts` transitions to `finalSettlementSubsidy`, then `destinationTransfer`. Other Alfredpay EVM outputs still use the routed Squid path. > - The Pendulum-side on-ramp swap chain (`subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `distributeFees` → `pendulumToAssethubXcm` / `pendulumToHydrationXcm` → `hydrationSwap` → `hydrationToAssethubXcm`) was used by the legacy Monerium-EUR-via-Pendulum corridor and by `avenia-to-assethub` BRL→AssetHub. Both corridors are **inactive**: Monerium was replaced by Mykobo-on-Base, and BRL↔AssetHub is temporarily disabled at quote eligibility. The Substrate-branch on-ramp handlers remain registered but are not reached by any active route. #### Off-Ramp Phase Flow @@ -189,6 +188,7 @@ graph TD 9. **On same-chain destinations, `destinationTransfer` MUST be the first executable nonce after the broadcast SquidRouter txs — no nonce gap** — When the SquidRouter source chain equals the destination chain (e.g., EUR → Base EURC, BRL → Base USDC, Alfredpay Polygon-internal), the ephemeral shares ONE nonce sequence for `squidRouterApprove` → `squidRouterSwap` → `destinationTransfer`. The runtime broadcasts these in order, so `destinationTransfer` MUST carry the nonce immediately following `squidRouterSwap`. Two failure modes must both be avoided: (a) **collision** — reusing a nonce already consumed by an earlier tx; (b) **gap** — signing `destinationTransfer` with a nonce *above* the next live nonce, which the chain rejects as "nonce too high" so the tx never mines and user funds strand on the ephemeral (root cause of the EUR→Base 0-delivery incident: `destinationTransfer` was signed after the post-`complete` cleanup approvals — and, originally, after handler-less backup re-swap txs — leaving a 1–2 nonce gap). The route builders therefore place `destinationTransfer` directly after `squidRouterSwap`, then append the post-`complete` cleanup approvals, and OMIT the backup re-swap txs on the same-chain branch (those have no registered handler — F-054 — and on a shared sequence would only widen the gap). Enforced in `mykobo-to-evm.ts`, `alfredpay-to-evm.ts`, and `avenia-to-evm-base.ts`. 10. **`destinationTransfer` MUST fail fast on a detectable nonce gap rather than retry-and-strand** — `destination-transfer-handler.ts` reads the ephemeral's live nonce (`getTransactionCount`, `blockTag: "pending"`) and compares it to the presigned `destinationTransfer` nonce before broadcasting. If the presigned nonce is *ahead* of the live nonce the transfer can never mine, so the handler raises an `UnrecoverablePhaseError` for manual review instead of looping until the retry budget silently exhausts (which previously stranded funds with no terminal signal). Using `"pending"` rather than `"latest"` ensures the check accounts for mempool transactions — a prior ephemeral tx still in the mempool would otherwise lower the observed nonce and falsely flag a gap. The live-nonce read is best-effort: an RPC failure or a malformed presigned transaction logs a warning and falls through to the normal balance-poll path, so a transient RPC outage or an unparseable tx cannot wedge the happy path. 11. **Base EVM `nablaSwap` MUST be dry-run before broadcast** — `nabla-swap-handler.ts` must simulate the exact presigned raw swap transaction with `eth_call` from the Base ephemeral account before calling `sendRawTransaction`. The dry-run MUST use the decoded transaction's actual recipient, calldata, value, gas, and fee fields, and should use `blockTag: "pending"` so liquidity-sensitive failures are checked against the freshest available Base state. If the simulation reverts (for example `SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO`), the handler MUST fail before submitting the transaction so the ephemeral does not spend gas on a predictably reverting swap. +12. **Presigned payout transfers MUST be balance-checked before broadcast** — `alfredpayOfframpTransfer`, `mykoboPayoutOnBase`, and `brlaPayoutOnBase` broadcast presigned ephemeral transfers for a fixed amount decided at registration time. A revert consumes the presigned nonce, after which the payload can never be re-broadcast and funds strand on the ephemeral. `ensurePresignedTransferFunded` (`handlers/helpers.ts`) recovers the sender from the signed raw transaction, decodes token and amount from the `transfer` calldata (or native value), and polls (5s interval, 3-minute timeout) until the sender's balance covers the transfer; on timeout the handler raises a **recoverable** error so the phase retries instead of burning the nonce. The guard is best-effort on decode: an unparseable or non-`transfer` payload logs a warning and falls through to the broadcast (same posture as the `destinationTransfer` nonce guard), so a malformed guard cannot wedge the happy path. ## Threat Vectors & Mitigations @@ -204,6 +204,7 @@ graph TD | **Wrong-chain signer on SquidRouter** | RPC selected from `inputCurrency` heuristic instead of `bridgeMeta.fromNetwork`; EUR-onramp presigned txs (`network: Networks.Base`) submitted on Polygon RPC → `invalid chain id for signer` and the ramp stalls in `squidRouterSwap`. | `squid-router-phase-handler.ts` reads `bridgeMeta.fromNetwork` (set by the route builder) and routes both approve+swap to that chain's client. Heuristic removed. | | **Same-chain destination nonce gap (0-delivery)** | SquidRouter source chain == destination chain (e.g. EUR → Base EURC). `destinationTransfer` is signed *after* the post-`complete` cleanup approvals (and handler-less backup re-swap txs), leaving its nonce above the live ephemeral nonce. The chain rejects it as "nonce too high"; it never mines, the ramp retries until the budget exhausts, and user funds strand on the ephemeral with no terminal signal. | Route builders (`mykobo-to-evm.ts`, `alfredpay-to-evm.ts`, `avenia-to-evm-base.ts`) place `destinationTransfer` at the first nonce after `squidRouterSwap`, append cleanups afterward, and omit the same-chain backup re-swap txs. `destination-transfer-handler.ts` additionally fails fast (`UnrecoverablePhaseError`) when the presigned nonce is detected ahead of the live nonce. | | **Predictable EVM Nabla swap revert** | Base Nabla pool liquidity or coverage-ratio constraints make the presigned swap impossible before it is broadcast. Without preflight, the ephemeral submits a transaction that reverts on-chain, wastes gas, and only fails after receipt polling. | The EVM branch of `nabla-swap-handler.ts` runs `eth_call` on the exact decoded presigned raw transaction from the ephemeral account with `blockTag: "pending"`. A simulation revert aborts before `sendRawTransaction`, preserving the revert reason in the phase error log. | +| **Short-funded ephemeral burns a presigned payout nonce** | The provider settles slightly less than quoted, or a subsidy is capped/skipped, so the ephemeral holds less than the presigned payout amount. Broadcasting anyway reverts, consumes the fixed nonce, and the presigned transfer can never be re-broadcast — funds strand on the ephemeral with only manual recovery. | `alfredpayOfframpTransfer`, `mykoboPayoutOnBase`, and `brlaPayoutOnBase` call `ensurePresignedTransferFunded` before their first broadcast: sender/token/amount are decoded from the signed raw tx and the sender balance is polled (3-minute timeout) before `sendRawTransaction`; a shortfall raises a recoverable error so the nonce is never spent on a doomed transfer. | ## Audit Checklist @@ -218,6 +219,7 @@ graph TD - [x] Moonbeam handler refreshes gas estimate per retry attempt (F-028, fixed) - [x] `post-swap-handler` has explicit default rejection for unrecognized routing combinations (F-031, fixed) - [x] `distributeFees` is a non-terminal phase — failure triggers retry, not silent skip +- [x] `alfredpayOfframpTransfer`, `mykoboPayoutOnBase`, and `brlaPayoutOnBase` run `ensurePresignedTransferFunded` before the first broadcast of their presigned single-use transfer — sender recovered from the signature, token/amount decoded from calldata, balance polled with a 3-minute timeout, shortfall raised as a recoverable error. Decode failures warn and fall through (best-effort). - [EXISTING FINDING] **F-053**: Five phase handlers lack idempotency guards — `stellar-payment-handler`, `pendulum-to-assethub-phase-handler`, `pendulum-to-hydration-xcm-phase-handler`, `hydration-swap-handler`, `nabla-swap-handler` can double-execute on retry. - [EXISTING FINDING] **F-054**: Backup presigned transactions (`backupSquidRouterApprove`, `backupSquidRouterSwap`, `backupApprove`) have no registered phase handlers — dead code or missing implementation. - [ ] No aggregate cross-ramp subsidy rate limiting — many concurrent ramps could drain funding account diff --git a/docs/security-spec/03-ramp-engine/state-machine.md b/docs/security-spec/03-ramp-engine/state-machine.md index 7268984f0..610392074 100644 --- a/docs/security-spec/03-ramp-engine/state-machine.md +++ b/docs/security-spec/03-ramp-engine/state-machine.md @@ -28,7 +28,7 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi 2. **Only `currentPhase` and `phaseHistory` MUST be updated during phase transitions** — The processor uses `{ fields: ["currentPhase", "phaseHistory"] }` to prevent handlers from accidentally overwriting unrelated state columns. 3. **Terminal states (`complete`, `failed`) MUST halt processing** — Once a ramp reaches a terminal state, the processor MUST stop recursion and clean up retry counters. 4. **Lock acquisition MUST be atomic** — **KNOWN ISSUE**: The current implementation reads `state.processingLock.locked` from a potentially stale DB read, then sets it in a separate UPDATE. Between the read and write, another process could also acquire the lock. There is no `SELECT FOR UPDATE`, advisory lock, or atomic compare-and-swap. -5. **Lock expiry MUST prevent indefinite stalls** — If a process crashes while holding a lock, the 15-minute expiry ensures another process can eventually take over. The `isLockExpired()` check validates the timestamp. +5. **Lock expiry MUST prevent indefinite stalls** — If a process crashes while holding a lock, the 15-minute expiry ensures another process can eventually take over. The `isLockExpired()` check validates the timestamp. **FIXED (2026-07-05)**: the takeover previously never succeeded — after force-releasing the expired DB lock, `acquireLock` re-read the stale in-memory `state.processingLock.locked` and gave up. `processRamp` now reloads the state after the release. Regression-tested in `apps/api/src/tests/corridors/brl-onramp.scenario.test.ts` ("lock takeover"), alongside a companion test that a *fresh* foreign lock is neither processed past nor clobbered. 6. **Retries MUST be bounded** — Maximum 8 retries (`MAX_RETRIES`). After exhaustion, the processor stops retrying (but does not automatically transition to `failed` — this is a gap). 7. **Phase execution MUST be time-bounded** — The 10-minute timeout (`MAX_EXECUTION_TIME_MS`) prevents handlers from hanging indefinitely. Timeouts are treated as recoverable errors. 8. **The retry counter MUST be reset on successful phase advancement** — When the phase changes, `retriesMap.delete(state.id)` clears the counter, giving the next phase a fresh retry budget. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 79d79d96e..1e210b9d0 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -17,7 +17,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **On-ramp flow:** 1. Quote stage emits `ctx.alfredpayOnramp` with provider `quoteId` (30s upstream TTL) and `ctx.subsidy` with the discount-engine target. -2. User initiates on-ramp → receives Alfredpay payment instructions. +2. API-key-authenticated integration initiates on-ramp for a user with completed Alfredpay KYC → receives Alfredpay payment instructions. 3. User makes fiat payment. 4. `alfredpayOnrampMint` phase: confirms Alfredpay payment, credits the Alfredpay on-chain token to the ephemeral on Polygon. If the provider quote is degraded or expired and the discount engine's `expectedOutput` exceeds the provider's, the phase emits `alfredOnrampMintFallback` to record the substitution. 5. `subsidizePreSwap` phase: tops up the ephemeral's Alfredpay on-chain token balance to the subsidy target (Polygon, `ALFREDPAY_EVM_TOKEN`). @@ -35,6 +35,8 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu **Request validation:** Alfredpay middleware (`alfredpay.middleware.ts`) validates the `country` parameter against the `AlfredPayCountry` enum for all Alfredpay-related requests. +**Fiat account routes:** `POST/GET/DELETE /v1/alfredpay/fiatAccounts` accept either a Supabase Bearer token (frontend/user-session) or a user-scoped secret API key (SDK/server integration) via `requirePartnerOrUserAuth()`. The controller resolves the operating user via `getEffectiveUserId(req)` so that `AlfredPayCustomer.findOne({ where: { userId, country } })` returns the linked customer regardless of which credential was presented. The remaining Alfredpay routes (KYC/KYB/customer creation/status) continue to require a Supabase Bearer session via `requireAuth` because KYC/KYB submission is a browser-mediated flow. + ## Security Invariants 1. **Alfredpay API credentials MUST be stored as environment variables** — Never hardcoded or in database. @@ -51,7 +53,10 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 12. **The Polygon swap short-circuit MUST be gated on the output token, not on the destination network alone** — Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) directly on Polygon, so the `squidRouterSwap` handler short-circuits straight to `finalSettlementSubsidy` (no swap) only when `quote.metadata.request.to === Networks.Polygon` **and** `quote.outputCurrency === ALFREDPAY_EVM_TOKEN`. `quote.metadata.request.to` is the destination *network*, not the output token; gating on the network alone would mis-deliver — a user requesting any other Polygon output (e.g. USDC) would silently receive the minted USDT instead of their swapped asset. The quote engine (`onramp-polygon-to-evm-alfredpay.ts`) mirrors this: it emits `skipRouteCalculation: true` only for the same-token (`outputCurrency === ALFREDPAY_EVM_TOKEN`) case and otherwise produces a real USDT→output swap. 13. **Offramp quote refresh at prep time MUST be strict and transactional** — `refreshAlfredpayOfframpQuoteIfMatching` (called during `prepareOfframpNonBrlTransactions`) re-fetches a provider quote and compares `toAmount` and `fee`. Any drift throws `INTERNAL_SERVER_ERROR`, aborting ramp registration. The quote metadata update (new `quoteId` + `expirationDate`) runs within the registration transaction, so a partial update cannot persist. 14. **`finalSettlementSubsidy` MUST NOT be skipped for Alfredpay offramps** — The `FinalSettlementSubsidyHandler` direct-transfer skip explicitly excludes `SELL && isAlfredpayToken(outputCurrency)`. This ensures the ephemeral on Polygon is always topped up to the expected amount before `alfredpayOfframpTransfer`, preventing under-funded settlements. -15. **Routed Alfredpay onramp quote output precision MUST match the destination token** — For Alfredpay USD/MXN/COP onramps that route through Squid, `quote.outputAmount` MUST preserve the final destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Polygon-minted Alfredpay token is only the Squid source-side input. Direct Polygon same-token passthrough remains at the minted token's 6-decimal precision. +15. **Routed Alfredpay onramp quote output precision MUST match the destination token** — For Alfredpay USD/MXN/COP/ARS onramps that route through Squid, `quote.outputAmount` MUST preserve the final destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Polygon-minted Alfredpay token is only the Squid source-side input. Direct Polygon same-token passthrough remains at the minted token's 6-decimal precision. +16. **Alfredpay ramp registration MUST bind to a completed KYC/KYB customer** — Registration MUST reject Alfredpay onramps before customer lookup when no completed Alfredpay customer context is available, and MUST reject customer records whose Alfredpay status is not `Success`. This prevents ramps from reaching provider/customer queries with undefined `user_id` and ensures payment instructions are only issued for verified Alfredpay customers. SDK/server integrations authenticate with partner API keys (`pk_*`/`sk_*`); Supabase Bearer tokens are frontend/user-session auth. +17. **Alfredpay ramp registration MUST derive the customer id from the effective user; quotes carry only tracking metadata** — The off-ramp transaction route, the on-ramp route, the register-time quote refresh, and the off-ramp transfer recovery path all resolve `alfredPayId` via the strict, KYC-gated `resolveAlfredpayCustomerId(fiatCurrency, effectiveUserId)`. Quote creation is anonymous-eligible: the on-ramp initialize engine and off-ramp partner engine use `resolveAlfredpayQuoteCustomerId`, which fills the *tracking-only* quote `metadata.customerId` with the caller's real customer id when a KYC-completed customer resolves, and the `"anonymous"` sentinel otherwise. Alfredpay validates the top-level `customerId` only on order creation, so no provider *order* ever carries a placeholder identity. Public keys and unlinked secret keys can quote but cannot register Alfredpay ramps. +18. **`alfredpayOfframpTransfer` MUST verify the ephemeral's token balance before the first broadcast of the presigned transfer** — The presigned final transfer is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the Polygon ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. This complements invariant 14 (`finalSettlementSubsidy` ordering) by also catching capped/failed subsidies. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. ## Threat Vectors & Mitigations @@ -69,7 +74,9 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu | **Alfredpay offramp skipping subsidy via direct-transfer flag** | An Alfredpay offramp ramp with `isDirectTransfer === true` skips `finalSettlementSubsidy`, under-funding the settlement | `FinalSettlementSubsidyHandler` explicitly excludes `SELL && isAlfredpayToken(outputCurrency)` from the direct-transfer skip; subsidy always runs for Alfredpay offramps | | **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | | **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the handler skips the swap on destination-network alone and transfers the minted USDT, delivering the wrong asset | The `squidRouterSwap` short-circuit is gated on `quote.outputCurrency === ALFREDPAY_EVM_TOKEN` (not just `quote.metadata.request.to === Networks.Polygon`); non-USDT Polygon outputs run the real USDT→output swap. Matched in the quote engine's `skipRouteCalculation` branch. | -| **Routed destination precision loss** | A USD/MXN/COP Alfredpay onramp mints a 6-decimal Polygon source token, routes to an 18-decimal destination token, and stores the final quote output with source precision. The final amount is truncated before destination-transfer expectations are calculated. | Finalize routed Alfredpay EVM quotes with the destination token's decimals when `evmToEvm` metadata exists; keep direct Polygon same-token passthrough at minted-token precision. | +| **Routed destination precision loss** | A USD/MXN/COP/ARS Alfredpay onramp mints a 6-decimal Polygon source token, routes to an 18-decimal destination token, and stores the final quote output with source precision. The final amount is truncated before destination-transfer expectations are calculated. | Finalize routed Alfredpay EVM quotes with the destination token's decimals when `evmToEvm` metadata exists; keep direct Polygon same-token passthrough at minted-token precision. | +| **Anonymous Alfredpay quote/resource creation** | An SDK caller requests an Alfredpay quote without a linked user, hoping to create provider-side resources with placeholder customer identity | Alfredpay quotes are rate estimates: the customer id appears only in tracking-only quote `metadata` (`"anonymous"` sentinel for non-KYC'd callers). Provider *orders* — the only calls that create customer-bound resources — are created at registration, which requires an effective user with a `Success` Alfredpay customer via `resolveAlfredpayCustomerId`. | +| **Claiming an Alfredpay quote with a different user** | Attacker creates a quote under one linked user, then presents another Supabase token or linked secret key at register time | `RampService.registerRamp` rejects cross-user quote registration with `403`; Alfredpay customer lookup is performed from the effective user at quote and register time. | ## Audit Checklist @@ -94,3 +101,5 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] `FinalSettlementSubsidyHandler` does NOT skip subsidy for Alfredpay offramps (`SELL && isAlfredpayToken`). **PASS** — explicit exclusion in the direct-transfer skip condition. - [x] AlfredPay offramp order is created at prep time (`evm-to-alfredpay.ts:229`); `processAlfredpayOfframpStart` is a defensive validation-only no-op. **PASS** — verified. - [x] Routed Alfredpay onramp quote output precision follows destination token decimals when `evmToEvm` metadata exists; direct Polygon same-token passthrough remains at minted-token precision. **PASS** — verified in `finalize/onramp.ts`. +- [x] Alfredpay onramp registration rejects missing customer context before customer lookup and requires a `Success` Alfredpay customer status. **PASS** — `ramp.service.ts` checks for the current user-backed customer context; `alfredpay-to-evm.ts` rejects missing/non-success customer records. +- [x] Alfredpay quote engines resolve the tracking-only `metadata.customerId` via `resolveAlfredpayQuoteCustomerId` (real id for KYC-completed users, `"anonymous"` sentinel otherwise); provider *orders* always resolve via the strict `resolveAlfredpayCustomerId`. **PASS**. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 1ff4fb264..f5b5dd0d8 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -75,6 +75,11 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 13. **BRL↔AssetHub MUST stay disabled while the Base BRL rail is active-only** — The quote engine should return no quote for BRL→AssetHub or AssetHub→BRL, preventing users from registering legacy Moonbeam/Pendulum BRL routes. 14. **The BRL→BRLA-on-Base on-ramp MUST take the direct-transfer bypass** — When `inputCurrency === BRL`, `outputCurrency === BRLA`, and `network === Base`, `isBrlToBrlaBaseDirect` MUST short-circuit the route to a single `destinationTransfer` from the ephemeral to the user, with `stateMeta.isDirectTransfer = true`. The Nabla swap, `distributeFees`, SquidRouter, `finalSettlementSubsidy`, and Base cleanup phases MUST NOT run — routing BRLA through USDC and back would burn double-swap slippage/fees against the user and expose the over-subsidy race (`06-cross-chain/fund-routing.md`). The `squidRouterSwap` and `finalSettlementSubsidy` handlers MUST also honor `isDirectTransfer`/`isBrlToBrlaBaseDirect` defensively and skip to `destinationTransfer` if reached. 15. **BRL→EVM quote output precision MUST match the destination token** — For supported EVM destinations, `quote.outputAmount` MUST preserve the destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Squid bridge input remains Base USDC raw (`evmToEvm.inputAmountRaw`), but final delivery uses destination-token decimals. +16. **BRL register paths MUST derive tax ID / subaccount from the effective user** — `RampService.prepareOfframpBrlTransactions` and `RampService.prepareAveniaOnrampTransactions` resolve the Avenia account via `resolveAveniaAccountForRamp(userId, additionalData.taxId)` and call `validateBrlaOnrampRequest(derivedTaxId, ...)` / `validateBrlaOfframpRequest(derivedTaxId, ...)`. A client-supplied `additionalData.taxId` is accepted only when it matches the derived value (enforced identically on the onramp and offramp paths); mismatches return `400`. `additionalData.receiverTaxId` may legitimately differ from the sender and is validated downstream against the PIX key owner. +17. **`/v1/brla/getUser` and `/v1/brla/getUserRemainingLimit` MUST scope reads to the effective user** — When a `taxId` query is provided, the `TaxId` row MUST be owned by `getEffectiveUserId(req)`. When `taxId` is omitted, the endpoint derives the user's Avenia account via the resolver and returns `400` for zero or multiple KYC-completed matches. The legacy partner-key exemption that allowed reading any taxId has been removed; bare partner keys (no `api_keys.user_id` binding) and fully anonymous callers are rejected with `400`. +18. **`/v1/brla/createSubaccount` MUST require an authenticated principal** — The route now uses `requirePartnerOrUserAuth()` and the controller requires an effective user. Bare partner keys and anonymous callers receive `400`; the Avenia API is not called and no `TaxId` row is created. This closes the anonymous subaccount creation DoS surface. +19. **BRL quote creation MUST remain anonymous-eligible while register/start remain user-gated** — `POST /v1/quotes` and `POST /v1/quotes/best` accept BRL corridors from anonymous callers and partner-key callers (with or without a `userId` binding). The Avenia `createPayInQuote` calls used by the BRL engines do not require a user-bound principal. The actual Avenia subaccount/taxId resolution still happens server-side at register time via `resolveAveniaAccountForRamp(effectiveUserId, additionalData.taxId)`. `POST /v1/ramp/register` requires Supabase or secret-key credentials, and `RampService.registerRamp` rejects provider-backed ramps without an effective user with `400 Invalid quote`. **An anonymous BRL quote may be claimed by an authenticated caller** (the normal web-app funnel: quote before login, register after) — claiming is not an escalation because the anonymous quote carries no owner and the Avenia identity is derived from the claimer's own KYC records, never from the quote or request body. +20. **`brlaPayoutOnBase` MUST verify the ephemeral's BRLA balance before the first broadcast of the presigned transfer** — The presigned payout is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. The Avenia-side balance poll (invariant 4) runs after this on-chain transfer and does not replace it. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. ## Threat Vectors & Mitigations @@ -91,6 +96,8 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Underdelivery from Nabla** | Nabla swap returns less BRLA than quoted, balance poll times out, ramp stuck | Balance-poll timeout (5min) fails the phase as recoverable; `subsidizePostSwap` (EVM branch) tops up eligible shortfalls subject to the env-configured split quote-relative EVM subsidy caps documented in `fund-routing.md`. The actual-vs-quoted swap discrepancy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Both default to `0.05`. | | **Disabled AssetHub corridor accidentally re-enabled** | Legacy BRL↔AssetHub route files are selected and a user registers a route that the Base BRL rail no longer supports | Quote eligibility must return no quote for BRL→AssetHub and AssetHub→BRL. Treat any successful quote for those corridors as a regression until the corridor is intentionally re-enabled. | | **BRL→BRLA-Base self-swap drain** | The generic pipeline swaps the user's already-minted BRLA to USDC and back, charging two swaps of slippage/fees and triggering `finalSettlementSubsidy` against bridge-less dust (over-subsidy + strand) | `isBrlToBrlaBaseDirect` collapses the corridor to a single `destinationTransfer` with `isDirectTransfer = true`; Nabla/distributeFees/Squid/finalSettlementSubsidy/cleanup are skipped at both route-build and handler level. | +| **Anonymous BRL register on someone else's subaccount** | An anonymous SDK caller (no Supabase session, no linked secret key) uses an anonymous BRL quote to register a ramp on top of another user's Avenia subaccount via the quoteId guess | `RampService.registerRamp` rejects provider-backed ramps without an effective user with `400 Invalid quote`; an attacker cannot bind a BRL ramp to a subaccount they do not own. | +| **Claiming an anonymous BRL estimate at register time** | Attacker mints an anonymous BRL quote, then presents a Supabase token (or a different user's linked secret API key) at register time to bind the resulting ramp to a different user's `TaxId` | `RampService.registerRamp` rejects with `403` when `quote.userId == null && request.userId != null` (inv. 19). The register principal must re-quote with their identity so `quote.userId` is bound at quote time. | | **Destination-token decimal under-delivery** | A BRL on-ramp targets an 18-decimal token such as BSC USDT, but the quote output is truncated to 6 decimals before `destinationTransfer` raw amount construction. | On-ramp finalization uses destination-token decimals for BRL EVM outputs; Squid metadata preserves destination raw output from `route.estimate.toAmount`. | ## Audit Checklist diff --git a/docs/security-spec/05-integrations/mykobo.md b/docs/security-spec/05-integrations/mykobo.md index 4f84ba4ae..70fe946e6 100644 --- a/docs/security-spec/05-integrations/mykobo.md +++ b/docs/security-spec/05-integrations/mykobo.md @@ -90,7 +90,9 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do 20. **`MYKOBO_CLIENT_DOMAIN` MUST be set in every deployment** — The constant is sent as `client_domain` on every Mykobo API call and selects the negotiated fee tier. Because it is loaded via `getEnvVar` with no default, a missing value silently falls back to Mykobo's default tier (worse fees, observed ~5x higher). Deploy-time checks MUST treat an unset `MYKOBO_CLIENT_DOMAIN` as a hard failure. 21. **Mykobo intent `value` MUST be floored to 2 decimal places** — Mykobo silently truncates anything beyond 2 dp, which would otherwise cause the on-chain transfer amount and the Mykobo-credited amount to diverge. Both the on-ramp `DEPOSIT` intent and the off-ramp `WITHDRAW` intent MUST send a 2dp-floored `value`, and the off-ramp on-chain transfer MUST be derived from that same floored value (not from the unrounded Nabla output). The sub-cent EURC remainder on the ephemeral MUST be swept by `baseCleanupEurc`. 22. **The EUR→EURC-on-Base on-ramp MUST take the direct-transfer bypass** — When `inputCurrency === EURC`, `outputCurrency === EURC`, and `network === Base`, `isEurToEurcBaseDirect` MUST short-circuit the route to a single `destinationTransfer` from the ephemeral to the user, with `stateMeta.isDirectTransfer = true`. The Nabla swap, SquidRouter, `finalSettlementSubsidy`, and Base cleanup phases MUST NOT run — routing EURC through USDC and back would burn double-swap slippage/fees against the user and expose the over-subsidy race (`06-cross-chain/fund-routing.md`). The `finalSettlementSubsidy` handler MUST also honor `isDirectTransfer`/`isEurToEurcBaseDirect` defensively and skip to `destinationTransfer` if reached. +23. **EUR ramp registration MUST derive the Mykobo email from the effective user and require approved Mykobo KYC** — Both the on-ramp (`prepareMykoboOnrampTransactions`) and off-ramp (`evm-to-mykobo.ts`) resolve the Mykobo `email_address` via `resolveMykoboCustomerForUser(userId, providedEmail?)`, which (a) reads the canonical email from the effective user's profile (`profiles.email` is unique and keyed by `userId`, so this works for both Supabase sessions and user-scoped secret keys), (b) accepts a client-supplied `additionalData.email` only when it matches the derived value and rejects mismatches with `400`, and (c) refreshes the Mykobo KYC mirror from the live profile and rejects with `400` unless the resulting `MykoboCustomer.status === APPROVED`. The client-supplied email is never passed to Mykobo directly, and the provider intent is created only after the gate passes. This mirrors the BRL/Avenia (`resolveAveniaAccountForUser`) and Alfredpay (`resolveAlfredpayCustomerId`) derivation+KYC pattern. Combined with the universal register-time effective-user requirement (`01-auth/api-keys.md` inv. 13), EUR ramps cannot be registered anonymously or with an unlinked key, and one user cannot drive a ramp against another user's Mykobo identity. 23. **Disabled EUR registration MUST fail before provider side effects** — While EUR ramps are disabled, `registerRamp` MUST reject any quote with `inputCurrency === FiatToken.EURC` or `outputCurrency === FiatToken.EURC` using `503 SERVICE_UNAVAILABLE` before Mykobo intent creation, presigned transaction preparation, quote consumption, or ramp-state creation. +24. **`mykoboPayoutOnBase` MUST verify the ephemeral's EURC balance before the first broadcast** — The presigned payout is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. ## Threat Vectors & Mitigations @@ -109,6 +111,8 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do | **Long-lived on-ramp DOS** | Attacker creates many on-ramps to occupy ephemeral accounts for 24h | Per-user concurrent ramp limit (cross-cutting; see `07-operations/api-surface.md` rate limiting). Ephemerals are user-funded so blast radius is bounded. | | **TLS downgrade / MITM** | Attacker intercepts Mykobo API calls | HTTPS-only base URL; bearer-token auth depends on TLS; refuse any non-HTTPS `MYKOBO_BASE_URL` configuration at deploy time. | | **Profile-doc PII leak** | KYC document or PII surfaces in Vortex storage or logs | Documents are streamed through to Mykobo as multipart form-data without persistence; no PII fields stored locally beyond the email→profile-existence linkage. | +| **Cross-user EUR identity via client-supplied email** | An authenticated user passes another user's KYC'd Mykobo email in `additionalData.email` at register time to ramp against that identity | `resolveMykoboCustomerForUser` ignores the body email as a source of truth: it derives the email from the caller's own `profiles.email` and only accepts a provided email if it matches. A non-matching email is rejected with `400`, so the body email cannot select a different Mykobo customer. | +| **Unverified EUR ramp / KYC bypass** | A registered-but-not-KYC'd profile (or one whose Mykobo review is pending/rejected) registers an EUR ramp and receives SEPA payment instructions | Registration refreshes the Mykobo KYC mirror live and rejects with `400` unless `MykoboCustomer.status === APPROVED`; the Mykobo intent is created only after the gate passes. | | **Cross-version Mykobo API drift** | Operator misconfigures `MYKOBO_BASE_URL` to a root domain, hitting an unintended version | `MykoboApiService` enforces a `/v` suffix; misconfiguration fails fast on the first auth call. | | **`MYKOBO_CLIENT_DOMAIN` unset → wrong fee tier** | Operator forgets to set `MYKOBO_CLIENT_DOMAIN`; Mykobo silently applies its default tier (~5x worse fees) and quotes/distributions drift from reality | Deploy-time check fails fast if the env var is missing; alarms on observed Mykobo fees exceeding `defaultDepositFee` / `defaultWithdrawFee` (see `07-operations/secret-management.md`). | | **Intent-value precision drift** | EURC payout amount carries >2 dp; Mykobo silently truncates and credits less than the on-chain transfer, leaving the user short | Both `DEPOSIT` and `WITHDRAW` intents send `Big.toFixed(2, 0)`-floored `value`; the off-ramp on-chain EURC transfer is derived from the same floored value; sub-cent dust is swept by `baseCleanupEurc`. | @@ -122,6 +126,7 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do - [ ] `mykoboOnrampDeposit` polls Base RPC for EURC arrival before advancing - [ ] 24h outer payment timeout enforced; on expiry the ramp transitions to `failed` - [ ] 5% recovery tolerance applied only to the pre-funded shortcut, not to live polling +- [ ] EUR register (on-ramp and off-ramp) derives the Mykobo email from `profiles.email` via `resolveMykoboCustomerForUser`, rejects a non-matching client-supplied email with `400`, and requires `MykoboCustomer.status === APPROVED` before creating the Mykobo intent - [ ] On-ramp intent `wallet_address` is the Base ephemeral (not the user destination address) - [ ] Off-ramp intent `wallet_address` is the Base ephemeral - [ ] Off-ramp `mykoboReceivablesAddress` comes from `intent.instructions.address` (server-side) diff --git a/docs/security-spec/05-integrations/squid-router.md b/docs/security-spec/05-integrations/squid-router.md index c5abb76db..af31a5841 100644 --- a/docs/security-spec/05-integrations/squid-router.md +++ b/docs/security-spec/05-integrations/squid-router.md @@ -8,7 +8,7 @@ Squid Router is a cross-chain swap/routing protocol built on Axelar's General Me - **EUR on-ramp (Mykobo on Base)**: Base USDC → user's destination EVM chain (after EURC→USDC Nabla swap). - **EUR off-ramp (Mykobo on Base)**: User's source EVM chain → Base USDC (client-side user-signed). - **Alfredpay on-ramp**: Polygon Alfredpay token → user's destination EVM chain/token, except for Polygon same-token passthrough. -- **Off-ramp permit acquisition (Alfredpay)**: User EVM → Moonbeam via `TokenRelayer.execute()` with EIP-2612 permit. +- **Off-ramp permit acquisition (Alfredpay)**: User source EVM → Polygon via the source-chain `TokenRelayer.execute()` with EIP-2612 permit. > **Removed:** the previous Monerium-EUR Squid usage (Polygon EURe → Moonbeam) is no longer active; Monerium is deprecated (see `monerium.md`). @@ -35,7 +35,7 @@ For quote metadata, Squid's `route.estimate.toAmount` is already denominated in ### Off-ramp flow (user EVM source → Base USDC) 1. User signs one of three paths (depending on source ERC-20 capabilities and direction): - - **Permit path**: EIP-2612 permit + payload typed data → `squidRouterPermitExecute` → `TokenRelayer.execute()` pulls funds, approves Squid, calls swap atomically. Gas paid by `MOONBEAM_EXECUTOR_PRIVATE_KEY`. + - **Permit path**: EIP-2612 permit + payload typed data → `squidRouterPermitExecute` → source-chain `TokenRelayer.execute()` pulls funds, approves Squid, calls swap atomically. Gas is paid by the configured executor key through a wallet client for `fromNetwork`. - **No-permit fallback** (`isNoPermitFallback=true`): user's own wallet broadcasts `squidRouterNoPermitApprove` + `squidRouterNoPermitSwap` (or `squidRouterNoPermitTransferHash` for direct-transfer subcase). Frontend reports the resulting tx hashes back via `UpdateRampRequest.additionalData`. Backend awaits receipts via `waitForUserHash`. **No presigned-tx validation runs for these phases** — they are user-submitted (see `transaction-validation.md`). - **Direct transfer** (`isDirectTransfer=true`): same-chain same-token, user wallet submits a direct ERC-20 transfer to the Base ephemeral. 2. `squidRouterPay`: monitors Axelar GMP for arrival on Base. @@ -56,12 +56,14 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is 5. **Squid API rate-limit responses MUST be retried with backoff** — 429 responses are retried with exponential backoff before failing the phase. Other errors propagate directly. 6. **Axelar gas funding MUST use `addNativeGas` on the correct chain** — The funding source/chain is selected based on the route, not from request input. 7. **Permit execution MUST verify both permit and payload signatures** — `squidRouterPermitExecute` extracts v/r/s from both `permitTypedData` and `payloadTypedData`; both must be valid `SignedTypedData`. -8. **`MOONBEAM_EXECUTOR_PRIVATE_KEY` is the relayer caller** — Funds gas only; MUST NOT hold user funds. +8. **The configured executor key is the relayer caller on the source EVM network** — It funds gas only; MUST NOT hold user funds. 9. **No-permit fallback MUST verify on-chain receipt for every reported user hash** — `waitForUserHash` calls `waitForTransactionReceipt`; non-success status throws `RecoverablePhaseError`. The user-reported hash itself is trusted (no signature verification — the receipt confirms it succeeded, which is sufficient because the user controls the source funds either way). 10. **No-permit fallback MUST NOT advance to `fundEphemeral` until BOTH approve and swap (or the direct transfer) have confirmed** — Sequential `waitForUserHash` calls in `executeNoPermitFallback` enforce this. 11. **Transaction hashes MUST be persisted to state before waiting** — `squidRouterApproveHash`, `squidRouterSwapHash`, `squidRouterPayTxHash`, `squidRouterPermitExecutionHash`, `squidRouterNoPermitApproveHash`, `squidRouterNoPermitSwapHash`, `squidRouterNoPermitTransferHash` all enable crash recovery. 12. **Skip-Squid path MUST NOT lose destination validation** — Quote engine `validate()` runs regardless of `skipRouteCalculation`; `destinationTransfer` is the only on-chain step that fires. 13. **Squid output raw metadata MUST use destination-token raw units** — `route.estimate.toAmount` is the authoritative destination raw output; `evmToEvm.outputAmountRaw` MUST NOT be recomputed with the source token's decimals. For same-chain same-token passthrough, `inputAmountRaw` is also the destination raw amount and is safe to mirror. Routed Alfredpay onramps follow the same rule; only direct Polygon same-token passthrough keeps the minted source-token precision. +14. **Permit execution MUST confirm the owner's token balance before spending the single-use permit** — An EIP-2612 permit is single-use: the token increments the owner's nonce on the first successful `permit()`, so executing against an unfunded owner burns the permit and strands the ramp. `assertOwnerHasBalance` in `squidrouter-permit-execution-handler.ts` reads `balanceOf(owner)` on both the direct-transfer and relayer paths and throws a **recoverable** error when the owner cannot cover `value`, giving the owner ~10 minutes (`getMaxRetries()=20` at the 30s cadence) to fund the wallet. On the direct-transfer path, retries additionally skip `permit()` when the standing allowance already covers `value`, so an already-consumed permit is never replayed. +15. **The SDK pre-checks the source wallet balance before registering any offramp** — `assertSufficientOfframpBalance` (called from `VortexSdk.registerRamp` for every SELL corridor) reads the input token balance of `walletAddress` on the source EVM chain and rejects registration with `InsufficientBalanceError` when it does not cover `inputAmount`. This is client-side defense-in-depth against reverting user transactions / unexecutable permits: it is best-effort (RPC failure or unknown token skips the check, AssetHub sources are not checked) and MUST NOT be relied on in place of invariant 14 or backend-side validation. ## Threat Vectors & Mitigations @@ -71,11 +73,12 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is | **Gas overpayment to Axelar** | `calculateGasFeeInUnits()` uses Axelar's reported base fee + estimated gas × source gas price × multiplier. Result verified non-negative. | | **Double-spend of approve/swap** | Approve hash persisted immediately; on re-entry handler skips to swap if hash exists. EVM nonce prevents on-chain double-spend in any case. | | **Permit replay** | Each permit has a nonce + deadline; TokenRelayer validates on-chain. | +| **Unfunded owner burns the single-use permit** | User signs the permit before funding the wallet (or drains it after signing); executing `permit()` would consume the nonce with no recoverable transfer. Backend checks `balanceOf(owner) >= value` before touching the permit and retries recoverably (~10 min window); SDK additionally refuses to register an offramp when the wallet balance does not cover `inputAmount`. | | **Executor key compromise** | Attacker can call `execute()` with their own signatures but cannot steal in-flight user funds — the key only pays gas. Blast radius: gas balance drain. | | **Squid Router API manipulation (fake "success")** | Balance check runs in parallel; even if Squid reports premature success, tokens must actually arrive. | | **Squid rate limit (429)** | Exponential backoff retry; other errors fail fast. | | **Transaction not found during confirmation** | Exponential backoff retry (5s → 10s → 20s → 30s cap), up to 4 attempts. | -| **No-permit fallback hash spoofing** | User reports tx hash → backend calls `waitForTransactionReceipt(hash)`. Hash is verified against actual chain state, not trusted blindly. The worst the user can do is report a hash that doesn't exist (handler errors recoverably) or a hash for a different transaction (receipt's `to`/`value` are not currently re-checked — see open question below). | +| **No-permit fallback hash spoofing** | User reports tx hash → backend calls `waitForTransactionReceipt(hash)` and verifies the receipt `from`, receipt `to`, and transaction calldata against the expected presigned user-wallet transaction. A missing hash or mismatched transaction fails before the phase advances. | | **No-permit allowance window attack** | The `squidRouterNoPermitApprove` grants Squid an allowance from the user's wallet; if the swap hash never confirms, the allowance lingers. The user wallet, not Vortex, retains the risk. UX should remind the user to revoke unused allowances; backend cannot revoke on the user's behalf. | | **Skip-Squid trivial-case manipulation** | The skip path triggers only when destination is Base+USDC, validated server-side by the quote engine before any presigned tx is generated. Attacker cannot force the skip path on non-Base/non-USDC routes. | | **Destination decimal under-scaling** | A quote route bridges from a 6-decimal source token to an 18-decimal destination token (for example Base USDC → BSC USDT), but metadata reconstructs the destination raw output using source decimals. Displayed decimals look correct while raw metadata is under-scaled. | Preserve Squid's `route.estimate.toAmount` directly as destination-token raw metadata, and persist `quote.outputAmount` with destination-token precision before building the final transfer. | @@ -91,6 +94,8 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is - [PARTIAL] Verify `MOONBEAM_FUNDING_PRIVATE_KEY` (gas funding) and `MOONBEAM_EXECUTOR_PRIVATE_KEY` (relayer calls) are distinct keys. **PARTIAL** — distinct env vars, but operationally `MOONBEAM_FUNDING_PRIVATE_KEY` is reused on **Base** for subsidization and the `backupApprove` funding spender. The name no longer reflects its scope; rename to `EVM_FUNDING_PRIVATE_KEY` and expose via a per-network getter (see `06-cross-chain/fund-routing.md`). - [PARTIAL] `getPublicClient()` Moonbeam fallback (line 147). **PARTIAL** — known buggy fallback; logs "This is a bug" but defaults to Moonbeam. - [x] `isSignedTypedDataArray` validation in `squidrouter-permit-execution-handler.ts` correct. **PASS** +- [x] **Owner balance guard before permit execution**: `assertOwnerHasBalance` runs on both the direct-transfer and relayer paths before `permit()` / `TokenRelayer.execute()`; insufficient balance raises a recoverable error (retry window via `getMaxRetries()=20`), and the direct-transfer path skips `permit()` when the standing allowance already covers `value`. **PASS** +- [x] **SDK offramp balance pre-flight**: `assertSufficientOfframpBalance` in `packages/sdk/src/preflight.ts` is invoked from `VortexSdk.registerRamp` for every SELL corridor and throws `InsufficientBalanceError` when the source wallet cannot cover `inputAmount`; RPC failures skip permissively. **PASS** — client-side only, backend guards remain authoritative. - [x] `RELAYER_ADDRESS` matches deployed TokenRelayer on the correct network. **PASS** - [x] `EVM_BALANCE_CHECK_TIMEOUT_MS` (15 minutes) appropriate for Axelar GMP. **PASS** - [x] `DEFAULT_SQUIDROUTER_GAS_ESTIMATE` (1,600,000) reasonable upper bound. **PASS** diff --git a/docs/security-spec/06-cross-chain/bridge-security.md b/docs/security-spec/06-cross-chain/bridge-security.md index 53aced9d1..4527c7b25 100644 --- a/docs/security-spec/06-cross-chain/bridge-security.md +++ b/docs/security-spec/06-cross-chain/bridge-security.md @@ -1,8 +1,10 @@ # Bridge Security — Spacewalk +> **⚠️ FULLY DEPRECATED.** The Spacewalk/Stellar bridge path is no longer an active Vortex corridor. EUR has migrated to Mykobo on Base, and ARS now routes through Alfredpay where supported. `spacewalkRedeemHandler` and `stellarPaymentHandler` are not registered in the active phase registry. This page is retained as historical documentation for the prior bridge model; do not treat it as reachable production behavior. + ## What This Does -Spacewalk is the bridge between the **Pendulum** parachain and the **Stellar** network. It enables off-ramp flows that terminate on Stellar (ARS today; EUR was migrated to Mykobo on Base — see `05-integrations/mykobo.md`) by converting Pendulum-wrapped Stellar tokens back to native Stellar tokens. +Spacewalk is the bridge between the **Pendulum** parachain and the **Stellar** network. Historically, it enabled off-ramp flows that terminated on Stellar by converting Pendulum-wrapped Stellar tokens back to native Stellar tokens. Those corridors are now removed from the active Vortex phase registry. The bridge operates through a **vault-based model**: independent vault operators lock collateral on Pendulum and process redeem requests. When a user (or ephemeral account) wants to redeem Pendulum-wrapped tokens for their Stellar originals, a vault is selected, the wrapped tokens are burned on Pendulum, and the vault releases the native tokens on Stellar. diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index d3d7f0db2..281d167e1 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -9,7 +9,7 @@ There are now **five** subsidization-related phase handlers and one settlement p **Phase handlers (Substrate):** - `subsidize-pre-swap-handler.ts` — Tops up the Pendulum ephemeral before a Nabla swap to ensure it has the expected input amount - `subsidize-post-swap-handler.ts` — Tops up the Pendulum ephemeral after a Nabla swap. Also contains complex next-phase routing logic. -- `final-settlement-subsidy.ts` — Tops up an EVM ephemeral by SquidRouter-swapping native → ERC-20 (legacy / cross-chain settlement). Has a USD cap (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`). +- `final-settlement-subsidy.ts` — Tops up an EVM ephemeral by SquidRouter-swapping native → ERC-20 (legacy / cross-chain settlement). Has a USD cap (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`). Records the confirmed top-up as a `subsidies` table row (like the other subsidy handlers), so settlement subsidies are visible to subsidy accounting. - `destination-transfer-handler.ts` — Sends the presigned EVM transfer from the ephemeral to the user's destination address **Phase handlers (EVM):** The Substrate handlers above are polymorphic: `subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` dispatch to their EVM branches when the ephemeral involved is on a supported EVM chain (currently Base). The EVM pre-swap branch tops the ephemeral up before `nablaSwap` and enforces the quote-relative cap fraction from `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`). The EVM post-swap branch splits the required top-up into a swap-discrepancy component and a discount component: `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` applies to the actual-vs-quoted swap-output discrepancy, while `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (default `0.05`) caps the discount-derived top-up separately. @@ -42,7 +42,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm 7. **Post-swap subsidization next-phase routing MUST be deterministic** — `subsidize-post-swap-handler.ts` contains branching logic that selects the next phase based on ramp direction (on/off), destination chain, and output token. This routing must be consistent with the flow defined at ramp creation. 8. **No subsidization handler MUST proceed if the funding account has insufficient balance** — If the funding account cannot cover the subsidy, the handler should fail with a recoverable error, not silently skip the top-up. 9. **EVM subsidy caps MUST stop transfers without forcing manual phase repair** — If an EVM pre-swap subsidy exceeds its configured quote-relative cap, the handler must not submit a transfer. For EVM post-swap subsidy, the handler must split the top-up into (a) actual-vs-quoted swap-output discrepancy and (b) discount-derived subsidy, then enforce each component's configured cap independently before submitting a single transfer. Both post-swap cap fractions are env-overridable and default to `0.05`. A cap breach is intentionally recoverable so operators can investigate, top up, or cancel the ramp without repairing an unrecoverably failed phase. -10. **`finalSettlementSubsidy` MUST subsidize the gap to *actual bridge delivery*, not to the ephemeral's total balance** — The subsidy is `expectedAmountRaw - delivered`, where `delivered = actualBalance - preSettlementBalance` and `preSettlementBalance` is the destination-token balance snapshotted right after the `squidRouterSwap` confirms (before `squidRouterPay`). Computing the subsidy from total balance is unsafe: leftover Nabla-swap dust in the destination token would make the handler return early and over-subsidize before the Squid bridge output has landed. To avoid racing the bridge, the balance poll waits for ≥90% of `expectedAmountRaw` to arrive (the 90% floor absorbs bridge slippage while still confirming the bridge actually delivered) rather than returning on any non-zero balance. (Incident: a EUR→EURC Base ramp was over-funded ~29.36 EURC and stranded ~59 EURC because the pre-fix handler returned on dust and subtracted total balance.) +10. **`finalSettlementSubsidy` MUST subsidize the gap to *actual bridge delivery*, not to the ephemeral's total balance** — The subsidy is `expectedAmountRaw - delivered`, where `delivered = actualBalance - preSettlementBalance` and `preSettlementBalance` is the destination-token balance snapshotted before `squidRouterSwap` is broadcast. The snapshot is written only once, so a retry after a same-chain synchronous swap cannot overwrite the true pre-delivery baseline with a post-delivery balance. Computing the subsidy from total balance is unsafe: leftover Nabla-swap dust in the destination token would make the handler return early and over-subsidize before the Squid bridge output has landed. To avoid racing the bridge, the balance poll waits for ≥90% of `expectedAmountRaw` to arrive (the 90% floor absorbs bridge slippage while still confirming the bridge actually delivered) rather than returning on any non-zero balance. The final subsidy is additionally clamped to `expectedAmountRaw - actualBalance`, so a bad or stale baseline cannot top up more than the on-chain shortfall. (Incident: a EUR→EURC Base ramp was over-funded ~29.36 EURC and stranded ~59 EURC because the pre-fix handler returned on dust and subtracted total balance.) 11. **Degenerate same-token settlement routes MUST skip `finalSettlementSubsidy` entirely** — When the ramp is a direct transfer (`state.state.isDirectTransfer === true`), a EUR→EURC-on-Base route (`isEurToEurcBaseDirect`), or a BRL→BRLA-on-Base route (`isBrlToBrlaBaseDirect`), the handler short-circuits to `destinationTransfer` without subsidizing. There is no Squid bridge to settle, so any subsidy computation would be against a balance the funder never needs to top up. ## Threat Vectors & Mitigations @@ -57,7 +57,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm | **Destination transfer replay** — The presigned EVM transaction is somehow submitted multiple times | EVM nonce prevents replay. Each transaction is valid for exactly one nonce value. | | **Balance check race condition in destination transfer** — Balance changes between the check and the transaction submission | Possible but unlikely for ephemeral accounts (no other senders). If balance drops between check and submission, the EVM transaction reverts (no fund loss, just a failed phase that retries). | | **Post-swap routing logic inconsistency** — The next-phase selection in `subsidize-post-swap-handler.ts` routes to a phase that doesn't match the ramp's intended flow | Routing logic uses `direction`, `toChain`, and `outputTokenType` from ramp state. A mismatch would cause the ramp to enter an unexpected phase. Since phases are handler-specific, executing the wrong phase could fail or produce incorrect results. | -| **Final settlement over-subsidy race** — `finalSettlementSubsidy` returns as soon as *any* destination-token balance appears (e.g. Nabla-swap dust) and computes `subsidy = expected − totalBalance` before the Squid bridge output lands. The funder then tops up the full expected amount on top of the later bridge delivery, double-paying the ephemeral and stranding the excess. | **Mitigated.** The handler now snapshots `preSettlementBalance` after `squidRouterSwap` confirms, waits for ≥90% of `expectedAmountRaw` to arrive, and subsidizes only `expected − (actualBalance − preSettlementBalance)`. Direct-transfer / EUR→EURC-Base routes skip the phase outright. Regression-test this: the failure mode silently over-pays from the funding key. | +| **Final settlement over-subsidy race** — `finalSettlementSubsidy` returns as soon as *any* destination-token balance appears (e.g. Nabla-swap dust) and computes `subsidy = expected − totalBalance` before the Squid bridge output lands. The funder then tops up the full expected amount on top of the later bridge delivery, double-paying the ephemeral and stranding the excess. | **Mitigated.** The handler snapshots `preSettlementBalance` before `squidRouterSwap` is broadcast, never overwrites that baseline on retry, waits for ≥90% of `expectedAmountRaw` to arrive, subsidizes only `expected − (actualBalance − preSettlementBalance)`, and clamps the transfer to the on-chain shortfall. Direct-transfer / EUR→EURC-Base routes skip the phase outright. Regression-test this: the failure mode silently over-pays from the funding key. | ## Audit Checklist @@ -78,5 +78,6 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm - [x] **FINDING F-060 (MEDIUM)**: Verify `validateSubsidyAmount` rejects negative, zero, NaN, and Infinity amounts. **PASS (FIXED)** — added try/catch around `Big()` construction to reject non-numeric strings, and `lte(0)` guard to reject zero and negative values. - [x] **EVM subsidy handlers (`subsidize-pre-swap-evm-handler.ts`, `subsidize-post-swap-evm-handler.ts`) enforce env-configured USD caps**. Pre-swap subsidy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`). Post-swap subsidy uses split caps: `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` for actual-vs-quoted swap-output discrepancy and `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (default `0.05`) for the discount-derived component. Over-cap subsidies throw `RecoverablePhaseError` before any transfer is submitted, leaving the ramp waiting for operator action instead of moving to `failed`. - [x] **`MOONBEAM_FUNDING_PRIVATE_KEY` rename/refactor**: EVM funding now uses the `EVM_FUNDING_PRIVATE_KEY` / `getEvmFundingAccount(network)` path, with the old env name retained only as backward-compatible fallback. -- [x] **`finalSettlementSubsidy` subsidizes against actual bridge delivery, not total balance.** **PASS** — snapshots `preSettlementBalance` after `squidRouterSwap` confirms (`squid-router-phase-handler.ts`, stored in `meta-state-types.ts`), waits for ≥90% of `expectedAmountRaw`, and computes `subsidy = expected − (actualBalance − preSettlementBalance)`. Prevents the dust-triggered over-subsidy race that stranded ~59 EURC. +- [x] **`finalSettlementSubsidy` subsidizes against actual bridge delivery, not total balance.** **PASS** — snapshots `preSettlementBalance` before broadcasting `squidRouterSwap` (`squid-router-phase-handler.ts`, stored in `meta-state-types.ts`), does not overwrite it on retry, waits for ≥90% of `expectedAmountRaw`, computes `subsidy = expected − (actualBalance − preSettlementBalance)`, and clamps the result to the on-chain shortfall. Prevents both the dust-triggered over-subsidy race and same-chain post-delivery baseline overwrite. - [x] **`finalSettlementSubsidy` short-circuits degenerate same-token routes.** **PASS** — returns to `destinationTransfer` when `state.state.isDirectTransfer === true`, `isEurToEurcBaseDirect(...)`, or `isBrlToBrlaBaseDirect(...)`, so no subsidy is computed for routes that have no Squid bridge to settle. +- [x] **Subsidy bookkeeping cannot silently drop rows for unknown token symbols.** **PASS (FIXED)** — `subsidies.token` was a Postgres enum, but `finalSettlementSubsidy` records the `assetSymbol` from the dynamic SquidRouter token registry (open-ended: `WETH`, `USDC.e`, ...) plus per-network native symbols (`BNB`, `AVAX`), and `BasePhaseHandler.createSubsidy` deliberately swallows insert errors so bookkeeping never blocks a phase. Any symbol outside the enum meant the subsidy was paid on-chain but never recorded. Migration 037 widens the column to `VARCHAR(32)` (the enum patched piecemeal before — see migration 036), and the swallowed-error path now logs an alertable `SUBSIDY_RECORDING_FAILED` line with ramp, phase, token, amount, and tx hash. Regression: `src/tests/subsidy-recording.invariants.test.ts`. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 7f2667b9d..92616f636 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -48,6 +48,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 10. **Request IDs MUST be correlation-only** — Request IDs may be accepted from clients or generated by the API, but they must not grant access, alter authorization, or be treated as trusted identity. 11. **API observability MUST NOT change request outcomes** — Client event persistence/logging must be best-effort and must not change controller response bodies, status codes, or ramp/quote state. 12. **Maintenance windows MUST be backend-enforced on mutable ramp entrypoints** — `POST /v1/quotes`, `POST /v1/quotes/best`, `POST /v1/ramp/register`, `POST /v1/ramp/update`, and `POST /v1/ramp/start` must reject during active maintenance with `503`, `Retry-After`, and explicit downtime start/end metadata. UI disabling is not sufficient because partners may call the API directly. +13. **Provider-backed ramp endpoints MUST reject callers without an effective user** — Alfredpay and Avenia/BRL flows derive their provider customer/subaccount from `api_keys.user_id -> profiles.id -> alfredpay_customers.user_id` / `tax_ids.user_id`. Quote creation is anonymous-eligible on every corridor (Alfredpay quotes carry only a tracking-metadata customer id — the `"anonymous"` sentinel for non-KYC'd callers), but `POST /v1/ramp/register` requires Supabase or secret-key credentials and `RampService.registerRamp` rejects missing effective users with `400 Invalid quote`. Quotes owned by a *different* user are rejected with `403`; anonymous quotes (no owner) may be claimed, with provider identity always derived from the claimer's own KYC records. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 60eb00431..9706dc948 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -4,7 +4,7 @@ The rebalancer is a standalone service (`apps/rebalancer/`) that monitors token coverage ratios and automatically moves liquidity across chains when ratios indicate a pool imbalance. Its primary function is ensuring the platform has sufficient tokens to service ramp operations without manual intervention. -The default Base rebalancer is cost-aware. A coverage-ratio breach makes a fresh cron run eligible for evaluation, but execution still depends on the configured urgency band and projected round-trip cost. Mild and moderate imbalances can be skipped when route quotes are unfavorable; severe imbalances tolerate higher configured cost. When coverage is already inside the configured bounds, the USDC → BRLA → USDC flow may still run opportunistically, but only if its projected route cost is below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). `REBALANCING_HARD_MAX_COST_BPS` remains a hard projected-cost cap in every mode. `REBALANCING_DAILY_BRIDGE_LIMIT_USD` caps non-profitable fresh Base runs, but a quote that projects profit may bypass the daily cap while still being recorded in history after completion. +The default Base rebalancer is cost-aware. A coverage-ratio breach makes a fresh cron run eligible for evaluation, but execution still depends on the configured urgency band and projected round-trip cost. Mild and moderate imbalances can be skipped when route quotes are unfavorable; severe imbalances tolerate higher configured cost. When coverage is already inside the configured bounds, the USDC → BRLA → USDC flow may still run opportunistically, but only if its projected route cost is below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). `REBALANCING_HARD_MAX_COST_BPS` remains a hard projected-cost cap in every mode. `REBALANCING_DAILY_BRIDGE_LIMIT_USD` caps non-profitable fresh Base runs, but a quote that projects profit may bypass the daily cap while still being recorded in history after completion. When a separate profitable USDC → BRLA → USDC amount is configured, the flow evaluates that larger amount with its own fresh quotes and executes it only when that larger quote projects profit. **Current implementation:** Three rebalancing paths: @@ -41,6 +41,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - `REBALANCING_MAX_COST_BPS_MILD` / `REBALANCING_MAX_COST_BPS_MODERATE` / `REBALANCING_MAX_COST_BPS_SEVERE` — maximum projected round-trip cost per urgency band. - `REBALANCING_HARD_MAX_COST_BPS` — final projected-cost ceiling enforced even in `always` mode. - `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` — maximum projected route cost for in-range opportunistic USDC → BRLA → USDC execution (default 10 bps). +- `REBALANCING_USD_TO_BRL_AMOUNT` / `REBALANCING_PROFITABLE_USD_TO_BRL_AMOUNT` — standard and projected-profitable USDC → BRLA → USDC sizing. The profitable amount defaults to the standard amount when unset and is used only when a fresh quote for that larger size projects profit. --- @@ -66,7 +67,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- **Daily bridge limit:** Total requested USDC amount recorded by Base-flow history per calendar day (UTC), including the amount about to be rebalanced, must not exceed `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) for paid current runs. Profit is inferred from projected output USDC greater than input USDC, which also yields negative projected cost bps. Projected-profitable current runs bypass the cap entirely. For paid runs, the limit decision is checked against both `UsdcBaseStateManager` and `BrlaToUsdcBaseStateManager` history after quote/cost-policy evaluation and before any fresh Base state write or transaction. Completed profitable runs still write normal history entries, so they remain visible in later paid-run daily accounting. -**Urgency-band policy:** Before any state write or transaction, the flow quotes the expected round-trip USDC output. Projected cost is `(input USDC - projected output USDC) / input USDC` in basis points. `auto` mode executes only when the projected cost is within the configured limit for the current coverage-deviation band. `dry-run` logs the same decision but never starts a rebalance. `off` skips without quoting. `always` can execute above the band limit, but not above `REBALANCING_HARD_MAX_COST_BPS`; the daily bridge limit still applies unless the quote projects profit. +**Urgency-band policy:** Before any state write or transaction, the flow quotes the expected round-trip USDC output. Projected cost is `(input USDC - projected output USDC) / input USDC` in basis points. `auto` mode executes only when the projected cost is within the configured limit for the current coverage-deviation band. `dry-run` logs the same decision but never starts a rebalance. `off` skips without quoting. `always` can execute above the band limit, but not above `REBALANCING_HARD_MAX_COST_BPS`; the daily bridge limit still applies unless the quote projects profit. Standard and profitable configured amounts are quoted independently when they differ; stale standard-amount quotes must not be reused for larger execution. **Rebalancing flow:** 1. Check initial USDC balance on Base (sufficient for requested amount) @@ -154,15 +155,16 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- 14. **Mild/moderate imbalances MUST be skippable when cost exceeds tolerance** — In `auto` mode, fresh Base rebalances must skip when projected round-trip cost exceeds the configured limit for the current band. 15. **Opportunistic in-range rebalances MUST stay below the configured projected-cost cap** — When coverage is already inside `[lowerBound, upperBound]`, only USDC → BRLA → USDC may run opportunistically. It must use the normal cost-policy quote, daily-limit/profit decision, Base USDC balance check, route selection, hard max-cost cap, and state machine; it must skip when projected route cost is greater than or equal to `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). If an opportunistic Avenia route later falls back to SquidRouter, the preflight SquidRouter quote must independently satisfy the normal cost policy, the configured opportunistic cap, and the profitable-quote requirement when the original current quote skipped the daily bridge limit because it was projected profitable. 16. **Severe imbalances MAY use higher tolerance but MUST NOT bypass hard cost caps** — Severe band can permit higher projected cost, but it cannot bypass `REBALANCING_HARD_MAX_COST_BPS`, balance checks, slippage limits, or phase safety checks. It also cannot bypass the daily bridge limit unless the selected quote projects profit. -17. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, that route is still quoted and cost-gated before execution. -18. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. -19. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. -20. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. -21. **SquidRouter source transactions MUST be receipt-gated before Axelar polling** — On resume, a persisted Polygon SquidRouter swap hash must be checked on Polygon before Axelar polling starts. Before retrying a failed or missing SquidRouter swap, the flow must first check whether the expected Base USDC delta already arrived from the previous attempt. If not recovered and the source receipt failed, the stale hash/quote must be cleared and the flow must request a fresh SquidRouter route instead of waiting for an Axelar execution that can never occur. -22. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia BRLA arrival checks allow a 95% tolerance for provider-side deductions, while Base/Polygon on-chain arrival checks use the default 99.8% tolerance for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. -23. **SquidRouter swaps MUST require available Polygon BRLA before submission** — Before requesting and submitting a fresh SquidRouter Polygon BRLA → Base USDC swap, the flow must verify the Polygon account still holds at least the BRLA amount selected for the swap. If the balance is insufficient and Base USDC recovery does not prove completion, the flow MUST throw instead of submitting an inevitably failing transaction. -24. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. -25. **Terminal Avenia ticket failures MUST NOT poll indefinitely** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. `PARTIAL-FAILED` is surfaced as a retryable ticket-specific status so the SquidRouter BRLA-to-Polygon branch can reconcile partial arrival and create a replacement ticket only for the remaining amount. +17. **Profitable-size execution MUST use matching fresh quotes** — The high-coverage flow may switch from `REBALANCING_USD_TO_BRL_AMOUNT` to `REBALANCING_PROFITABLE_USD_TO_BRL_AMOUNT` only after the profitable amount's own quote projects profit. It must not infer larger-size profitability, route selection, or daily-limit bypass from the standard amount's quote. Manual CLI amounts bypass this automatic up-sizing. +18. **Route comparison MUST handle provider failures gracefully** — If every enabled return route quote fails, the high-coverage flow MUST abort (not proceed with zero information). If some routes fail, the best available route is used. If `--route=` is specified, that route is still quoted and cost-gated before execution. +19. **Avenia fallback to SquidRouter MUST be atomic in state** — If Avenia ticket creation fails, the flow sets `winningRoute = "squidrouter"` and `currentPhase = AveniaTransferToPolygon` in a single `saveState()` call. A crash between the failure and the save could leave the flow in an inconsistent state. +20. **NonceManager MUST be re-initialized on resume** — The `NonceManager` is created fresh at the start of each execution from `getTransactionCount()`. On resume, it must not reuse stale nonces from a previous execution. +21. **Axelar cross-chain execution MUST have a timeout** — SquidRouter's Axelar polling has a 30-minute timeout. If Axelar does not confirm execution within this window, the flow MUST throw (not poll indefinitely). This resolves F-034 for the Base flow. +22. **SquidRouter source transactions MUST be receipt-gated before Axelar polling** — On resume, a persisted Polygon SquidRouter swap hash must be checked on Polygon before Axelar polling starts. Before retrying a failed or missing SquidRouter swap, the flow must first check whether the expected Base USDC delta already arrived from the previous attempt. If not recovered and the source receipt failed, the stale hash/quote must be cleared and the flow must request a fresh SquidRouter route instead of waiting for an Axelar execution that can never occur. +23. **Balance arrival checks MUST be delta-based** — The Base high-coverage flow persists pre-action balances before each arrival-producing operation and waits for `starting balance + expected delta` rather than checking absolute hot-wallet/provider balances. Avenia BRLA arrival checks allow a 95% tolerance for provider-side deductions, while Base/Polygon on-chain arrival checks use the default 99.8% tolerance for rounding, route deductions, and minor quote shortfalls without sweeping unrelated leftover balances into the current run. The actual received Base USDC delta is persisted before advancing to final verification. +24. **SquidRouter swaps MUST require available Polygon BRLA before submission** — Before requesting and submitting a fresh SquidRouter Polygon BRLA → Base USDC swap, the flow must verify the Polygon account still holds at least the BRLA amount selected for the swap. If the balance is insufficient and Base USDC recovery does not prove completion, the flow MUST throw instead of submitting an inevitably failing transaction. +25. **`EVM_ACCOUNT_SECRET` derives the same address on all EVM chains** — A single BIP-39 mnemonic is used for Base, Polygon, and Moonbeam. This means compromise of this one secret drains the rebalancer on ALL EVM chains. `PENDULUM_ACCOUNT_SECRET` is separate and legacy-only. +26. **Terminal Avenia ticket failures MUST NOT poll indefinitely** — `checkTicketStatusPaid` treats `FAILED` as terminal and throws immediately instead of retrying until timeout. `PARTIAL-FAILED` is surfaced as a retryable ticket-specific status so the SquidRouter BRLA-to-Polygon branch can reconcile partial arrival and create a replacement ticket only for the remaining amount. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/SPEC-DELTA-2026-05.md b/docs/security-spec/SPEC-DELTA-2026-05.md index 192da7631..f41673284 100644 --- a/docs/security-spec/SPEC-DELTA-2026-05.md +++ b/docs/security-spec/SPEC-DELTA-2026-05.md @@ -278,19 +278,19 @@ Priority order for the next audit/dev cycle, based on severity × likelihood. Re | 10 | **F-NEW-09** — Investigate BRLA payout recovery branches. | NO BUG — once `payOutTicketId` exists, BRLA acknowledged the EVM payout; on-chain receipt is no longer authoritative. | | 11 | **F-NEW-10** — Avenia anchor-fee assumption in three-amount model. | NO BUG — `OffRampMergeSubsidyEvmEngine` adds the projected subsidy into `nablaSwapEvm.outputAmountRaw`, and `OffRampFinalizeEngine` then sets `quote.outputAmount = nablaSwapEvm.outputAmountDecimal − anchorFee`. The relationship `nablaSwapEvm.outputAmountRaw ≥ quote.outputAmount × 10^brlaDecimals` is therefore tautological at quote-build time. The actual safety net is the EVM branch of `subsidize-post-swap-handler.ts`, which tops the ephemeral up to `nablaSwapEvm.outputAmountRaw` at runtime using env-configured split caps for swap discrepancy and discount subsidy. No build-time assertion needed. | | 12 | **F-NEW-05** — Add Base ephemeral cleanup. | RESOLVED — `BaseChainPostProcessHandler` sweeps BRLA and USDC residuals after `currentPhase === "complete"` via presigned `approve` + funding-key `transferFrom`. Wired into both `evm-to-brl-base.ts` (offramp) and `avenia-to-evm-base.ts` (onramp). New phase keys `baseCleanupBrla` and `baseCleanupUsdc`. ETH gas dust on EVM ephemerals remains unswept (intentional). | -| 13 | **F-013** — Multiple security-sensitive endpoints have no authentication. | RESOLVED — dual-track auth wired across all `/v1/ramp/*` and `/v1/ramp/quotes(/best)` endpoints. Each request carrying credentials must present **either** `X-API-Key: sk_*` (partner SDK) **or** `Authorization: Bearer ` (Supabase frontend); invalid credentials are always rejected. Per-principal ownership guards (`assertRampOwnership`, `assertQuoteOwnership`) prevent cross-tenant access: partners are scoped via `RampState.quoteId → QuoteTicket.partnerId`, Supabase users via `RampState.userId`. Anonymous access is permitted only on register/update/start/status/errors and only when the underlying resource is fully anonymous (no partner, no user owner); `getRampHistory` always requires credentials. `enforcePartnerAuth()` is active on `/quotes` and `/quotes/best`, closing the partner-spoofing vector. | +| 13 | **F-013** — Multiple security-sensitive endpoints have no authentication. | RESOLVED — dual-track auth wired across all `/v1/ramp/*` and `/v1/ramp/quotes(/best)` endpoints. Each request carrying credentials must present **either** `X-API-Key: sk_*` (partner SDK) **or** `Authorization: Bearer ` (Supabase frontend); invalid credentials are always rejected. Per-principal ownership guards (`assertRampOwnership`, `assertQuoteOwnership`) prevent cross-tenant access: partners are scoped via `RampState.quoteId → QuoteTicket.partnerId`, Supabase users via `RampState.userId`. `POST /v1/ramp/register` and `GET /v1/ramp/history/:walletAddress` always require credentials; update/start/status/errors keep optional auth only for legacy fully-anonymous ramps whose ownership checks allow access. `enforcePartnerAuth()` is active on `/quotes` and `/quotes/best`, closing the partner-spoofing vector. | --- ## 6. Auth Posture (Post-Delta) -The dual-track auth model — partner SDK key OR Supabase user session — is the canonical model going forward. Anonymous access is permitted **only** on register/update/start/status/errors endpoints, and **only** when the underlying quote/ramp is itself fully anonymous (no `partnerId` and no `userId`). Owned resources always require matching credentials. +The dual-track auth model — partner SDK key OR Supabase user session — is the canonical model going forward. `POST /v1/ramp/register` now requires credentials because ramp creation derives provider identity from the effective user. Anonymous access is permitted only on update/start/status/errors endpoints, and only when the underlying ramp is itself fully anonymous (no `partnerId` and no `userId`). Owned resources always require matching credentials. | Endpoint | Auth | Owner check | |---|---|---| | `POST /v1/ramp/quotes` | `apiKeyAuth({required: false})` + `enforcePartnerAuth()` | Partner key, if present, must match `partnerId` in body | | `POST /v1/ramp/quotes/best` | `apiKeyAuth({required: false})` + `enforcePartnerAuth()` | Same as above | -| `POST /v1/ramp/register` | `optionalPartnerOrUserAuth()` | `assertQuoteOwnership(req, quoteId)` — anonymous caller allowed iff quote has `partnerId === null AND userId === null` | +| `POST /v1/ramp/register` | `requirePartnerOrUserAuth()` | `assertQuoteOwnership(req, quoteId)` + service effective-user check; anonymous quotes may be claimed by the registering principal (provider identity derives from the claimer's KYC records) | | `POST /v1/ramp/update` | `optionalPartnerOrUserAuth()` | `assertRampOwnership(req, rampId)` — anonymous caller allowed iff ramp has `userId === null` AND its quote has `partnerId === null` | | `POST /v1/ramp/start` | `optionalPartnerOrUserAuth()` | `assertRampOwnership(req, rampId)` — same condition as update | | `GET /v1/ramp/:id` | `optionalPartnerOrUserAuth()` | `assertRampOwnership(req, id)` — same condition as update | @@ -300,6 +300,6 @@ The dual-track auth model — partner SDK key OR Supabase user session — is th | `/v1/maintenance/*` | `adminAuth` | n/a | | `/v1/webhook/*` | `apiKeyAuth` | Partner ownership | -`optionalPartnerOrUserAuth()` accepts a request with no credentials, but a request that *presents* invalid credentials (malformed `X-API-Key` or expired/forged Bearer) is still rejected with 401. The downstream ownership checks then decide whether the resource is reachable: anonymous callers are admitted only for fully-anonymous quotes/ramps. This preserves the principle that owned resources are never reachable without matching credentials, while allowing API clients without keys (or first-time users without a Supabase session) to drive a ramp end-to-end. +`optionalPartnerOrUserAuth()` accepts a request with no credentials, but a request that *presents* invalid credentials (malformed `X-API-Key` or expired/forged Bearer) is still rejected with 401. The downstream ownership checks then decide whether the resource is reachable: anonymous callers are admitted only for legacy fully-anonymous ramps on the optional endpoints. Ramp registration itself is credential-gated. -Frontend uses `Authorization: Bearer` (Supabase). SDK partners use `X-API-Key: sk_*`. SDK clients without keys may operate against fully-anonymous quotes (no partner-rate benefits). Both authenticated principals grant equal access subject to per-principal ownership scoping. +Frontend uses `Authorization: Bearer` (Supabase). SDK partners use `X-API-Key: sk_*`. SDK clients without keys may request anonymous quote estimates on every corridor (Alfredpay quotes carry only a tracking-metadata customer id), but must authenticate before registering a ramp. Both authenticated principals grant equal access subject to per-principal ownership scoping. diff --git a/docs/test-audit-findings.md b/docs/test-audit-findings.md new file mode 100644 index 000000000..d19e5190b --- /dev/null +++ b/docs/test-audit-findings.md @@ -0,0 +1,438 @@ +# Existing-Test Audit Findings (2026-07-05) + +Every existing test file was audited for correctness defects; each finding below was +independently re-verified by an adversarial reviewer before being confirmed (5 further +claims were rejected at that stage). Findings marked ✅ were fixed on the +`test-suite-foundation` branch; the rest are catalogued for follow-up. + +Severity reflects impact on trustworthiness of the suite, not production risk. + +## Remediation status (2026-07-05, branch test-suite-foundation) + +Fixed in this branch: +- ✅ All process-wide `mock.module` / singleton patches without restore (maintenanceGuard, + nabla-swap-handler, squid-router-phase-handler, quote nabla-swap/base-evm, priceFeed.service, + quote squidrouter/index, ephemeral-freshness, transactions common+avenia-to-evm-base, + webhook.service, cleanup.worker): real modules are captured as value copies before mocking + and restored in afterAll. +- ✅ Live integration tests' module-level patching gated behind RUN_LIVE_TESTS. +- ✅ `bun test` no longer discovers stale compiled test copies in dist/ (bunfig test root=src). +- ✅ crypto.test.ts injects keys via the config snapshot instead of inert process.env writes. +- ✅ apiKeyAuth.helpers.test.ts fixture no longer issues live INSERTs (stubbed update()). +- ✅ webhook.service.test.ts validates quoteId against QuoteTicket (was stale RampState mock); + dead crypto/randomBytes mock removed. +- ✅ dualAuth.test.ts renamed to ownershipAuth.test.ts. +- ✅ cleanup.worker.test.ts neutralizes the CronJob runOnInit cleanup cycle that fired real DB + queries on construction. +- ✅ webhook-delivery.service.test.ts rewritten against current production behavior (was + quarantined): real RSA-PSS signing via the cryptoService singleton with a verifySignature + round-trip, webhookService methods patched on the instance and restored in afterAll (no + mock.module), per-test fetch stubs with restore, real timers with 1ms backoff. Resolves the + cannot-fail (line 47), stale-or-dead (line 60), and both wrong-assertion (lines 157, 414) + findings below. + +Fixed in the second remediation pass (2026-07-05): +- ✅ The 6 quarantined EUR-onramp cases in transactions/validation.test.ts rebuilt on the live + Base BRL-onramp corridor (nablaApprove/squidRouterSwap/destinationTransfer on Networks.Base, + chainId 8453) and un-skipped; this also exercises the polymorphic nabla-on-Base=EVM mapping + that broke the old fixture. The cannot-fail "matches a signed EVM transaction..." test was + deleted (fully subsumed by the neighboring calldata-differences test). +- ✅ clientIp.test.ts: uses a non-loopback request IP (no more real ipify call) and asserts the + exact normalized value. +- ✅ priceFeed.service.test.ts: "without API key" test now controls the key on the instance and + asserts absence of the real header (x-cg-pro-api-key), plus a positive-presence companion; + exported-singleton caches cleared in beforeEach (order-independence); duplicate + "default values" config test deleted (its env deletion was inert). +- ✅ brla-onramp-hold.test.ts: missing-ticket case asserts updateState was not called instead of + re-asserting the fixture's initial value. +- ✅ squid-router-phase-handler.test.ts: Monerium fixture uses network=Base (BUY quote.network is + the destination by construction) and asserts the pre-settlement snapshot on (Base, USDC). +- ✅ ramp.service.register-auth.test.ts: no-effective-user test pins the guard's message so a + later unrelated 400 can't satisfy it. +- ✅ discount/helpers.test.ts: mislabeled "negative targetDiscount (rate floor)" block replaced + with genuine calculateExpectedOutput coverage (negative/positive discount, offramp inversion). +- ✅ webhook.service.test.ts: not-found test pins status 404 + message; registration-error test + resolves the quote so the rejection genuinely comes from Webhook.create (pins 500); orphaned + randomBytes mock removed. +- ✅ base.service.test.ts: sequelize/QuoteTicket singleton patches restored in afterAll. +- ✅ vars.test.ts: FLOW_VARIANT added to the required production env; subprocesses run with + cwd=os.tmpdir() so the developer's .env can no longer backfill missing variables. +- ✅ rebalancer config.test.ts: REBALANCING_DAILY_BRIDGE_LIMIT_USD added to the scrubbed env list. +- ✅ phase-processor.onramp.integration.test.ts: registerRamp now passes a userId, ephemeral keys + renamed to EVM/Substrate (they were silently dropped before), updateRamp reordered before + startRamp, vestigial ../brla/helpers mock deleted. +- ✅ phase-processor.recovery.integration.test.ts: loads the fixture from + failedRampStateRecovery.json with fail-fast validation, and polls currentPhase asserting + "complete" instead of an unconditional 50-minute sleep with zero expects. +- ✅ xcm/assethubToMoonbeam: dry-run test now asserts the Result is Ok and local execution + succeeded; the inert assetAccountKey parameter was dropped from the production function + (the asset is hardcoded to USDT on AssetHub). +- 🗑 xcm/moonbeamToAssethub.test.ts deleted: it targeted + createMoonbeamToAssethubTransferWithSwapOnHydration, whose own doc comment says the resulting + XCM cannot work on Moonbeam; the dead production function was left in place (flagged, not removed). +- 🗑 frontend phaseFlows.test.ts deleted: it compared PHASE_FLOWS to a verbatim copy of itself + (typos are already caught at compile time by the `as RampPhase[]` casts; backend parity was + never actually checked). +- 🗑 frontend translations/helpers.test.ts "Extensibility Example" block deleted: both tests + asserted properties of local objects they had just built; the real extraction path stays + covered by the getBrowserLanguage tests. + +Skipped with pointer (blocked on a product decision, not fixable in tests): +- ⏸ The two Mykobo EUR registration contract tests (mykobo-eur-offramp/onramp + .integration.test.ts) are it.skip'd: registerRamp unconditionally rejects EURC quotes with + 503 "EUR ramps are currently disabled" (commit be52569e4). Re-enable when EUR ramps return + or a test bypass for the guard exists. + +All findings below are now remediated. The full apps/api suite passes as one process: +317 pass / 12 skip / 0 fail. The tracked frontend suite passes 88/88. + + +## HIGH + +### `apps/api/src/api/middlewares/maintenanceGuard.test.ts:13` — isolation-hazard + +Four mock.module() calls (lines 13, 28, 34, 43) replace '../observability/apiClientEvent.service', '../controllers/quote.controller', '../controllers/ramp.controller', and '../services/auth' and are never restored. Bun runs all test files in one process and mock.module replaces the ENTIRE export set for the rest of the process. Verified empirically: after this file runs, sanitizeApiClientEvent and recordApiClientEventSafe become undefined in the module registry, getSafeApiKeyPrefix loses its pk_/sk_ validation (returns a 16-char slice of ANY string), quote/ramp controllers become 418-teapot stubs, and SupabaseAuthService.verifyToken always returns {valid:false}. Concretely, running this file together with src/api/observability/apiClientEvent.service.test.ts makes the latter fail to even load: "SyntaxError: Export named 'sanitizeApiClientEvent' not found in module .../apiClientEvent.service.ts" (reproduced in both CLI orderings; bun executes maintenanceGuard.test.ts first). Any other suite-wide run that includes both files fails. The module-level observedEvents array (line 10) also keeps collecting events from later files' code that calls the mocked observeApiClientEvent. + +**Suggested fix:** Capture the real modules with `import * as realSvc from '../observability/apiClientEvent.service'` (etc.) before mocking, and re-register them in afterAll via `mock.module(path, () => realSvc)`. Alternatively avoid mock.module entirely: patch MaintenanceService only (already done) and spy on observeApiClientEvent via a restorable instance/property patch, or move this route-level test into its own isolated process/run. + +### `apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts:42` — isolation-hazard + +mock.module("@vortexfi/shared", ...) replaces the entire shared package process-wide with a stub missing most real exports (FiatToken, AveniaTicketStatus, getOnChainTokenDetails, etc.) and is never restored (no afterAll; bun module mocks persist across test files in the single test process). Empirically demonstrated: all five audited files pass individually, but `bun test` over the phases handler/helper directories in default discovery order fails 2 tests — squid-router-phase-handler.test.ts errors with "Export named 'FiatToken' not found" (its production import chain via quote/utils.ts resolves against this stub) and brla-onramp-hold.test.ts errors with "Export named 'AveniaTicketStatus' not found". The mock.module("../../ramp/ramp.service") at line 77 has the same unrestored-global problem, gutting ramp.service to a default export with only appendErrorLog for every later test file. + +**Suggested fix:** Build the mock factory by spreading the actual module and overriding only what the test needs, e.g. `const actual = await import("@vortexfi/shared"); mock.module("@vortexfi/shared", () => ({ ...actual, checkEvmBalanceForToken, EvmClientManager: ... }))`, and do the same for ramp.service, so un-overridden exports (FiatToken, AveniaTicketStatus, ...) remain intact for other files in the process. + +### `apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts:62` — isolation-hazard + +Same unrestored process-global mock.module("@vortexfi/shared") pattern with an incomplete stub (no AveniaTicketStatus, no real FiatToken values, etc.). Demonstrated pairwise: running this file then brla-onramp-hold.test.ts in one bun process makes brla-onramp-hold.test.ts fail to load ("Export named 'AveniaTicketStatus' not found in module .../packages/shared/dist/node/index.js"), because brla-onramp-hold.ts imports AveniaTicketStatus from @vortexfi/shared and resolves against this stub. This file is also itself a victim of the identical leak from nabla-swap-handler.test.ts: in default discovery order (nabla* sorts before squid*), this file's tests error out entirely with "Export named 'FiatToken' not found", so it cannot pass in a combined run today. mock.module("../../ramp/ramp.service") at line 102 has the same problem. + +**Suggested fix:** Spread the actual @vortexfi/shared module in the mock factory and override only the handful of functions the test controls (checkEvmBalanceForToken, EvmClientManager, getEvmBalance, getOnChainTokenDetails, evmTokenConfig), keeping real enums/constants; same for ramp.service. + +### `apps/api/src/api/services/phases/mykobo-eur-offramp.integration.test.ts:116` — isolation-hazard + +Module-scope monkeypatching without restore, executed on EVERY `bun test` run even when the describe is skipped (describe.skipIf gates only the tests, not top-level code): RampState.update/findByPk/create (116-144), QuoteTicket.findByPk/update/create (146-168), BrlaApiService.getInstance (186), RampRecoveryWorker.prototype.start (188), plus process-global mock.module of ../quote/core/nabla (10) and ../mykobo/mykobo-customer.service (35). Bun runs all test files in one process, so these bleed into later files: mykobo-customer.service.test.ts tests the real resolveMykoboCustomerForUser, which this file replaces with a stub returning mail@test.com; ramp.service.register-auth.test.ts captures QuoteTicket.findByPk as its 'original' in afterEach and would capture and restore the poisoned mock. The file also writes lastRampStateMykoboEur.json into the src tree on every model write (gitignored/documented, but still a repo-dir side effect). + +**Suggested fix:** Move all patching into beforeAll inside the skipIf-gated describe, capture originals, and restore them (and the module mocks) in afterAll. + +### `apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts:191` — stale-or-dead + +registerRamp is called without userId and the quote is created without a user, so effectiveUserId resolves to undefined and registerRamp always throws 'Invalid quote: this route requires an API key linked to a user or Supabase user authentication.' (ramp.service.ts:216-221). The test cannot get past registration when actually run (RUN_LIVE_TESTS=1); everything after line 195, including the completion assertions at lines 232-233, is unreachable. Production has diverged from this test (userId is now mandatory; the newer mykobo tests pass TEST_USER_ID). + +**Suggested fix:** Pass a userId to registerRamp (as mykobo-eur-*.integration.test.ts do) and stub whatever user/Avenia-account resolution the BRL onramp path requires (resolveAveniaAccountForRamp). + +### `apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts:83` — stale-or-dead + +testSigningAccounts uses keys 'moonbeam' and 'pendulum', cast to EphemeralAccountType at line 91. The enum values are 'Substrate' and 'EVM' (packages/shared/src/endpoints/ramp.endpoints.ts:69-72), and normalizeAndValidateSigningAccounts (ramp.service.ts:135-146) matches case-insensitively against those values and SILENTLY DROPS non-matching entries. Both accounts are discarded, so even with a valid userId, registration would fail with 'Base ephemeral not found' / missing ephemeral. The 'as EphemeralAccountType' cast hides this from the type checker. This is a second, independent reason the test is dead. + +**Suggested fix:** Rename the keys to EVM/Substrate (matching EphemeralAccountType) as the mykobo integration tests do, and drop the unchecked cast. + +### `apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts:19` — isolation-hazard + +mock.module("@vortexfi/shared", ...) (and the mock.module calls at lines 33, 41, 47 for core/nabla, priceFeed.service, and config/logger) is never restored. Bun module mocks persist for the entire test process, and the factory replaces the shared package's Networks with only {Base} and EvmToken with only {BRLA, USDC}. Any test file loaded after this one sees the gutted module. This is not theoretical: `cd apps/api && bun test src/api/services/quote/` currently fails 2 tests in finalize/onramp.test.ts with 'APIError: Invalid EVM destination network' because Networks.BSC and EvmToken.USDT resolve to undefined after this file's mock is registered. The unrestored priceFeed.service mock (only getOnchainOraclePrice) similarly poisons any later file that calls priceFeedService.convertCurrency/convertCurrencyOrNull on the real singleton. + +**Suggested fix:** Avoid mock.module for the whole @vortexfi/shared package: import the real shared module and use the real EvmToken/Networks/RampDirection/getOnChainTokenDetails (they are pure config), and stub only calculateNablaSwapOutputEvm and priceFeedService.getOnchainOraclePrice via property monkeypatching with afterEach restore (the pattern already used in finalize/index.test.ts and alfredpay-auth.test.ts). If mock.module must stay, capture the original exports with `import * as actual` and re-register them (mock.module(spec, () => actual)) in afterAll. + +### `apps/api/src/api/services/transactions/validation.test.ts:139` — stale-or-dead + +The EUR-onramp fixtures (VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP, lines 138-142, and VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP, lines 144-148) place phase "nablaApprove" on Networks.Polygon. Since the polymorphic-phase refactor (commit 1b71402a8), getTransactionTypeForPhase in validation.ts (lines 189-196) classifies nabla/distributeFees/subsidize phases as Substrate unless network === Base, so validateSubstrateTransaction rejects the fixture with "Substrate transaction signer 0xFCAd... does not match the expected signer for phase nablaApprove" (the Substrate ephemeral is ""). Verified by running `bun test src/api/services/transactions/validation.test.ts`: 6 of 46 tests currently FAIL — "should pass validation for valid presigned EVM transactions" (line 387), "should pass validation for single valid presigned transaction" (line 393), "should throw when an ephemeral transaction is missing backup transactions" (line 448, throws but with the wrong error — the asserted backup-validation message is unreachable because tx[0] fails first), "should throw when backup transaction nonces are not sequential" (line 459, same), "accepts a subset of presigned txs when requireComplete is false" (line 1060), and "still rejects subset submissions by default" (line 1068). CI runs `cd apps/api && bun test`, so these block the suite. + +**Suggested fix:** Regenerate the EUR fixture to match the current phase→signer-type mapping: put the nablaApprove tx on Networks.Base (sign with chainId 8453) with a matching unsigned counterpart, or drop nablaApprove from the fixture and anchor the backup-transaction tests on the squidRouterApprove/squidRouterSwap entries (EVM on Polygon is still valid for those phases on BUY). + +### `apps/api/src/api/services/webhook/__tests__/webhook-delivery.service.test.ts:47` — cannot-fail + +The global setTimeout mock returns a dummy id and never invokes the callback, so deliverWithRetry's backoff (`await new Promise(resolve => setTimeout(resolve, delay))`) never resolves. Verified by running: 7 of 10 tests time out at the per-test timeout, and the final test ('should handle network errors gracefully') hangs past bun's own timeout and wedges the entire `bun test` process (had to be SIGKILLed, exit 144). Only the two 'do nothing when no webhooks are found' tests pass. None of these tests can ever pass; they also block any full-suite run. This goes unnoticed because .github/workflows/ci.yml never runs `bun test`. + +**Suggested fix:** Make the setTimeout mock invoke callbacks immediately (or leave the retry sleep unmocked and shrink retryDelays via DI), and fix the signing setup (see the crypto/HMAC finding) so delivery actually reaches fetch. + +### `apps/api/src/api/services/webhook/__tests__/webhook-delivery.service.test.ts:60` — stale-or-dead + +The file mocks node crypto's createHmac and gives webhooks a `secret` field, but production (commit 80bf43e5e) signs with RSA-PSS via cryptoService.signPayload (config/crypto.ts) — there is no HMAC and no per-webhook secret (webhook.model.ts has no secret column). Since cryptoService.initializeKeys() is only called in src/index.ts, signPayload throws 'RSA keys not initialized' in the test process, deliverWebhook returns false before fetch is ever called, and every assertion about fetch calls, URLs, headers and payloads is unreachable. Combined with the setTimeout mock this is why the tests hang. + +**Suggested fix:** Drop the createHmac mock and webhook `secret` fixtures; either call cryptoService.initializeKeys() in setup or mock ../../../../config/crypto's signPayload to return a fixed base64 string. + +### `apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts:61` — stale-or-dead + +The test mocks ../../../../models/rampState.model.findByPk to validate quoteId, but production webhook.service.ts:59 validates via QuoteTicket.findByPk (changed in commit b892fe2cf 'transactionId is QuoteTicket'); rampState.model is not imported by the service at all. Verified by running: 'should register a webhook with quoteId' fails (assertion at line 108 that rampStateFindByPkMock was called, which never happens) and the unmocked QuoteTicket model issues a real database query from a unit test, producing a generic 500 APIError. + +**Suggested fix:** Replace the rampState.model mock with mock.module('../../../../models/quoteTicket.model', () => ({ default: { findByPk: quoteTicketFindByPkMock } })) and update the assertions accordingly. + +### `apps/api/src/config/crypto.test.ts:31` — other + +Always-failing test (verified: fails on `bun test src/config/crypto.test.ts`). It sets process.env.WEBHOOK_PRIVATE_KEY at test time, but CryptoService.initializeKeys() reads config.secrets.webhookPrivateKey (crypto.ts:28), and config/vars.ts snapshots process.env at module import — long before the test body runs. initializeKeys therefore derives the public key from the developer's .env key (or generates a random pair when unset), never from the test-generated private key, so the equality assertion at line 42 cannot pass on any machine. + +**Suggested fix:** Inject the key instead of touching process.env: mock.module('./vars', ...) with a controlled secrets.webhookPrivateKey before importing ./crypto, or refactor initializeKeys to accept the PEM as a parameter. + + +## MEDIUM + +### `apps/api/src/api/helpers/clientIp.test.ts:20` — isolation-hazard + +The test 'adds the normalized request IP when additional data does not include one' calls enrichAdditionalDataWithClientIp with { ip: "::1" }, which normalizes to loopback 127.0.0.1. Because the test preload (apps/api/src/test-utils/preload.ts) forces DEPLOYMENT_ENV=test, the production code's non-production branch runs fetchHostPublicIp(), issuing a REAL outbound HTTPS request to the hardcoded https://api.ipify.org?format=json. Verified empirically: instrumenting globalThis.fetch during this exact call shows the request firing and the returned ipAddress being the host machine's real public IP. This violates the repo's explicit hermetic-test policy in preload.ts ('no test can accidentally reach a real external service'), makes the test environment-dependent and up to ~2s slow (abort timeout) when the endpoint is unreachable, and caches the real public IP in module-level state (cachedHostPublicIp, 10-min TTL) that bleeds into any other test importing clientIp.ts in bun's single test process. + +**Suggested fix:** Avoid the loopback branch: use a non-loopback request IP, e.g. enrichAdditionalDataWithClientIp({ email: "user@example.com" }, { ip: "::ffff:203.0.113.42" }), and assert ipAddress === "203.0.113.42". If the loopback/public-IP-lookup branch itself needs coverage, stub globalThis.fetch (restoring it in afterEach) so no real network call occurs. + +### `apps/api/src/api/helpers/clientIp.test.ts:23` — cannot-fail + +expect(typeof additionalData?.ipAddress).toBe("string") is insensitive to the behavior the test is named after. resolvedIpAddress is set to "127.0.0.1" before the public-IP lookup and only overwritten if the lookup succeeds, so the assertion passes identically whether the ipify fetch succeeds (host's real public IP), fails ("127.0.0.1"), or normalizeClientIp is completely broken (any non-empty string). The 'normalized request IP' claimed by the test name is never actually asserted; the only regression it can catch is the ipAddress key being dropped entirely. The assertion was evidently weakened to tolerate the nondeterministic network result from the ipify call. + +**Suggested fix:** After removing the network dependency (see the line 20 finding), assert the exact expected value, e.g. expect(additionalData?.ipAddress).toBe("203.0.113.42") for input ip "::ffff:203.0.113.42". + +### `apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts:24` — isolation-hazard + +createSecretKeyRecord builds a REAL Sequelize instance (Object.assign(new ApiKey(), {...})) with isNewRecord=true and no stubbed update(). On every successful validation path, production validateSecretApiKey calls keyRecord.update({lastUsedAt}) fire-and-forget, which issues a live INSERT INTO api_keys against the database configured in apps/api/.env (127.0.0.1:54322). Verified by running the file: Postgres responds with 'null value in column "key_prefix" of relation "api_keys" violates not-null constraint' three times, proving the query reaches the real dev DB. The tests only stay green because the INSERT happens to violate a NOT NULL constraint and production swallows the error via .catch(logger.error); if the instance ever serialized fully (schema or fixture change), the unit tests would silently persist junk API-key rows into the developer/CI database. + +**Suggested fix:** Stub the persistence call on the fixture, e.g. add `update: mock(async () => keyRecord)` to the Object.assign payload (or use ApiKey.build({...}, {isNewRecord:false}) with a stubbed save), so no real DB traffic is possible. + +### `apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts:88` — isolation-hazard + +QuoteTicket.findByPk is monkeypatched at module top level on the real shared Sequelize model class (`QuoteTicket.findByPk = mock(async () => ({ metadata: { nablaSwapEvm: ... } }))`) and never restored. Because bun runs all test files in one process, every later test file that touches QuoteTicket.findByPk silently receives this stub returning a nablaSwapEvm quote instead of hitting its own fixture/DB. + +**Suggested fix:** Save the original (`const original = QuoteTicket.findByPk`) and restore it in afterAll, or use spyOn(QuoteTicket, "findByPk").mockImplementation(...) with mock.restore()/mockRestore() in afterAll. + +### `apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts:119` — isolation-hazard + +QuoteTicket.findByPk is monkeypatched at module top level (`QuoteTicket.findByPk = mock(async () => quote as any)`) and never restored; it also closes over the file-scoped mutable `quote` variable, so after this file runs, any later test file in the same bun process calling QuoteTicket.findByPk gets whatever quote fixture the last test here assigned. + +**Suggested fix:** Restore the original findByPk in afterAll, or use spyOn with mockRestore(). + +### `apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts:200` — wrong-assertion + +The Monerium onramp test builds an impossible fixture and enshrines its consequence. The quote sets `network: Networks.Polygon` (line 191) while declaring a BUY ramp with destination Base (`to: Networks.Base`, evmToEvm.toNetwork: Base). In production, quote.network for BUY is by construction the destination network (quote.controller.ts:36: `getNetworkFromDestination(rampType === BUY ? to : from)`; final-settlement-subsidy.ts:131 relies on exactly this). snapshotPreSettlementBalance intends to snapshot the ephemeral's destination-token balance (used to compute `delivered` in finalSettlementSubsidy), so for this route it must read Base USDC; the assertion `expect(getOnChainTokenDetails).toHaveBeenCalledWith(Networks.Polygon, EvmToken.USDC)` instead locks in a snapshot on the source chain. The test would keep passing if the handler read the wrong network field and would fail a correct refactor to bridgeMeta.toNetwork. + +**Suggested fix:** Set the fixture's `network: Networks.Base` (consistent with a BUY ramp to Base) and assert getOnChainTokenDetails was called with (Networks.Base, EvmToken.USDC). + +### `apps/api/src/api/services/phases/mykobo-eur-offramp.integration.test.ts:303` — stale-or-dead + +The 'registers a Base+USDC ramp and prepares the Mykobo phase set' test can never pass at HEAD. RampService.registerRamp unconditionally throws APIError 503 'EUR ramps are currently disabled' for any quote with EURC input or output (apps/api/src/api/services/ramp/ramp.service.ts:223-228, added in commit be52569e4 'disable euro flows'). The test creates a USDC->EURC SELL quote and calls registerRamp directly, so with RUN_LIVE_TESTS=1 it always fails before any of its assertions run. Because the suite is gated behind describe.skipIf(!RUN_LIVE_TESTS), this breakage is invisible in normal CI runs. + +**Suggested fix:** Either skip this test with an explicit reference to the EUR-disable guard (be52569e4) until EUR ramps are re-enabled, or make the guard bypassable for the sandbox contract test (e.g., env flag checked in registerRamp) so the test can exercise the Mykobo path again. + +### `apps/api/src/api/services/phases/mykobo-eur-onramp.integration.test.ts:304` — stale-or-dead + +Same defect as the offramp file: the 'registers a EUR->Base USDC onramp' test calls RampService.registerRamp with an EURC-input quote, but registerRamp unconditionally rejects EURC quotes with 'EUR ramps are currently disabled' (ramp.service.ts:223-228, commit be52569e4). With RUN_LIVE_TESTS=1 the test always throws before reaching its phase/state assertions (lines 315-348), so the entire Mykobo onramp registration contract is untested despite appearing covered. + +**Suggested fix:** Skip with an explanatory comment tied to the EUR-disable guard, or add a test-only bypass for the guard so the sandbox contract test remains executable. + +### `apps/api/src/api/services/phases/mykobo-eur-onramp.integration.test.ts:117` — isolation-hazard + +Identical unrestored module-scope patching as the offramp file (RampState/QuoteTicket statics at 117-169, BrlaApiService.getInstance at 187, RampRecoveryWorker.prototype.start at 189, mock.module of ../quote/core/nabla at 10 and ../mykobo/mykobo-customer.service at 35), executed even when the suite is skipped. Additionally, this file mocks ../quote/core/nabla with a 1.05 rate while the offramp file mocks the same module with 0.92 — whichever file loads last silently wins for any subsequent importer of the real module in the same bun process, making cross-file behavior order-dependent. Writes lastRampStateMykoboEurOnramp.json into the src tree as a side effect. + +**Suggested fix:** Same as offramp file: gate patching behind RUN_LIVE_TESTS in beforeAll and restore in afterAll; scope the nabla mock per-file lifetime. + +### `apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts:205` — stale-or-dead + +The test calls rampService.startRamp (line 205) BEFORE rampService.updateRamp with presignedTxs (line 224). The current startRamp requires presigned transactions to already be present and throws 'No presigned transactions found. Please call updateRamp first.' (ramp.service.ts:473-478). The call order enshrines an obsolete API contract; startRamp is also the only place that triggers phaseProcessor.processRamp, so with this ordering processing would never start even if startRamp did not throw. + +**Suggested fix:** Reorder to sign transactions, call updateRamp with presignedTxs, then call startRamp. + +### `apps/api/src/api/services/phases/phase-processor.recovery.integration.test.ts:9` — stale-or-dead + +RAMP_STATE_RECOVERY is an empty placeholder object ('{ // ... }'), so rampState has no id, currentPhase, or flowVariant. PhaseProcessor.processRamp then early-returns at the flow-variant guard (phase-processor.ts:45-50: state.flowVariant undefined !== config.flowVariant, which always resolves to 'monerium' or 'mykobo' per config/vars.ts:46-54) after only logging a warning. As committed, the test performs no recovery processing at all — it is a dead fixture that must be hand-edited to do anything. + +**Suggested fix:** Load the fixture from failedRampStateRecovery.json (the workflow CLAUDE.md documents) and fail fast with a clear error if the fixture is empty/missing, instead of silently no-opping. + +### `apps/api/src/api/services/phases/phase-processor.recovery.integration.test.ts:96` — cannot-fail + +The test contains zero expect() calls (expect is not even imported) and ends with an unconditional 3,000,000 ms sleep. processor.processRamp never throws for phase failures — it catches them internally and only logs (phase-processor.ts:75-77) — so recovery failure cannot surface through the try/catch either. Outcome depends solely on the runner timeout: under bun's default 5 s per-test timeout the test ALWAYS fails on timeout regardless of correctness; with the documented large --timeout it sleeps ~50 minutes and then ALWAYS passes regardless of whether the ramp recovered. The result never reflects the behavior under test. + +**Suggested fix:** Replace the fixed sleep with a poll loop on rampState.currentPhase (like waitForCompleteRamp in the onramp test) and assert the final phase is 'complete' (or at least not 'failed'). + +### `apps/api/src/api/services/phases/phase-processor.recovery.integration.test.ts:47` — isolation-hazard + +RampState.update/findByPk/create are monkeypatched at module scope (47-82) and mock.module permanently replaces ../../workers/ramp-recovery.worker (14-23); neither is restored, and both execute on every `bun test` run even though the describe is skipped without RUN_LIVE_TESTS. In bun's single-process test runner these patches persist into all later test files that use the real RampState statics or the recovery worker. The mocked update/findByPk also write failedRampStateRecovery.json into the src tree as a side effect (gitignored, but a repo-dir write). + +**Suggested fix:** Gate the patching behind RUN_LIVE_TESTS inside beforeAll, capture and restore the original statics in afterAll. + +### `apps/api/src/api/services/priceFeed.service.test.ts:8` — isolation-hazard + +mock.module("@vortexfi/shared", ...) (line 8), mock.module("./nablaReads/outAmount") (line 94), mock.module("./pendulum/apiManager") (line 111), mock.module("../../config/logger") (line 129), and mock.module("../../../index", () => ({})) (line 147) are registered at file scope and never restored (grep confirms no mock.restore anywhere in the file). Bun runs all test files in one process, so every apps/api test file that loads after this one and imports @vortexfi/shared receives the gutted stub instead of the real package. The stub is missing most real exports (FiatToken, MykoboApiService, mapMykoboReviewStatus, Networks, etc.) and replaces getPendulumDetails/isFiatToken/getTokenUsdPrice with fakes whose semantics diverge from production (e.g. isFiatToken returns true only for BRL/EUR/ARS, while the real FiatToken set is EURC/ARS/BRL/USD/MXN/COP). 29 api test files import @vortexfi/shared, many of which sort after this file (quote/**, ramp/**, transactions/**, webhook/**), plus the app entry module is mocked to {} for everyone. + +**Suggested fix:** Build the shared mock from the real module (const actual = await import("@vortexfi/shared"); mock.module("@vortexfi/shared", () => ({ ...actual, getTokenOutAmount: ..., getTokenUsdPrice: ... }))) so untouched exports stay real, and restore the overridden exports in afterAll (or run this file with test isolation / a preload). Drop the logger and ../../../index module mocks or restore them the same way. + +### `apps/api/src/api/services/priceFeed.service.test.ts:417` — cannot-fail + +"should work without API key" asserts headers do NOT contain "x-cg-demo-api-key", but production (priceFeed.service.ts line 120) sets "x-cg-pro-api-key" when a key exists. Since the code never sets "x-cg-demo-api-key" under any condition, expect.not.objectContaining({"x-cg-demo-api-key": ...}) passes whether or not an API key header is attached — the assertion is vacuous. Additionally, the test's premise is dead: delete process.env.COINGECKO_API_KEY (line 405) has no effect because the service reads config.priceProviders.coingecko.apiKey from config/vars, which captured the environment at import time. + +**Suggested fix:** Assert absence of the header the code actually sets ("x-cg-pro-api-key"), and control the key via the instance (e.g. Object.assign(serviceInstance, { coingeckoApiKey: undefined })) instead of process.env, mirroring how the TTL tests override cryptoCacheTtlMs. + +### `apps/api/src/api/services/priceFeed.service.test.ts:217` — isolation-hazard + +beforeEach/afterEach only reset the private static PriceFeedService.instance, but many tests run against the module-level exported singleton `priceFeedService`, whose cryptoPriceCache/fiatExchangeRateCache Maps are never cleared. Tests are therefore order-dependent: "should fetch price from CoinGecko API when cache is empty" (line 250) only sees an empty cache because it happens to run first; the "populate cache" call in the next test (line 262) is actually a cache hit left over from line 250; "should convert USD to crypto" (line 559) expects exactly 1 fetch and relies on no earlier test having cached ethereum:usd on the shared singleton. Reordering tests, or another test file using priceFeedService earlier in the same bun process, breaks the call-count assertions. The populated caches (bitcoin=50000, BRL rate 1.25, real Date.now + 300000ms TTL) also persist into any later test file that uses the exported singleton. + +**Suggested fix:** In beforeEach, also clear the exported singleton's caches ((priceFeedService as any).cryptoPriceCache.clear(); (priceFeedService as any).fiatExchangeRateCache.clear()), or run every test against a freshly-obtained instance instead of the module-level export. + +### `apps/api/src/api/services/quote/engines/squidrouter/index.test.ts:8` — isolation-hazard + +mock.module("../../core/squidrouter", ...) replaces the real core/squidrouter module with an object exposing only calculateEvmBridgeAndNetworkFee, and is never restored. getTokenDetailsForEvmDestination (used by finalize/onramp.ts and core/squidrouter consumers) disappears from the module registry for the rest of the process. Reproduced: `bun test src/api/services/quote/engines/squidrouter/index.test.ts src/api/services/quote/engines/finalize/onramp.test.ts` aborts the entire onramp test file with "SyntaxError: Export named 'getTokenDetailsForEvmDestination' not found in module .../core/squidrouter.ts". The full-directory run currently passes only because bun happens to load finalize/onramp.test.ts before this file; any new test file sorting after it that imports core/squidrouter, or a change in load order, breaks. + +**Suggested fix:** Mock only the one function while preserving the module's other exports: `import * as actualSquidrouter from "../../core/squidrouter"; mock.module("../../core/squidrouter", () => ({ ...actualSquidrouter, calculateEvmBridgeAndNetworkFee: mock(async () => ...) }))`, and restore the original exports in afterAll (mock.module with the actual namespace). + +### `apps/api/src/api/services/ramp/base.service.test.ts:34` — isolation-hazard + +Lines 34-38 monkeypatch the sequelize singleton (sequelize.transaction, sequelize.query) and three QuoteTicket statics (update, findAll, destroy) at module scope and never restore them (no afterAll). bun test runs all files in one process, so every test file loaded after this one sees a sequelize.query that returns [{acquired: true}] for ANY query and a sequelize.transaction mock that invokes its first argument as a callback (calling it without a callback, as BaseRampService.withTransaction does, throws 'callback is not a function'). Any later file exercising real DB paths or unmocked QuoteTicket statics silently runs against these fakes. + +**Suggested fix:** Capture the originals before patching and restore them in afterAll (or use spyOn with mockRestore), e.g. save sequelize.transaction/query and QuoteTicket.update/findAll/destroy at module top and reassign them in afterAll. + +### `apps/api/src/api/services/ramp/ephemeral-freshness.test.ts:14` — isolation-hazard + +mock.module("@vortexfi/shared", ...) is process-global in bun and is never undone. It permanently replaces ApiManager and EvmClientManager (with stubs closed over this file's mutable variables substrateNonce/substrateFree/evmNonce/evmGetClientShouldThrow) for every module loaded after this file in the same bun test process. It also collides with src/api/services/transactions/onramp/common/transactions.test.ts:41, which registers its own mock.module("@vortexfi/shared") — whichever loads last rewires the shared package for both, making results order-dependent. Later tests that need real chain clients (phase handler / integration tests importing ApiManager or EvmClientManager) silently get stubs reporting nonce 0 and zero balances. + +**Suggested fix:** In afterAll, re-register the module with its actual implementations (mock.module("@vortexfi/shared", () => require("@vortexfi/shared") actual exports) or mock only the two managers via a dedicated injectable seam), and document that the stub state variables are only valid inside this file. + +### `apps/api/src/api/services/ramp/ramp.service.register-auth.test.ts:70` — cannot-fail + +The 'rejects registration with no effective user with 400' test asserts only that registerRamp throws an APIError with status 400 and discards the APIError returned by expectRegisterError. It cannot detect removal of the guard it locks in: if the effectiveUserId guard at ramp.service.ts:216 were deleted, registerRamp(userId=undefined, quote.userId=null) still throws APIError 400 further down (prepareAveniaOnrampTransactions at ramp.service.ts:1110 throws 400 'Parameter destinationAddress is required for onramp' because additionalData is {}) — the exact downstream failure the first test in this file documents in its comment. So the test passes with or without the user-gating guard. + +**Suggested fix:** Use the returned error to pin the guard's distinctive message, e.g. const error = await expectRegisterError(undefined, httpStatus.BAD_REQUEST); expect(error.message).toContain("requires an API key linked to a user"); + +### `apps/api/src/api/services/transactions/onramp/common/transactions.test.ts:41` — isolation-hazard + +mock.module("@vortexfi/shared", ...) (line 41), mock.module("../../../../../config/vars", ...) (line 60), and the moonbeam/pendulum cleanup mocks (lines 68, 72) are never restored — there is no afterAll, and bun's mock.restore() does not undo mock.module. bun test executes all files in one process, and I reproduced concrete leakage: adding a test file to the same run that statically imports @vortexfi/shared after this file received the stub module and crashed at load with "SyntaxError: Export named 'NUMBER_OF_PRESIGNED_TXS' not found in module '.../packages/shared/dist/node/index.js'" (the mock factory only exports the handful of names this test needs). The existing suite is currently unaffected only by luck of bun's file-walk order (validation.test.ts happens to run before the onramp directories); any new test file ordered after this one that imports @vortexfi/shared or config/vars can be poisoned. + +**Suggested fix:** Snapshot the real modules before mocking (const realShared = await import("@vortexfi/shared")) and restore them in afterAll via mock.module("@vortexfi/shared", () => realShared) — same for config/vars and the two cleanup modules. Alternatively, spread the real module into the factory ({ ...realShared, createNablaTransactionsForOnrampOnEVM }) so un-mocked exports stay intact. + +### `apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts:46` — isolation-hazard + +Ten mock.module calls with no restoration: @vortexfi/shared (line 46) replaced by a stub missing most real exports (no NUMBER_OF_PRESIGNED_TXS, RampDirection, WebhookEventType, CleanupPhase, etc.), the transactions barrel "../../index" reduced to a single export encodeEvmTransactionData (line 175), and config/logger reduced to { debug } only (line 183) — any later-loaded code calling logger.info/error would crash. Combined with the sibling file's mocks, whichever @vortexfi/shared factory registers last wins for the rest of the process; bun runs all test files in one process and mock.module leakage into later files was reproduced in this run configuration (see transactions.test.ts finding). Nothing in the current suite breaks today only because of bun's file ordering, which is an accident, not a guarantee. + +**Suggested fix:** Capture the real modules with await import(...) before mock.module and restore them in afterAll; for @vortexfi/shared and ../../index, build the factory as { ...realModule, } so the mock does not silently delete unrelated exports. + +### `apps/api/src/api/services/webhook/__tests__/webhook-delivery.service.test.ts:414` — wrong-assertion + +Asserts the X-Vortex-Signature header matches /^sha256=/. Production sets the header to the raw base64 RSA-PSS signature from cryptoService.signPayload (webhook-delivery.service.ts:13-15,37) with no 'sha256=' prefix — that prefix belongs to the removed HMAC scheme. Even after the hang is fixed, this assertion enshrines the wrong signature format. + +**Suggested fix:** Assert the header equals the (mocked or real) base64 signature, e.g. expect.any(String) plus a verifySignature round-trip, not a sha256= prefix. + +### `apps/api/src/api/services/webhook/__tests__/webhook-delivery.service.test.ts:157` — wrong-assertion + +The expected payload omits `quoteId` and asserts transactionId: 'tx-123'. Production's triggerTransactionCreated(quoteId, sessionId, transactionId, type) is called as ('tx-123', 'session-456', 'tx-id', BUY) and builds payload.payload = { quoteId: 'tx-123', sessionId: 'session-456', transactionId: 'tx-id', ... } (webhook-delivery.service.ts:95-105, commits eb9e79adc/755218975). The strict toEqual can never match. Same defect at line 257: expects payload.payload.transactionId to be 'tx-123' where production sends 'tx-id' ('tx-123' is the quoteId). + +**Suggested fix:** Expect { quoteId: 'tx-123', sessionId: 'session-456', transactionId: 'tx-id', transactionStatus: 'PENDING', transactionType: 'BUY' }; at line 257 assert transactionId 'tx-id' and quoteId 'tx-123'. + +### `apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts:247` — cannot-fail + +'should reject when quoteId does not exist' sets rampStateFindByPkMock.mockResolvedValue(null), which is inert (service uses QuoteTicket, not RampState). The test passes only because the unmocked QuoteTicket.findByPk errors against the DB and registerWebhook wraps every error in an APIError; the only assertion is rejects.toBeInstanceOf(APIError), which every failure path satisfies. Deleting the 404 not-found validation from webhook.service.ts would not fail this test. + +**Suggested fix:** After fixing the model mock, mockResolvedValue(null) on QuoteTicket.findByPk and assert the APIError has status 404 / message containing 'not found'. + +### `apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts:196` — cannot-fail + +'should handle registration errors' mocks Webhook.create to reject, but the flow never reaches Webhook.create: the request includes quoteId 'quote-123', so the unmocked QuoteTicket.findByPk throws first and already yields the generic APIError. The create-failure path this test claims to cover is never executed, and the assertion (rejects.toBeInstanceOf(APIError)) passes for the wrong reason. + +**Suggested fix:** Mock QuoteTicket.findByPk to resolve an existing quote so the rejection genuinely comes from Webhook.create, and assert status 500. + +### `apps/api/src/config/crypto.test.ts:76` — cannot-fail + +'should generate new key pair when WEBHOOK_PRIVATE_KEY is not provided' deletes process.env vars, which is inert for the same config-snapshot reason. On this machine (and any with WEBHOOK_PRIVATE_KEY in apps/api/.env, which bun auto-loads) the test actually executes the load-from-environment branch — the run log prints 'RSA keys loaded from environment (public key derived from private)' during this test — yet it still passes because its assertions (truthy PEM, sign/verify round-trip) hold for either branch. The generateKeyPair path it claims to cover can be arbitrarily broken without this test noticing. + +**Suggested fix:** Mock ./vars so config.secrets.webhookPrivateKey is undefined for this test, and assert the generation branch ran (e.g. the resulting public key differs from the env-derived one). + +### `apps/api/src/config/vars.test.ts:6` — isolation-hazard + +requiredProductionEnv omits FLOW_VARIANT, which vars.ts:267 requires when NODE_ENV=production. The suite still passes locally only because Bun.spawn (line 16) runs the child with cwd=apps/api, where bun auto-loads the developer's .env (it contains FLOW_VARIANT), silently un-hermetizing the carefully constructed env. Reproduced: running the exact test-1 scenario from a directory without .env exits 1 with 'Missing required environment variables in production: FLOW_VARIANT', so the 'allows sandbox mode...' test (line 41) fails on a clean checkout/CI. Any other unset variable can likewise leak from .env into these subprocess tests. + +**Suggested fix:** Add FLOW_VARIANT to requiredProductionEnv and pass a cwd without .env files (e.g. os.tmpdir()) in Bun.spawn so the local .env cannot mask missing variables. + +### `apps/rebalancer/src/utils/config.test.ts:46` — isolation-hazard + +The test 'uses the default when the env value is missing' calls parseRebalancingDailyBridgeLimitUsd(undefined). Passing undefined triggers the default parameter (value = process.env.REBALANCING_DAILY_BRIDGE_LIMIT_USD in config.ts:21), so the test actually reads the ambient environment instead of simulating a missing value. REBALANCING_DAILY_BRIDGE_LIMIT_USD is the one policy variable omitted from the file's policyEnvVars cleanup list (lines 9-21), so the beforeEach scrub never deletes it. The variable is documented uncommented in apps/rebalancer/.env.example, and Bun auto-loads .env when running tests from apps/rebalancer. Verified empirically: 'REBALANCING_DAILY_BRIDGE_LIMIT_USD=50000 bun test src/utils/config.test.ts' fails this test (expected 10000, received 50000). It only passes today because the developer's .env happens not to set the variable (and .env.example's value coincidentally equals the 10_000 default). The same gap can make the getConfig tests (lines 137-150) throw spuriously if the ambient value is malformed, since getConfig() calls parseRebalancingDailyBridgeLimitUsd() internally (config.ts:107). + +**Suggested fix:** Add "REBALANCING_DAILY_BRIDGE_LIMIT_USD" to the policyEnvVars array (apps/rebalancer/src/utils/config.test.ts:9-21) so the existing beforeEach delete / afterEach restore covers it; the call at line 46 then genuinely exercises the missing-env default. + +### `packages/shared/src/services/xcm/assethubToMoonbeam.test.ts:25` — cannot-fail + +The test has zero assertions: it builds the extrinsic, dry-runs it, and only console.logs the result. dryRunApi.dryRunCall returns a Result whose payload contains the execution outcome (success or an XCM error such as Filtered/FailedToTransactAsset); the test never inspects it, so a dry run that reports failure still passes. Even when opted in via RUN_LIVE_TESTS, the test can only fail on a thrown exception (e.g., RPC unreachable), never on the behavior it claims to verify. + +**Suggested fix:** Assert on the dry-run outcome, e.g. unwrap the Result and expect(result.isOk).toBe(true) plus expect the inner executionResult to be Complete/Ok; remove the console.log-only pattern. + + +## LOW + +### `apps/api/src/api/middlewares/dualAuth.test.ts:5` — stale-or-dead + +The file is named dualAuth.test.ts but contains zero tests of dualAuth.ts: it imports and tests only assertQuoteOwnership/assertRampOwnership from ./ownershipAuth (line 5). The dual-track auth handler itself (dualAuthHandler / requirePartnerOrUserAuth / optionalPartnerOrUserAuth in dualAuth.ts) is untested by this file; dualAuth.ts merely re-exports the ownership helpers. The name reflects a pre-extraction layout and misleads maintainers into thinking the dual-auth middleware has coverage here. + +**Suggested fix:** Rename the file to ownershipAuth.test.ts (no content change needed). + +### `apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts:71` — cannot-fail + +In "does not update state when the Avenia pay-in ticket is missing", `expect(state.state.onHold).toBe(false)` asserts the fixture's own initial value (makeState(false)) and cannot fail: with an empty ticket list there is no code path that could set onHold to true, and if syncAveniaOnHoldState erroneously invoked updateState({...state, onHold: false}) despite the missing ticket, the assertion would still pass. The test's stated claim (state is not updated) is never actually verified; only the ticketFound === false assertion on line 70 is meaningful. + +**Suggested fix:** Pass a mock as the updateState callback and assert it was not called, e.g. `const updateState = mock(async () => {}); ...; expect(updateState).not.toHaveBeenCalled();`. + +### `apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts:158` — stale-or-dead + +mock.module("../brla/helpers", ...) targets apps/api/src/api/services/brla/helpers, but that directory no longer exists — verifyReferenceLabel now lives in packages/shared/src/services/brla/helpers.ts and is not called anywhere in apps/api production code. The mock (and mockVerifyReferenceLabel at line 153) is vestigial: it registers a virtual module nobody imports, so the intended bypass of reference-label verification silently does nothing. + +**Suggested fix:** Delete the mock, or if reference-label verification is still exercised by the live flow, re-point the mock at the module that production actually imports (@vortexfi/shared). + +### `apps/api/src/api/services/priceFeed.service.test.ts:94` — stale-or-dead + +mock.module("./nablaReads/outAmount", ...) (line 94) and mock.module("./pendulum/apiManager", ...) (line 111) target modules that do not exist in apps/api: there is no src/api/services/nablaReads directory, and src/api/services/pendulum contains only helpers.ts and pendulum.service.ts. priceFeed.service.ts imports getTokenOutAmount and ApiManager from @vortexfi/shared, so these mocks are dead leftovers from an older import layout and shadow nothing the service uses; the comment on line 93 ("Keep the existing mock structure for Nabla") confirms the drift. + +**Suggested fix:** Delete both mock.module blocks; the @vortexfi/shared mock already provides getTokenOutAmount and ApiManager. + +### `apps/api/src/api/services/priceFeed.service.test.ts:596` — cannot-fail + +"should use default values when environment variables are not set" deletes COINGECKO_API_URL/CRYPTO_CACHE_TTL_MS/FIAT_CACHE_TTL_MS (lines 598-600) before creating a new instance, but the constructor reads config/vars values captured at process start, so the env deletion is a no-op and the default-fallback behavior the title claims to test is never exercised. The test ends up asserting exactly the same three config-snapshot values as the next test (line 619, which honestly documents this "keep loaded configuration" behavior) — it is a duplicate whose setup cannot influence the outcome. The same applies to the env vars set in the outer beforeEach (lines 176-182): they never reach the service. + +**Suggested fix:** Delete this test (line 619's test already covers the config-snapshot behavior), or if default-fallback coverage is wanted, test config/vars parsing directly where the defaults are applied. + +### `apps/api/src/api/services/quote/engines/discount/helpers.test.ts:33` — other + +The describe block "negative targetDiscount scenarios (rate floor)" does not test negative-target-discount behavior. calculateSubsidyAmount(expectedOutput, actualOutput, maxSubsidy) has no discount parameter; the negative-discount / rate-floor logic lives in calculateExpectedOutput (helpers.ts lines 74-92), which this file never calls. The four tests in the block are structurally identical to the earlier cases (shortfall subtraction and maxSubsidy cap) with different constants, so the block name gives false confidence that the rate-floor path is covered while enshrining nothing about it. + +**Suggested fix:** Either rename the block to reflect what it tests (plain shortfall/cap arithmetic) and drop the duplicated cases, or actually test calculateExpectedOutput with a negative targetDiscount and assert the discounted rate/expected output it produces. + +### `apps/api/src/api/services/transactions/validation.test.ts:246` — cannot-fail + +In "matches a signed EVM transaction to the unsigned server-built transaction", signedTx is constructed as { ...unsignedTx, txData: signedRawTx }, and areAllTxsIncluded (validation.ts:169-185) compares only phase/network/nonce/signer — fields that are identical by construction via the spread. The ~30 lines of EIP-1559 signing setup cannot influence the assertion; expect(areAllTxsIncluded([signedTx], [unsignedTx])).toBe(true) only fails if areAllTxsIncluded itself is totally broken, and the signed-vs-unsigned matching the test name promises is never exercised (the stray comment on line 245, "change to use universal validator", acknowledges this). + +**Suggested fix:** Either delete the signing ceremony and rename the test to state it checks metadata-only matching (the neighboring test at line 249 already documents that txData is ignored), or convert it to call validatePresignedTxs so the signature/nonce/value verification is actually on the assertion path. + +### `apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts:45` — stale-or-dead + +mock.module('crypto') with randomBytes exists to support webhook secret generation, which was removed from production in commit f1ff2092d ('remove secret generation in the webhook registration'); webhook.service.ts no longer imports crypto and the model has no secret column. The mock and its beforeEach reset/re-arm (lines 87-90) are dead setup. + +**Suggested fix:** Delete the crypto mock and randomBytesMock plumbing. + +### `apps/api/src/config/crypto.test.ts:60` — cannot-fail + +'should be able to sign and verify with derived public key' has the same inert env manipulation: the keypair actually used comes from config at import time, not the test-generated private key, so the 'derived public key' premise is not what is exercised. The test still passes because any consistent keypair satisfies the sign/verify round-trip — it verifies signPayload/verifySignature generally, not derivation. + +**Suggested fix:** Either rename/re-scope the test to 'sign/verify round-trip' or inject the test key via a mocked ./vars so it genuinely uses the derived key. + +### `apps/frontend/src/pages/progress/phaseFlows.test.ts:6` — cannot-fail + +Both tests (lines 6-16 and 20-35) assert that the PHASE_FLOWS constant equals a verbatim copy of itself pasted from phaseFlows.ts. There is no independent oracle: the test names claim to match 'the active BRL ... runtime phases' (i.e. backend parity), but nothing ties the expected arrays to the backend phase handlers in apps/api/src/api/services/phases/handlers/. The test can only fail when someone edits the frontend constant, at which point the test is updated to match — it can never detect the drift-from-backend bug it purports to guard. (I verified the current arrays do happen to match the backend transition chains, so no wrong assertion — the defect is that the test provides zero verification while implying runtime parity.) + +**Suggested fix:** Either delete the file, or make the expectation independent: derive/export the canonical phase sequences from a shared source used by both backend and frontend, or at minimum rename the tests to state they only pin the frontend constant against accidental edits. + +### `apps/frontend/src/translations/helpers.test.ts:170` — cannot-fail + +'demonstrates how easy it would be to add Spanish support' builds a local object `extendedFamilies` (lines 165-168) and then asserts properties the test itself just set (lines 170-171: length 3 and es === 'es'). Those assertions cannot fail regardless of production behavior. The only production-touching assertion is the brittle Object.keys length check on line 163, which asserts a count, not behavior. + +**Suggested fix:** Delete this test (it documents nothing enforceable), or keep only meaningful assertions on the exported LANGUAGE_FAMILIES map. + +### `apps/frontend/src/translations/helpers.test.ts:187` — cannot-fail + +'demonstrates the simplicity of the language code extraction' re-implements the extraction expression inline (line 186: input.toLowerCase().split('-')[0]) and asserts against that inline copy. It never calls any exported function from helpers.ts, so if the production extraction logic in getBrowserLanguage changed or broke, this test would still pass — it only tests JavaScript string methods. + +**Suggested fix:** Delete the test; the real extraction path is already covered via getBrowserLanguage in 'should extract language code correctly from complex locale strings' (lines 108-114). + +### `packages/shared/src/services/xcm/assethubToMoonbeam.test.ts:17` — other + +The test passes assetAccountKey = '0xFFfffffF7D2B0B761Af01Ca8e25242976ac0aD7D' commented as 'xcUSDC', implying the transfer moves that asset. The production function createAssethubToMoonbeamTransferWithSwapOnHydration accepts assetAccountKey but never uses it — the XCM message hardcodes USDT on AssetHub (PalletInstance 50 / GeneralIndex 1984). The argument is inert, so the test misleadingly enshrines a dead parameter and would keep passing no matter what asset key is supplied. + +**Suggested fix:** Either wire assetAccountKey through in the production function so it actually selects the asset, or drop the parameter from the signature and the test call to stop implying it has an effect. + +### `packages/shared/src/services/xcm/moonbeamToAssethub.test.ts:25` — cannot-fail + +Same defect as the assethubToMoonbeam test: no assertions at all. The dry-run Result is only logged via console.log, so a failed XCM dry run (which is the expected outcome here, see the stale-feature finding) still makes the test pass. The test can only fail on network/API exceptions, never on the dry-run outcome it exists to check. + +**Suggested fix:** Assert on the dry-run Result (isOk and inner execution result) instead of logging it, or delete the test together with the dead production function. + +### `packages/shared/src/services/xcm/moonbeamToAssethub.test.ts:15` — stale-or-dead + +The test exercises createMoonbeamToAssethubTransferWithSwapOnHydration, whose own doc comment in packages/shared/src/services/xcm/moonbeamToAssethub.ts (line 51) states: 'WARNING: The resulting XCM transaction does not work because Moonbeam does not allow polkadotXcm::execute calls'. The dry run of this extrinsic can therefore only ever report failure (Filtered), meaning the test targets a documented-dead feature and validates nothing even in live mode. + +**Suggested fix:** Delete this test (and consider removing the dead production function), or convert it into a test that asserts the dry run reports the expected Filtered error if documenting the limitation is intended. diff --git a/docs/test-suite-integrity-check.md b/docs/test-suite-integrity-check.md new file mode 100644 index 000000000..438111d3f --- /dev/null +++ b/docs/test-suite-integrity-check.md @@ -0,0 +1,169 @@ +# Test-Suite Integrity Check — Agent Brief + +You are auditing the test suite of this repository (branch `test-suite-foundation`) for +**integrity**: does it actually deliver what it was built to deliver? Do not trust any +documentation, commit message, or code comment — verify every claim empirically. Where a claim +is false, broken, or only partially true, say so plainly. You are the last gate before this +branch is merged and relied upon. + +## The goals this suite was built for + +The owner's original requirements — everything below must be judged against these: + +- **(a) Easy to maintain and extend** — adding a corridor/endpoint/flow means composing existing + factories and fakes, not hand-rolling mocks; conventions are documented and consistently used. +- **(b) Best practices for similar projects** — hermetic by default, deterministic, one obvious + command to run everything, enforced in CI, live/networked tests strictly opt-in. +- **(c) Catches real regressions** — if someone breaks a security invariant or an API contract, + a test fails. Tests that cannot fail are defects. + +## What was claimed to be delivered (verify each) + +1. **Strategy & docs**: `docs/testing-strategy.md` (architecture, commands, how-to-extend), + `docs/test-audit-findings.md` (57 confirmed defects in pre-existing tests + remediation log). +2. **Hermetic API harness** in `apps/api/src/test-utils/`: env-neutralizing preload (via + `apps/api/bunfig.toml`, incl. `root = "src"`), dockerized test Postgres (port 54329, + `bun test:db:start`), model factories, fake external world (EVM ledger at the + `EvmClientManager` seam, BRLA/Avenia, Mykobo, Alfredpay, SquidRouter `getRoute`, price feeds, + Supabase auth), global **fetch guard** that rejects any un-faked external HTTP call, in-process + Express app (`test-app.ts`). +3. **Invariant tests** (`apps/api/src/tests/`): auth/credential matrix, ramp & quote ownership, + quote lifecycle (expiry, consumed, foreign-user, flow-variant, EUR kill-switch), and **atomic + quote consumption** (two concurrent registrations → exactly one ramp). +4. **Corridor scenarios** (`apps/api/src/tests/corridors/brl-onramp.scenario.test.ts`): real + `PhaseProcessor` over pix→BRLA-on-Base with viem-signed presigned txs — happy path, transient + failure + retry, wrong-recipient rejection (security regression), concurrent-lock behavior. +5. **SDK ↔ API contract tests** (`apps/api/src/tests/sdk-contract.test.ts`): the real SDK from + `packages/sdk/src` driving the in-process API (quote → register → sign → start → status), + plus negative cases (missing secretKey, foreign ramp → typed 403). +6. **Frontend**: 62+ XState machine tests (`apps/frontend/src/machines/*.machine.test.ts`), + RTL+MSW component tests (Onramp/Offramp quote forms, `ProgressPage`, Avenia KYC fields; infra + under `apps/frontend/src/test/`), Playwright journeys in `apps/frontend/e2e/` with an EIP-6963 + mock wallet, wired as **non-blocking nightly** (`.github/workflows/e2e.yml`). +7. **CI** (`.github/workflows/ci.yml`): a `test` job with a Postgres service container (port + 54329) running shared, sdk, rebalancer, api and frontend suites on every PR, after + `bun build:shared`. +8. **Audit remediation**: no process-wide `mock.module`/singleton patches without restore; live + integration tests' module-level patching gated behind `RUN_LIVE_TESTS`; stale suites either + rewritten (webhook-delivery) or fixed/removed (see the findings doc's remediation section — + verify it reflects reality after the follow-up sessions). + +## Phase 1 — Everything runs green (foundation, do first) + +From a clean checkout state (note anything that only works because of leftover local state): + +```bash +bun install +bun test:db:start # docker Postgres on 54329 +bun run build # must pass (CI parity) +bun run verify && bun run typecheck +bun test # root aggregate: shared, sdk, rebalancer, api, frontend +``` + +- `cd apps/api && bun test` must exit 0 **as one process**. Record pass/skip/fail counts. + Run it **twice in a row** (state bleed check) and confirm identical results. +- `cd apps/frontend && bunx vitest run` must pass. Playwright: run if browsers are installed + (`bunx playwright test`), otherwise verify the config/workflow wiring and say you didn't run it. +- Verify `git status` stays clean after test runs (no JSON snapshots or scratch files churned). +- Verify the skip count is fully explained: every skipped test must be either a + `RUN_LIVE_TESTS`-gated live test or a documented quarantine with a pointer to + `docs/test-audit-findings.md`. Unexplained skips are findings. + +## Phase 2 — Hermeticity and isolation + +1. **No network escapes**: run the api suite with verbose logs and grep for + `Hermetic test violation` — occurrences must only be *deliberate* assertions/warn-paths, never + silent besides-the-point failures. Inspect `fetch-guard.ts` for holes (WebSockets are NOT + covered by it — verify nothing in the hermetic suite opens chain WS connections; check + the SDK contract test's NetworkManager handling specifically). +2. **Credential safety**: confirm `apps/api/src/test-utils/preload.ts` neutralizes every + credential a local `.env` could carry that the hermetic suite might otherwise use (Mykobo, + BRLA, Alfredpay, Supabase, signing seeds). Cross-check against `apps/api/.env.example` for + any credential-bearing var NOT overridden — each one is a potential leak; assess it. +3. **DB safety**: `db.ts` must refuse non-`test` database names; truncation must exclude + `SequelizeMeta`. Confirm tests cannot reach the dev database even with a populated `.env`. +4. **Mock isolation**: search all `*.test.ts` for `mock.module(` and module-scope singleton + patches (`X.getInstance =`, `Model. =`, `global. =`, `prototype. =`). Every one + must either (i) be restored in `afterAll` from a **value copy captured before mocking** (not a + live ESM namespace), or (ii) sit behind `if (process.env.RUN_LIVE_TESTS)`. Also verify the + canary (`aaa-leak-probe.test.ts`) still exists and passes. +5. **Order independence (sampled)**: pick ~8 api test files spanning directories and run each + standalone (`bun test `) — results must match their full-suite behavior. +6. **dist/ discovery**: confirm `apps/api/bunfig.toml` has `[test] root = "src"` and that + `bun test` executes no file under `dist/` (check the run's file list). +7. **Live-test gating**: with `RUN_LIVE_TESTS` unset, confirm the four phase integration tests + and shared XCM dry-runs skip AND execute no module-level patching (the guards around their + `mock.module`/model patches). + +## Phase 3 — Mutation checks: can the tests actually fail? + +This is the core of the integrity check. For each mutation: apply it, run ONLY the named test +scope, confirm at least one test **fails for the right reason**, then revert with +`git checkout -- ` and re-run to green. Never leave a mutation in place; verify +`git status` is clean at the end of this phase. + +| # | Mutation (production code) | Expected failing tests | +|---|---|---| +| 1 | `ramp.service.ts`: make `consumeQuote` not filter on `status = 'pending'` (or skip the `affectedRows === 0` throw) | quote-consumption invariants (concurrent double-register) | +| 2 | `ramp.service.ts`: remove the `quote.status !== "pending"` rejection | "rejects an already-consumed quote" | +| 3 | `ramp.service.ts`: remove the expiry check | "rejects an expired quote" | +| 4 | `destination-transfer-handler.ts`: make `validateDestinationTransferRecipient` a no-op | corridor scenario "wrong recipient" (security regression) | +| 5 | `ownershipAuth.ts`: make `assertRampOwnership` return without checking `ramp.userId` | auth invariants ownership tests | +| 6 | `apiKeyAuth.helpers.ts`: skip the `isActive`/expiry check in `validateSecretApiKey` | revoked/expired API key tests | +| 7 | `phase-processor.ts`: don't release the lock in the `finally` | corridor happy-path lock assertions | +| 8 | API response shape: rename a field the SDK reads in `getRampStatus`'s response (service level) | `sdk-contract.test.ts` | +| 9 | `ramp.machine.ts` (frontend): break one transition target the machine tests cover | the corresponding machine test | +| 10 | `webhook-delivery.service.ts`: change the signature header name or signing input | rewritten webhook-delivery tests | + +If any mutation survives (no test fails), that is a **high-severity finding**: name the missing +assertion and where it should live. + +## Phase 4 — Goal-level assessment (a/b/c) + +- **(a) Maintainability**: write (temporarily) a tiny new test using the harness — e.g. a new + invariant test hitting one endpoint via `startTestApp` + factories. Time/effort should be + minutes with zero new mocking. Delete it afterward. Judge the "How to extend" section of + `docs/testing-strategy.md` for accuracy: follow it literally and note every place it lies. +- **(b) Best practices**: single command (`bun test`) works; CI blocks on it; live tests opt-in; + no watch-mode surprises in CI paths; deterministic (no `Date.now`-sensitive flakes — check the + quote-expiry tests' clock handling); reasonable runtime (api suite target: well under a minute). +- **(c) Regression net coverage** — verify a test exists (and locate it) for each security-spec + invariant; flag any without one as a gap: + quote consumed exactly once/atomically; quote expiry; fee structure present & used for status + (fees immutable path); ownership (user/partner/anonymous, ramps AND quotes); credential matrix + (missing/invalid/malformed/revoked/expired); admin vs metrics-dashboard secrets; EUR + kill-switch behavior; presigned-tx recipient & signer validation; phase-processor lock + acquire/release + terminal states + retry exhaustion; ephemeral freshness check; webhook + signing/delivery/retry; SDK response-shape contract; frontend ramp/KYC machine error paths. + +## Phase 5 — Known deliberate gaps (confirm they are still true, documented, and acceptable) + +These were consciously descoped — confirm each is documented (strategy doc or findings doc), and +flag if any has silently grown in importance: + +- No golden/snapshot tests for quote **pricing math** (fee amounts for a fixed input matrix). + Check whether anything equivalent exists now; if not, it remains the top recommended addition. +- Only the BRL corridor has processor-level scenarios (EUR is kill-switched; Mykobo corridors are + covered by gated live tests only; Alfredpay corridors have no hermetic scenario). +- Substrate/Pendulum chain interactions are not faked (the ApiManager fake serves only inert + reads); XCM flows have no hermetic coverage. +- No Anvil/fork EVM tests (deliberate: upstream-RPC flakiness in CI). +- No coverage-percentage gate (deliberate for now). +- Rebalancer has unit tests only (no scenario harness). + +## Report format + +Produce a single report, most severe first: + +1. **Verdict** — one paragraph: does the suite deliver (a), (b), (c)? Merge-ready? +2. **Broken claims** — anything documented/claimed that is not true (file:line, how verified). +3. **Mutation results** — table: mutation → failed as expected? → notes. Highlight survivors. +4. **Hermeticity/isolation findings** — leaks, escapes, order dependence, credential exposure. +5. **Coverage gaps** — invariants without a failing-capable test. +6. **Deliberate-gap review** — still acceptable? Any promoted to "should fix now"? +7. **Confirmed-good summary** — what you verified works, with the numbers (pass/skip counts, + runtimes), so this report is also a record of the suite's state at audit time. + +Rules: read production code before judging a test wrong; verify empirically before reporting; +one mutation at a time and always revert; leave the working tree exactly as you found it +(`git status` clean, test DB left running). diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md new file mode 100644 index 000000000..2f656b69a --- /dev/null +++ b/docs/testing-strategy.md @@ -0,0 +1,255 @@ +# Testing Strategy + +This document describes the test suite architecture for Vortex: what each layer covers, the +infrastructure it runs on, and where to add tests when you change something. It was introduced +together with the shared test harness (`apps/api/src/test-utils`) — see "How to extend" below. + +## Goals + +1. **Catch regressions before manual testing does.** Critical paths (quote → register → ramp + execution → payout) are covered by automated tests that run on every PR. +2. **Easy to extend.** Adding a corridor, phase, or endpoint means writing a scenario on top of + existing factories and fakes — not hand-rolling a new mock setup. +3. **Hermetic by default.** The PR suite needs no secrets, no chain access, and no third-party + sandboxes. Anything that touches the network is opt-in (`RUN_LIVE_TESTS=1`) and never blocks CI. + +## Test layers + +| Layer | What | Where | Runner | +|---|---|---|---| +| 1. Unit | Pure logic: helpers, token configs, SDK handlers | each package, next to source | `bun test` (Vitest for frontend) | +| 2. API integration | Real Express + real Postgres + fake external world, driven over HTTP; incl. the quote pricing goldens (`quote-pricing.golden.test.ts`) and the HTTP surface tests (auth OTP flow, webhooks, ramp history, public routes; `http-surface.invariants.test.ts`) | `apps/api/src/tests/` | `bun test` | +| 3. Corridor scenarios | Phase processor end-to-end per corridor against the fake world: BRL onramp (pix→BRLA-on-Base), BRL offramp (USDC-on-Base→pix incl. real Nabla swap + both EVM subsidy phases), CROSS-CHAIN BRL offramp (USDC-on-Polygon→squid→Base→pix incl. user-reported squid-hash verification), MXN on/offramp (spei↔USDT-on-Polygon), CROSS-CHAIN MXN onramp (spei→Polygon mint→squid→USDT-on-Arbitrum incl. real squidRouterSwap/Pay + Arbitrum settlement subsidy), CROSS-CHAIN BRL onramp (pix→Base mint+Nabla swap→squid→USDC-on-Arbitrum), a USD/COP/ARS matrix over the same Alfredpay rails (happy paths + per-currency limit breaches + per-currency transient AND unrecoverable failures + per-currency cross-chain BUY and no-permit cross-chain SELL, incl. MXN SELL cross-chain), and EUR (Mykobo) on/offramp scenarios (SEPA↔EURC/USDC-on-Base incl. real Nabla swap; registration stays kill-switched — see the coverage matrix) | `apps/api/src/tests/corridors/` | `bun test` | +| 4. SDK contract | Real SDK against the real API in-process: BRL onramp lifecycle (`sdk-contract.test.ts`), the SELL/user-transaction surface — offramp lifecycle via submitUserTransactions, updateRamp, getQuote, listAlfredpayFiatAccounts (`sdk-contract.offramp.test.ts`) — and full per-currency lifecycles for all four Alfredpay currencies in both directions: SELL offramp lifecycles for USD/ach, MXN/spei, COP/ach and ARS/cbu (`sdk-contract.alfredpay-offramp.test.ts`) and BUY onramp lifecycles for MXN/spei, USD/ach, COP/ach and ARS/cbu (`sdk-contract.alfredpay-onramp.test.ts`) | `apps/api/src/tests/sdk-contract*.test.ts` | `bun test` | +| 5. Frontend | XState machine tests, actor tests (register/sign/start/KYC-routing against MSW with mocked wallet seams), component tests (RTL + MSW + mock wagmi) | `apps/frontend/src` | Vitest | +| 6. E2E | Critical Playwright journeys with a mock wallet: BRL on/offramp plus parameterized Alfredpay journeys for all four currencies in both directions | `apps/frontend/e2e/` | Playwright (non-blocking) | + +### The invariants the suite protects + +Derived from `docs/security-spec/` — these must never regress, and each has dedicated tests: + +- A quote is consumed **exactly once**, atomically with ramp registration; it expires after its TTL. +- Fees are fixed at quote creation (`metadata.fees`) and identical at registration time; no + client-supplied fee is accepted. +- Subsidy caps are enforced (pre-swap, post-swap, `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`) — a breach + throws instead of paying out (finding F-001). Each of the three caps has an end-to-end breach + test: pre- and post-swap in `corridors/brl-offramp.scenario.test.ts`, the final-settlement cap + in `corridors/mxn-offramp.scenario.test.ts`. +- Ownership guards: a partner only sees its own quotes/ramps; a user only their own (F-068 class). +- Phase processor: retries are bounded (`MAX_RETRIES`); after exhaustion of a recoverable error + the processor stops without a terminal transition, releasing the lock and leaving the ramp + resumable (the missing `failed` transition is documented as open finding F-004 in + `docs/security-spec/03-ramp-engine/state-machine.md`). Unrecoverable errors transition to + `failed`. Locks are released on terminal states; only `currentPhase`/`phaseHistory` are + updated by the processor. +- Presigned transaction and ephemeral address validation (F-021, F-038 class). +- External swap/route outputs are validated against expectations before funds move (F-030). + +When a new security finding is fixed, add a regression test in the same PR and reference the +finding ID in the test name. + +## Coverage matrix: corridors × entry points + +One row per live corridor and direction. The scenario columns are the **direct-API entry point** +(registration over real HTTP + the real phase processor against the fake world); SDK and E2E are +the other two entry points. Corridor-agnostic invariants (auth matrix, quote consumption/expiry, +fee immutability, pricing goldens, ownership) are not repeated per row — they run once against +shared code and are listed in the invariants section above. **Update this table in the same PR +as any new corridor, rail, or entry-point test.** + +Legend: ✅ directly tested · ◐ covered only via shared code/another corridor (see footnote) · +❌ missing · — not applicable · 🚫 kill-switched. + +| Corridor (rail) | Dir | Happy path | Transient | Unrecoverable | Security / caps | Cross-chain leg | SDK | E2E journey | +|---|---|---|---|---|---|---|---|---| +| BRL (Avenia / Pix) | BUY | ✅ | ✅ | ✅ | ✅ recipient | ✅² | ✅ | ✅ | +| BRL (Avenia / Pix) | SELL | ✅ | ✅ | ✅ | ✅ pre/post-swap caps, recipient | ✅ F-021 | ✅ | ✅ | +| MXN (Alfredpay / SPEI) | BUY | ✅ | ✅ | ✅ | ✅ recipient | ✅ settlement subsidy | ✅ | ✅ | +| MXN (Alfredpay / SPEI) | SELL | ✅ | ✅ | ✅ | ✅ calldata, F-001 cap | ✅¹ | ✅ | ✅ | +| USD (Alfredpay / ACH) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | +| USD (Alfredpay / ACH) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | +| COP (Alfredpay / ACH) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | +| COP (Alfredpay / ACH) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | +| ARS (Alfredpay / CBU) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | +| ARS (Alfredpay / CBU) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | +| EUR (Mykobo / SEPA) | BUY | ✅³ | ✅³ | ✅³ | ✅ recipient, KYC gate | ◐⁴ | 🚫 | 🚫 | +| EUR (Mykobo / SEPA) | SELL | ✅³ | ✅³ | ✅³ | ✅ payout-vs-intent match, KYC gate | ◐⁴ | 🚫 | 🚫 | +| AssetHub (BRL BUY → USDC; USDC SELL → Pix) | both | ❌ deferred | ❌ | ❌ | ❌ | — | ❌ | ❌ | + +¹ Alfredpay SELL cross-chain is covered on the no-permit fallback path (user-broadcast squid +approve+swap verified against the blueprints by hash); the permit/TokenRelayer variant is +untested — it needs relayer-contract execution the fake world doesn't model. +² BRL BUY cross-chain (pix → Base mint + Nabla swap → squid → USDC-on-Arbitrum) is happy-path +only; failure modes of the shared squid handlers are covered by the MXN cross-chain and BRL +cross-chain offramp scenarios. +³ EUR registration is still kill-switched at `/v1/ramp/register` (503 — asserted by these +tests). The scenarios seed the registered state through the same service calls registration +runs below the switch, then drive the real PhaseProcessor. The re-enablement precondition is +met; once the switch is lifted, swap the seeding helpers in `corridors/eur-*.scenario.test.ts` +to plain HTTP registration and add SDK + E2E coverage. +⁴ The EUR scenarios cover the direct EURC-on-Base paths; BUY to other destinations / SELL from +non-Base sources use the shared squid legs tested in the other corridors' cross-chain variants. + +**Gaps at a glance** (everything not ✅ above): the Alfredpay permit/TokenRelayer cross-chain +SELL variant is untested (no-permit fallback is); EUR SDK and E2E entry points stay closed +behind the kill-switch (corridor scenarios are in place, so lifting it is unblocked); the +AssetHub corridors are reachable in production but deliberately deferred (see the decision +note under Infrastructure — revisit if the product keeps them). + +## Infrastructure + +### The fake external world (`apps/api/src/test-utils/`) + +All external boundaries are stubbed at the existing service seams — the singletons the production +code already goes through: + +- **Anchors/APIs**: `BrlaApiService` (Avenia), `MykoboApiService`, Alfredpay, SquidRouter, + price feeds. Each fake is configurable per test: succeed with given amounts, return malformed + data, time out, or fail N times then succeed (for retry testing). +- **Chains**: faked at the `EvmClientManager` and Pendulum `apiManager` seams with an in-memory + balance ledger. Phase handlers genuinely poll balances and observe transfers; tests script the + ledger ("EURC arrives on the ephemeral after the 2nd poll"). +- **Clock**: there is no injected clock; expiry tests stay deterministic by writing + `expiresAt` timestamps in the past instead of faking timers. + +Decision: we deliberately do **not** run Anvil/fork-based EVM tests in CI. Fork mode depends on an +upstream RPC (flaky public endpoints or a paid key as a CI secret). If calldata-level fidelity ever +becomes a problem, add a non-blocking nightly Anvil job — do not put it in the PR path. + +Decision: Pendulum/AssetHub/XCM ramp corridors are **deliberately not tested**. Vortex no longer +supports those flows as a product; the route strategies and substrate handlers that still +reference them are kept only in case support is re-added later. Do not count them as coverage +gaps and do not build a substrate fake for them — if the flows come back, that is the moment to +add corridor scenarios (and the fake) for them. + +### Database + +API integration tests run against a real Postgres (Docker locally, service container in CI), +migrated with the production Umzug migrations and truncated between tests. Sequelize is **not** +mocked in integration tests — transactionality (quote consumption, processing locks) is part of +what we test. Unit tests may still mock models where the DB is incidental. + +### Factories + +`apps/api/src/test-utils/factories.ts` builds `User`, `Partner`, `ApiKey`, `QuoteTicket`, +`RampState`, `TaxId` (Avenia KYC) and `AlfredPayCustomer` (Alfredpay KYC) rows. Never +hand-write these objects or copy JSON snapshots into tests; extend the factory instead. + +### Playwright E2E (`apps/frontend/e2e/`) + +Critical journeys run against the real frontend in Chromium, hermetically: quote form → quote +displayed, quote error surfaced, wallet gate on offramps, and full ramp journeys — the BRL +onramp (`onramp-brl-journey.spec.ts`: quote → email/OTP auth → Avenia KYC gate → registration +→ in-page ephemeral signing asserted via the presigned txs posted to `/v1/ramp/update` → Pix +payment info → progress → success), the BRL OFFRAMP (`offramp-brl-journey.spec.ts`: the +money-out path — wallet gate + balance check → CPF/Pix eligibility → registration → +USER-WALLET broadcast of the source-of-funds transfer with its hash reported in a second +update → automatic start → success), and the Alfredpay rail parameterized over all four +currencies in both directions — BUY MXN/USD/COP/ARS (`onramp-alfredpay-journeys.spec.ts`: +Alfredpay KYC gate with per-country routing asserted → Polygon-side ephemeral signing → +per-currency payment instructions (SPEI/CLABE, ACH bank details, COP bank details, ARS CVU) +→ success) and SELL USD/MXN/COP/ARS (`offramp-alfredpay-journeys.spec.ts`: wallet gate on +Polygon → Alfredpay KYC gate → per-country fiat-account form (US wire, 18-digit CLABE, COP +ACH, 22-digit CBU) → ephemeral presigning → user-wallet broadcast with its hash reported in +a second update → automatic start → success incl. per-token arrival text): + +- The API origin (`http://localhost:3000`) is intercepted per-test with `page.route` + (`e2e/support/mockBackend.ts`) — no backend, database, or chain access. +- A mock wallet (`e2e/support/mockWallet.ts`) is injected as an EIP-6963-announced + EIP-1193 provider before the app loads; wagmi/AppKit picks it up like any installed + browser wallet. +- Third-party endpoints (SquidRouter token list, WalletConnect config) are blocked; the + app's built-in fallbacks cover them. + +They run nightly via `.github/workflows/e2e.yml` (never PR-blocking) and locally with +`bun test:e2e`. + +### EUR re-enablement precondition + +EUR ramps are kill-switched at registration (`ramp.service.ts`). The hermetic EUR corridor +scenarios this precondition asked for now exist (`corridors/eur-onramp.scenario.test.ts`, +`corridors/eur-offramp.scenario.test.ts` — both directions, happy path + transient + +unrecoverable, driving the real PhaseProcessor; they also pin the 503 the kill-switch +returns). What remains before EUR can be re-enabled: lift the switch, swap the scenarios' +state-seeding helpers to plain HTTP registration (each file's docstring says how), and add +SDK contract + E2E journey coverage for the reopened entry points. + +### Live tests + +Tests that hit real RPCs or sandboxes (e.g. XCM dry-runs in `packages/shared`) are gated behind +`RUN_LIVE_TESTS=1` via `describe.skipIf`. They are for local debugging and optional nightly runs, +never PR-blocking. + +## CI + +- **PR-blocking** (`ci.yml`): build, Biome, typecheck, then unit + integration tests for every + workspace. Postgres is provided as a GitHub Actions service container. +- **Non-blocking / nightly**: Playwright E2E journeys and any live smoke tests. Failures alert; + they don't block merges. +- Every workspace suite carries a coverage ratchet, enforced by `bun run test:coverage` in CI: + the bun workspaces (shared, sdk, rebalancer, api) produce an LCOV report checked against + per-package floors by `scripts/check-coverage.ts` (floors live in each `package.json` script); + the frontend uses vitest's built-in thresholds (`apps/frontend/vitest.config.ts`). Floors sit just under the + coverage measured when they were last raised — raise them when you add tested code; never lower + them to make CI pass. +- The coverage denominator is real, testable source only: built bundles, foreign workspace code, + migrations, generated files (contract ABIs, route tree), Storybook stories, and test + infrastructure are excluded (each workspace's `bunfig.toml` `coveragePathIgnorePatterns`, + resp. `coverage.exclude` in the frontend vitest config). If a methodology change like this + moves the measured numbers, re-base the floors to just under the new values in the same PR — + that is not "lowering the ratchet". +- `bun run test:coverage` at the repo root runs every workspace's gate and then prints a + per-area breakdown (`scripts/coverage-report.ts`) showing which parts of each workspace are + covered and which are not. + +## How to extend + +- **New endpoint / route change** → add or update an HTTP-level test in `apps/api/src/tests/`. + If it's auth-protected, add it to the auth-matrix test. +- **New corridor or phase** → add a scenario in `apps/api/src/tests/corridors/` using the + factories and fake world. Cover: happy path, one transient failure + recovery, one unrecoverable + failure → `failed`. +- **New external integration** → add a fake for it in `test-utils/fake-world/` with the standard + configurable behaviors (success / malformed / timeout / fail-then-succeed). +- **SDK-visible API change** → the SDK contract tests in `apps/api/src/tests/sdk-contract.test.ts` + must pass unchanged, or the change is breaking and needs an SDK release note. +- **New frontend flow** → machine test first (transitions incl. rejection/error paths), component + test if there's meaningful rendering logic, E2E only if it's a top-level critical journey. +- **Quote/fee logic change** → the pricing goldens in + `apps/api/src/tests/quote-pricing.golden.test.ts` will diff; update the expected values + consciously and mention the fee impact in the PR description. + +## Commands + +```bash +# One-time per machine: dedicated test Postgres (Docker, port 54329) +bun test:db:start # bun test:db:stop to remove it + +# Everything hermetic (what CI runs). NOTE: `bun run test`, not `bun test` — +# a bare `bun test` at the root invokes bun's own runner (the root bunfig.toml +# makes it a no-op instead of letting it pick up stray files). +bun run test # shared + sdk + rebalancer + api + frontend + +# Coverage: all workspace gates + per-area report (needs the test db) +bun run test:coverage + +# Browsable HTML version of the same report (per-file, with uncovered line +# ranges) from the lcov files the previous command left behind +bun run test:coverage:html + +# Individual workspaces +bun test:api # unit + integration (needs the test db) +bun test:frontend +bun test:shared +bun test:rebalancer +bun test:sdk + +# Playwright E2E journeys (non-blocking; starts its own Vite dev server) +# One-time per machine: cd apps/frontend && bunx playwright install chromium +bun test:e2e + +# Opt-in live tests (real RPCs / sandboxes; needs credentials in .env) +cd apps/api && RUN_LIVE_TESTS=1 bun test src/api/services/phases/ +``` + +(Scripts are defined in the root `package.json`; see there for the authoritative list.) diff --git a/package.json b/package.json index 31e58c5a7..b103f2d14 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,18 @@ "prepare": "husky", "serve:backend": "bun run --cwd apps/api serve", "serve:frontend": "bun run --cwd apps/frontend preview", + "test": "bun run test:shared && bun run test:sdk && bun run test:rebalancer && bun run test:api && bun run test:frontend", + "test:api": "cd apps/api && bun test", "test:contracts:relayer": "bun run --cwd contracts/relayer test", + "test:coverage": "bun run --cwd packages/shared test:coverage && bun run --cwd packages/sdk test:coverage && bun run --cwd apps/rebalancer test:coverage && bun run --cwd apps/api test:coverage && bun run --cwd apps/frontend test:coverage && bun scripts/coverage-report.ts", + "test:coverage:html": "bun scripts/coverage-report.ts --html coverage/index.html && open coverage/index.html", + "test:db:start": "bun run --cwd apps/api test:db:start", + "test:db:stop": "bun run --cwd apps/api test:db:stop", + "test:e2e": "cd apps/frontend && bun run test:e2e", + "test:frontend": "cd apps/frontend && bunx vitest run", + "test:rebalancer": "cd apps/rebalancer && bun test", + "test:sdk": "bun run --cwd packages/sdk test", + "test:shared": "cd packages/shared && bun test", "typecheck": "bunx tsc", "verify": "biome check --no-errors-on-unmatched" }, diff --git a/packages/sdk/.env.example b/packages/sdk/.env.example new file mode 100644 index 000000000..b910d5ac1 --- /dev/null +++ b/packages/sdk/.env.example @@ -0,0 +1,44 @@ +# Copy this file to `.env` (in packages/sdk) and fill in your own values. +# `bun run scripts/*.ts` and `bun run examples/*.ts` auto-load it — no code edits needed. +# `.env` is gitignored; `.env.example` is committed as the template. +# +# Vars marked (required) have no default: the example throws on startup if they are unset, +# rather than silently using a placeholder. Vars marked (default: ...) may be left unset. + +# ── Backend / credentials (all examples) ──────────────────────────────────── +# Backend base URL. (default: http://localhost:3000) +VORTEX_API_URL=http://localhost:3000 +# API key pair from `bun run scripts/login-and-create-api-key.ts` (see .api-key.json). (required) +# For Alfredpay (MXN) flows this MUST be a user-linked sk_* key, not a partner-only key. +VORTEX_PUBLIC_KEY= +VORTEX_SECRET_KEY= + +# ── Wallet / destination (all examples) ───────────────────────────────────── +# Where crypto lands (onramp) and the connected wallet used for offramp registration. (required) +DESTINATION_ADDRESS= +WALLET_ADDRESS= + +# ── EUR examples (SEPA) ───────────────────────────────────────────────────── +USER_EMAIL=user@example.com # (default: user@example.com) +USER_IP_ADDRESS=203.0.113.1 # (default: 203.0.113.1) + +# ── BRL examples (PIX) ────────────────────────────────────────────────────── +BRL_TAX_ID= # (required for BRL onramp + offramp) +BRL_RECEIVER_TAX_ID= # (required for BRL offramp) +BRL_PIX_DESTINATION= # (required for BRL offramp) + +# ── MXN offramp (Alfredpay / SPEI) ────────────────────────────────────────── +# Throwaway wallet holding the test USDC; used to sign offramp permits locally with viem. +# Leave unset to sign in your own wallet and paste signatures at the prompt — but then +# WALLET_ADDRESS (above) must be the address you sign with. (optional) +OFFRAMP_WALLET_PRIVATE_KEY= +# The user's Alfredpay fiat account id. (required for MXN offramp) +FIAT_ACCOUNT_ID= + +# ── API-key provisioning scripts (scripts/*.ts) ───────────────────────────── +# Note: the scripts read API_BASE_URL (not VORTEX_API_URL). +API_BASE_URL=http://localhost:3000 +# Email of the user to log in as — for Alfredpay use the user who completed MXN KYC. +TEST_USER_EMAIL=test@email.io +API_KEY_NAME=sdk-test +API_KEY_OUTFILE=.api-key.json diff --git a/packages/sdk/.gitignore b/packages/sdk/.gitignore index f3299315d..879616b86 100644 --- a/packages/sdk/.gitignore +++ b/packages/sdk/.gitignore @@ -22,6 +22,10 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .env.production.local .env.local +# api-key artifacts (secrets) +.api-key.json +ephemerals_*.json + # caches .eslintcache .cache diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5ab77ef9e..174aaae8b 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -51,6 +51,71 @@ console.log("Please do the pix transfer using the following code: ", depositQrCo const startedRamp = await sdk.startRamp(rampProcess.id); ``` +### Alfredpay (USD / MXN / COP / ARS) onramp + +```typescript +import { VortexSdk, FiatToken, EvmToken, EPaymentMethod, Networks, RampDirection } from "@vortexfi/sdk"; + +const sdk = new VortexSdk({ + apiBaseUrl: "http://localhost:3000", + publicKey: process.env.VORTEX_PUBLIC_KEY, + secretKey: process.env.VORTEX_SECRET_KEY +}); + +const quote = await sdk.createQuote({ + from: EPaymentMethod.ACH, // USD and COP settle via ACH; MXN uses EPaymentMethod.SPEI + inputAmount: "100", + inputCurrency: FiatToken.COP, + network: Networks.Polygon, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Polygon +}); + +const { rampProcess } = await sdk.registerRamp(quote, { + destinationAddress: "0x1234567890123456789012345678901234567890", + walletAddress: "0x1234567890123456789012345678901234567890" + // fiatAccountId is optional for onramp. +}); + +// Inspect off-chain fiat payment instructions before starting. +const startedRamp = await sdk.startRamp(rampProcess.id); +console.log("Pay via:", startedRamp.achPaymentData); +``` + +Quotes can be requested without any key (anonymous rate discovery). Registering the ramp requires the user to be onboarded first: authenticate the SDK with that user's own **user-linked** `secretKey` (the `sk_*` key created by that user), not a `publicKey` alone, a partner-scoped key, or a Supabase Bearer token. The same user must have completed Alfredpay KYC for the country — the key and the KYC record belong to the same account, so registration resolves to the user's Alfredpay customer automatically. + +> The SDK cannot mint keys or run KYC. Onboard the user through the Vortex app or Widget first, then use their `sk_*` key (shown only once, at creation) with the SDK. + +### Alfredpay (USD / MXN / COP / ARS) offramp + +```typescript +const quote = await sdk.createQuote({ + from: Networks.Polygon, + inputAmount: "10", + inputCurrency: EvmToken.USDC, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: EPaymentMethod.SPEI // USD and COP settle via EPaymentMethod.ACH +}); + +const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { + fiatAccountId: "", + walletAddress: "0x1234567890123456789012345678901234567890" +}); + +// Sign or broadcast each user-side transaction before starting the ramp. +await sdk.submitUserTransactions(rampProcess.id, unsignedTransactions, { + signTypedData: payload => walletClient.signTypedData(payload), + sendTransaction: tx => walletClient.sendTransaction(tx) +}); + +const startedRamp = await sdk.startRamp(rampProcess.id); +``` + +> `fiatAccountId` is opaque to the SDK. It is required for offramp and optional for onramp. Consumers create or look up the user's Alfredpay fiat account out-of-band (via the Vortex backend) and pass the ID in. + ## Core Features - **Ephemerals abstracted**: No need to keep track of the ephemeral accounts used in the ramp process. If `storeEphemeralKeys` is enabled, keys are stored in a JSON file in Node.js. - **Stateless Design**: No internal state management - you control persistence of the rampId for status checking @@ -77,7 +142,7 @@ Retrieves an existing quote by ID. Gets the current status of a ramp process. ##### `registerRamp(quote: Q, additionalData: RegisterRampAdditionalData): Promise<{ rampProcess: RampProcess; unsignedTransactions: UnsignedTx[] }>` -Registers a new ramp process. Creates fresh ephemeral accounts on Stellar, Pendulum, and Moonbeam, submits the quote and ephemeral addresses to the API, then signs and submits the returned unsigned transactions. Returns the ramp process and the list of unsigned transactions returned by the API for the caller's reference. +Registers a new ramp process. Creates fresh Substrate and EVM ephemeral accounts, submits the quote and ephemeral addresses to the API, then signs and submits the returned ephemeral-owned transactions. Returns the ramp process and the user-owned `unsignedTransactions` that the caller must sign or broadcast. ##### `updateRamp(quote: Q, rampId: string, additionalUpdateData: UpdateRampAdditionalData): Promise` Submits route-specific transaction hashes after off-chain steps complete. Used for sell flows. Buy flows do not require a separate update call. @@ -85,6 +150,24 @@ Submits route-specific transaction hashes after off-chain steps complete. Used f ##### `startRamp(rampId: string): Promise` Starts a registered ramp process. +##### `getUserTransactionType(tx: UnsignedTx): "evm-typed-data" | "evm-transaction" | "unsupported"` +Classifies a user-owned transaction returned by `registerRamp`. Unsupported transactions require a network-specific wallet flow outside the SDK helpers. + +##### `getTypedDataToSign(tx: UnsignedTx, options?: { includeDomainType?: boolean }): SignedTypedData[]` +Returns the EIP-712 payloads to sign for an `"evm-typed-data"` transaction. Sign every payload in order and submit the signatures with `submitUserSignature`. + +##### `submitUserSignature(rampId: string, tx: UnsignedTx, signatures: string | string[]): Promise` +Attaches user EIP-712 signatures to the original unsigned transaction and submits them to Vortex. + +##### `getTransactionToBroadcast(tx: UnsignedTx): EvmTransactionData` +Returns the EVM transaction data for an `"evm-transaction"` transaction. Throws for typed-data or unsupported transaction shapes. + +##### `submitUserTxHash(rampId: string, tx: UnsignedTx, hash: string): Promise` +Submits the on-chain transaction hash for a user-broadcast EVM transaction. + +##### `submitUserTransactions(rampId: string, unsignedTransactions: UnsignedTx[], handlers: SubmitUserTransactionsHandlers): Promise` +Processes every user-owned transaction returned by `registerRamp`. The SDK classifies each entry, calls your `signTypedData` or `sendTransaction` callback, and submits the resulting signatures or hashes back to Vortex. Wallet prompts stay under your application's control; the SDK never receives a wallet object or private key. + ## Error Handling ### Ephemeral Account Freshness diff --git a/packages/sdk/bunfig.toml b/packages/sdk/bunfig.toml new file mode 100644 index 000000000..d0707c586 --- /dev/null +++ b/packages/sdk/bunfig.toml @@ -0,0 +1,6 @@ +[test] +# Coverage report config; the gate itself lives in scripts/check-coverage.ts. +# Without the ignore pattern, the built shared bundle dominates the denominator +# and the ratchet measures shared's coverage instead of the SDK's. +coverageSkipTestFiles = true +coveragePathIgnorePatterns = ["**/packages/shared/dist/**"] diff --git a/packages/sdk/examples/exampleBrlOfframp.ts b/packages/sdk/examples/exampleBrlOfframp.ts index 7c2ec213c..3b026026b 100644 --- a/packages/sdk/examples/exampleBrlOfframp.ts +++ b/packages/sdk/examples/exampleBrlOfframp.ts @@ -1,10 +1,16 @@ -// @ts-nocheck - import * as readline from "readline"; -import { EvmToken, EvmTransactionData, FiatToken, Networks, RampDirection } from "../src/index"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "../src/index"; import { VortexSdkConfig } from "../src/types"; import { VortexSdk } from "../src/VortexSdk"; +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}. Copy .env.example to .env and set it.`); + } + return value; +} + async function runBrlOfframpExample() { const askQuestion = (query: string): Promise => { const rl = readline.createInterface({ @@ -25,13 +31,13 @@ async function runBrlOfframpExample() { console.log("📝 Step 1: Initializing VortexSdk..."); const config: VortexSdkConfig = { - apiBaseUrl: "http://localhost:3000", + apiBaseUrl: process.env.VORTEX_API_URL ?? "http://localhost:3000", autoReconnect: true, // Optional: provide custom WebSocket URLs moonbeamWsUrl: undefined, pendulumWsUrl: undefined, // 'wss://custom-moonbeam-rpc.com', - publicKey: "pk_live_REPLACEME", // 'wss://custom-pendulum-rpc.com', - secretKey: "sk_live_REPLACEME", // default is `true` + publicKey: requireEnv("VORTEX_PUBLIC_KEY"), // 'wss://custom-pendulum-rpc.com', + secretKey: requireEnv("VORTEX_SECRET_KEY"), // default is `true` // Optional: store ephemeral keys for debug storeEphemeralKeys: true // default is `true` }; @@ -48,7 +54,7 @@ async function runBrlOfframpExample() { network: Networks.Polygon, outputCurrency: FiatToken.BRL, rampType: RampDirection.SELL, - to: "pix" as const + to: EPaymentMethod.PIX }; const quote = await sdk.createQuote(quoteRequest); @@ -60,10 +66,10 @@ async function runBrlOfframpExample() { console.log(` Expires at: ${quote.expiresAt}\n`); const brlOfframpData = { - pixDestination: "157.492.981-08", - receiverTaxId: "157.492.981-08", - taxId: "157.492.981-08", - walletAddress: "0x1234567890123456789012345678901234567890" + pixDestination: requireEnv("BRL_PIX_DESTINATION"), + receiverTaxId: requireEnv("BRL_RECEIVER_TAX_ID"), + taxId: requireEnv("BRL_TAX_ID"), + walletAddress: requireEnv("WALLET_ADDRESS") }; const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, brlOfframpData); @@ -74,7 +80,7 @@ async function runBrlOfframpExample() { // The unsignedTransactions object will always return the transactions the user must sign and broadcast, please check the docs for more information https://api-docs.vortexfinance.co/vortex-sdk-1289458m0. console.log(" Unsigned transactions:"); unsignedTransactions.forEach(tx => { - const { to, data, value } = tx.txData as EvmTransactionData; + const { to, data, value } = sdk.getTransactionToBroadcast(tx); console.log(` - ${tx.phase}: Send to ${to} data ${data} with value ${value}`); }); console.log(""); diff --git a/packages/sdk/examples/exampleBrlOnramp.ts b/packages/sdk/examples/exampleBrlOnramp.ts index da815795a..855329803 100644 --- a/packages/sdk/examples/exampleBrlOnramp.ts +++ b/packages/sdk/examples/exampleBrlOnramp.ts @@ -2,19 +2,27 @@ import { CreateQuoteRequest, EPaymentMethod, EvmToken, FiatToken, Networks, Quot import { VortexSdkConfig } from "../src/types"; import { VortexSdk } from "../src/VortexSdk"; +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}. Copy .env.example to .env and set it.`); + } + return value; +} + async function runBrlOnrampExample() { try { console.log("Starting BRL Onramp Example...\n"); console.log("📝 Step 1: Initializing VortexSdk..."); const config: VortexSdkConfig = { - apiBaseUrl: "http://localhost:3000", + apiBaseUrl: process.env.VORTEX_API_URL ?? "http://localhost:3000", autoReconnect: true, // Optional: provide custom WebSocket URLs moonbeamWsUrl: undefined, pendulumWsUrl: undefined, // 'wss://custom-moonbeam-rpc.com', - publicKey: "pk_live_REPLACEME", // 'wss://custom-pendulum-rpc.com', - secretKey: "sk_live_REPLACEME", // default is `true` + publicKey: requireEnv("VORTEX_PUBLIC_KEY"), // 'wss://custom-pendulum-rpc.com', + secretKey: requireEnv("VORTEX_SECRET_KEY"), // default is `true` // Optional: store ephemeral keys for later use storeEphemeralKeys: true // default is `true` }; @@ -27,13 +35,12 @@ async function runBrlOnrampExample() { console.log("📝 Step 2: Creating quote for BRL onramp..."); const quoteRequest: CreateQuoteRequest = { from: EPaymentMethod.PIX, - inputAmount: "100", + inputAmount: "5", inputCurrency: FiatToken.BRL, network: Networks.Polygon, outputCurrency: EvmToken.USDC, rampType: RampDirection.BUY, to: Networks.Polygon - //partnerId: "example-partner" }; const quote = (await sdk.createQuote(quoteRequest)) as QuoteResponse; @@ -45,8 +52,8 @@ async function runBrlOnrampExample() { console.log(` Expires at: ${quote.expiresAt}\n`); const brlOnrampData = { - destinationAddress: "0x1234567890123456789012345678901234567890", - taxId: "123.456.789-00" + destinationAddress: requireEnv("DESTINATION_ADDRESS"), + taxId: requireEnv("BRL_TAX_ID") }; const { rampProcess } = await sdk.registerRamp(quote, brlOnrampData); diff --git a/packages/sdk/examples/exampleEurOfframp.ts b/packages/sdk/examples/exampleEurOfframp.ts new file mode 100644 index 000000000..b15ca705a --- /dev/null +++ b/packages/sdk/examples/exampleEurOfframp.ts @@ -0,0 +1,121 @@ +import * as readline from "readline"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "../src/index"; +import { VortexSdkConfig } from "../src/types"; +import { VortexSdk } from "../src/VortexSdk"; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}. Copy .env.example to .env and set it.`); + } + return value; +} + +async function runEurOfframpExample() { + const askQuestion = (query: string): Promise => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + return new Promise(resolve => { + rl.question(query, (ans: string) => { + rl.close(); + resolve(ans.trim()); + }); + }); + }; + + try { + console.log("Starting EUR Offramp Example...\n"); + + console.log("📝 Step 1: Initializing VortexSdk..."); + const config: VortexSdkConfig = { + apiBaseUrl: process.env.VORTEX_API_URL ?? "http://localhost:3000", + autoReconnect: true, + publicKey: requireEnv("VORTEX_PUBLIC_KEY"), + secretKey: requireEnv("VORTEX_SECRET_KEY"), + storeEphemeralKeys: true + }; + + const sdk = new VortexSdk(config); + console.log("✅ VortexSdk initialized successfully\n"); + + console.log("📝 Step 2: Creating quote for EUR offramp (USDC on Polygon -> EUR via SEPA)..."); + const quoteRequest = { + from: Networks.Polygon, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Polygon, + outputCurrency: FiatToken.EURC, + rampType: RampDirection.SELL, + to: EPaymentMethod.SEPA + }; + + const quote = await sdk.createQuote(quoteRequest); + console.log("✅ Quote created successfully:"); + console.log(` Quote ID: ${quote.id}`); + console.log(` Input: ${quote.inputAmount} ${quote.inputCurrency}`); + console.log(` Output: ${quote.outputAmount} ${quote.outputCurrency}`); + console.log(` Total Fee: ${quote.totalFeeFiat} ${quote.feeCurrency}`); + console.log(` Expires at: ${quote.expiresAt}\n`); + + const eurOfframpData = { + destinationAddress: requireEnv("DESTINATION_ADDRESS"), + email: process.env.USER_EMAIL ?? "user@example.com", + ipAddress: process.env.USER_IP_ADDRESS ?? "203.0.113.1", + walletAddress: requireEnv("WALLET_ADDRESS") + }; + + console.log("📝 Step 3: Registering EUR offramp..."); + const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, eurOfframpData); + + console.log("✅ EUR Offramp registered successfully:"); + console.log(` Ramp ID: ${rampProcess.id}`); + + console.log(" Unsigned transactions:"); + unsignedTransactions.forEach(tx => { + const txType = sdk.getUserTransactionType(tx); + console.log(` - ${tx.phase} (${txType}): signer ${tx.signer} on ${tx.network}`); + }); + console.log(""); + + console.log( + "\n🛑 Complete the token payment on-chain now. Execute the transactions shown above (squidRouterApprove and squidRouterSwap), and save the corresponding transaction hashes." + ); + + const squidRouterApproveHash = await askQuestion("➡️ Enter the Squid Router Approve Hash: "); + const squidRouterSwapHash = await askQuestion("➡️ Enter the Squid Router Swap Hash: "); + + console.log("\n📝 Step 4: Updating EUR offramp..."); + const transactionHashes = { + squidRouterApproveHash, + squidRouterSwapHash + }; + + await sdk.updateRamp(quote, rampProcess.id, transactionHashes); + console.log("✅ EUR Offramp updated successfully."); + + console.log("\n📝 Step 5: Starting EUR offramp..."); + await sdk.startRamp(rampProcess.id); + console.log("✅ EUR Offramp started successfully."); + } catch (error) { + console.error("❌ Error in EUR Offramp Example:", error); + if (error instanceof Error) { + console.error("Error message:", error.message); + } + process.exit(1); + } +} + +if (require.main === module) { + runEurOfframpExample() + .then(() => { + console.log("\n✨ Example execution completed"); + process.exit(0); + }) + .catch(error => { + console.error("\n💥 Example execution failed:", error); + process.exit(1); + }); +} diff --git a/packages/sdk/examples/exampleEurOnramp.ts b/packages/sdk/examples/exampleEurOnramp.ts new file mode 100644 index 000000000..9cd89e1de --- /dev/null +++ b/packages/sdk/examples/exampleEurOnramp.ts @@ -0,0 +1,95 @@ +import { CreateQuoteRequest, EPaymentMethod, EvmToken, FiatToken, Networks, QuoteResponse, RampDirection } from "../src/index"; +import { VortexSdkConfig } from "../src/types"; +import { VortexSdk } from "../src/VortexSdk"; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}. Copy .env.example to .env and set it.`); + } + return value; +} + +async function runEurOnrampExample() { + try { + console.log("Starting EUR Onramp Example...\n"); + + console.log("📝 Step 1: Initializing VortexSdk..."); + const config: VortexSdkConfig = { + apiBaseUrl: process.env.VORTEX_API_URL ?? "http://localhost:3000", + autoReconnect: true, + publicKey: requireEnv("VORTEX_PUBLIC_KEY"), + secretKey: requireEnv("VORTEX_SECRET_KEY"), + storeEphemeralKeys: true + }; + + const sdk = new VortexSdk(config); + console.log("✅ VortexSdk initialized successfully\n"); + + console.log("📝 Step 2: Creating quote for EUR onramp (EUR via SEPA -> USDC on Base)..."); + const quoteRequest: CreateQuoteRequest = { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base + }; + + const quote = (await sdk.createQuote(quoteRequest)) as QuoteResponse; + console.log("✅ Quote created successfully:"); + console.log(` Quote ID: ${quote.id}`); + console.log(` Input: ${quote.inputAmount} ${quote.inputCurrency}`); + console.log(` Output: ${quote.outputAmount} ${quote.outputCurrency}`); + console.log(` Total Fee: ${quote.totalFeeFiat} ${quote.feeCurrency}`); + console.log(` Expires at: ${quote.expiresAt}\n`); + + const eurOnrampData = { + destinationAddress: requireEnv("DESTINATION_ADDRESS"), + email: process.env.USER_EMAIL ?? "user@example.com", + ipAddress: process.env.USER_IP_ADDRESS ?? "203.0.113.1" + }; + + console.log("📝 Step 3: Registering EUR onramp..."); + const { rampProcess } = await sdk.registerRamp(quote, eurOnrampData); + + console.log("✅ EUR Onramp registered successfully:"); + console.log(` Ramp ID: ${rampProcess.id}`); + + // IBAN payment instructions are returned in rampProcess.ibanPaymentData after registration. + // The user must complete the SEPA transfer before starting the ramp. + if (rampProcess.ibanPaymentData) { + console.log(" IBAN Payment Instructions:"); + console.log(` IBAN: ${rampProcess.ibanPaymentData.iban}`); + console.log(` Receiver: ${rampProcess.ibanPaymentData.receiverName}`); + console.log(` Reference: ${rampProcess.ibanPaymentData.reference}`); + } + + // Step 4: Start the ramp AFTER making the SEPA payment + console.log("\n📝 Step 4: Starting EUR onramp (ensure SEPA payment is completed first)..."); + await sdk.startRamp(rampProcess.id); + console.log("✅ EUR Onramp started."); + } catch (error) { + console.error("❌ Error in EUR Onramp Example:", error); + + if (error instanceof Error) { + console.error("Error message:", error.message); + console.error("Error stack:", error.stack); + } + + process.exit(1); + } +} + +if (require.main === module) { + runEurOnrampExample() + .then(() => { + console.log("\n✨ Example execution completed"); + process.exit(0); + }) + .catch(error => { + console.error("\n💥 Example execution failed:", error); + process.exit(1); + }); +} diff --git a/packages/sdk/examples/exampleMxnOfframp.ts b/packages/sdk/examples/exampleMxnOfframp.ts new file mode 100644 index 000000000..6d6494b6d --- /dev/null +++ b/packages/sdk/examples/exampleMxnOfframp.ts @@ -0,0 +1,156 @@ +// Manual end-to-end example for the Mexico (MXN) Alfredpay offramp. MXN settles via SPEI +// (EPaymentMethod.SPEI). Offramp: USDC -> MXN. The SDK returns user-side EVM transactions to +// sign; push the resulting hashes/signatures back via submitUserTransactions, then start. +// fiatAccountId is REQUIRED here. +// +// Requires a running backend (default http://localhost:3000) with Alfredpay enabled. +// +// Prerequisites: authenticate with the user's own user-linked secretKey (their sk_* key), and +// that same user must have completed Alfredpay MXN KYC. Onboard the user through the Vortex app +// first; the SDK cannot mint keys or run KYC. Also needs that user's FIAT_ACCOUNT_ID below. +// +// Config is read from packages/sdk/.env (bun auto-loads it). See .env.example. +// Env: VORTEX_API_URL, VORTEX_PUBLIC_KEY, VORTEX_SECRET_KEY (user-linked), +// OFFRAMP_WALLET_PRIVATE_KEY, FIAT_ACCOUNT_ID. +// +// Run: +// cd packages/sdk +// bun run examples/exampleMxnOfframp.ts + +import * as readline from "readline"; +import { privateKeyToAccount } from "viem/accounts"; +import { CreateQuoteRequest, EPaymentMethod, EvmToken, FiatToken, Networks, QuoteResponse, RampDirection } from "../src/index"; +import { VortexSdkConfig } from "../src/types"; +import { VortexSdk } from "../src/VortexSdk"; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}. Copy .env.example to .env and set it.`); + } + return value; +} + +// Optional: sign offramp permits locally (viem) with a throwaway wallet that holds the test USDC. +// viem derives the EIP712Domain canonically, so its signatures match the backend's ethers +// verifyTypedData exactly. If unset, you sign in your own wallet and paste the signature — but then +// WALLET_ADDRESS must be set to the address you'll sign with. +const OFFRAMP_WALLET_PRIVATE_KEY = process.env.OFFRAMP_WALLET_PRIVATE_KEY as `0x${string}` | undefined; +const OFFRAMP_WALLET_ADDRESS = OFFRAMP_WALLET_PRIVATE_KEY + ? privateKeyToAccount(OFFRAMP_WALLET_PRIVATE_KEY).address + : requireEnv("WALLET_ADDRESS"); +const FIAT_ACCOUNT_ID = requireEnv("FIAT_ACCOUNT_ID"); + +async function runMxnOfframpExample() { + const askQuestion = (query: string): Promise => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + return new Promise(resolve => { + rl.question(query, (ans: string) => { + rl.close(); + resolve(ans.trim()); + }); + }); + }; + + try { + console.log("Starting MXN Offramp Example...\n"); + + console.log("📝 Step 1: Initializing VortexSdk..."); + const config: VortexSdkConfig = { + apiBaseUrl: process.env.VORTEX_API_URL ?? "http://localhost:3000", + autoReconnect: true, + publicKey: requireEnv("VORTEX_PUBLIC_KEY"), + // Must be the user's user-linked sk_* key, not a partner-only key. + secretKey: requireEnv("VORTEX_SECRET_KEY"), + storeEphemeralKeys: true + }; + + const sdk = new VortexSdk(config); + console.log("✅ VortexSdk initialized successfully\n"); + + console.log("📝 Step 2: Creating quote for MXN offramp (USDC -> MXN via SPEI)..."); + const quoteRequest: CreateQuoteRequest = { + from: Networks.Polygon, + inputAmount: "10", + inputCurrency: EvmToken.USDC, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: EPaymentMethod.SPEI + }; + + const quote = (await sdk.createQuote(quoteRequest)) as QuoteResponse; + console.log("✅ Quote created successfully:"); + console.log(` Quote ID: ${quote.id}`); + console.log(` Input: ${quote.inputAmount} ${quote.inputCurrency}`); + console.log(` Output: ${quote.outputAmount} ${quote.outputCurrency}`); + console.log(` Total Fee: ${quote.totalFeeFiat} ${quote.feeCurrency}`); + console.log(` Expires at: ${quote.expiresAt}\n`); + + console.log("📝 Step 3: Registering offramp (fiatAccountId + walletAddress required)..."); + const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { + fiatAccountId: FIAT_ACCOUNT_ID, + walletAddress: OFFRAMP_WALLET_ADDRESS + }); + console.log(`✅ MXN Offramp registered successfully. Ramp ID: ${rampProcess.id}`); + + const localAccount = OFFRAMP_WALLET_PRIVATE_KEY ? privateKeyToAccount(OFFRAMP_WALLET_PRIVATE_KEY) : undefined; + + await sdk.submitUserTransactions(rampProcess.id, unsignedTransactions, { + sendTransaction: async (evmTx, { unsignedTransaction }) => { + console.log( + `\n🛑 Broadcast ${unsignedTransaction.phase} from your wallet: to=${evmTx.to} value=${evmTx.value} data=${evmTx.data}` + ); + const hash = await askQuestion(`➡️ Tx hash for ${unsignedTransaction.phase}: `); + console.log(`✅ Received hash for ${unsignedTransaction.phase}.`); + return hash; + }, + signTypedData: async (payload, { payloadCount, payloadIndex, unsignedTransaction }) => { + if (localAccount) { + const signature = await localAccount.signTypedData(payload as Parameters[0]); + console.log( + ` [${payloadIndex + 1}/${payloadCount}] ${payload.primaryType} signed locally by ${localAccount.address}` + ); + return signature; + } + + const [v4Payload] = sdk + .getTypedDataToSign(unsignedTransaction, { includeDomainType: true }) + .slice(payloadIndex, payloadIndex + 1); + console.log( + `\n----- sign payload ${payloadIndex + 1}/${payloadCount}: ${payload.primaryType} (account ${unsignedTransaction.signer}) -----` + ); + console.log(JSON.stringify(v4Payload, null, 2)); + const signature = await askQuestion(`➡️ Signature for ${payload.primaryType}: `); + console.log(`✅ Received signature for ${unsignedTransaction.phase}.`); + return signature; + } + }); + + console.log("📝 Step 4: Starting offramp..."); + await sdk.startRamp(rampProcess.id); + console.log("✅ MXN Offramp started successfully."); + } catch (error) { + console.error("❌ Error in MXN Offramp Example:", error); + if (error instanceof Error) { + console.error("Error message:", error.message); + } + process.exit(1); + } +} + +if (require.main === module) { + runMxnOfframpExample() + .then(() => { + console.log("\n✨ Example execution completed"); + process.exit(0); + }) + .catch(error => { + console.error("\n💥 Example execution failed:", error); + process.exit(1); + }); +} diff --git a/packages/sdk/examples/exampleMxnOnramp.ts b/packages/sdk/examples/exampleMxnOnramp.ts new file mode 100644 index 000000000..14712c9c3 --- /dev/null +++ b/packages/sdk/examples/exampleMxnOnramp.ts @@ -0,0 +1,126 @@ +// Manual end-to-end example for the Mexico (MXN) Alfredpay onramp. MXN settles via SPEI +// (EPaymentMethod.SPEI). Onramp: MXN -> USDC. The user pays the SPEI instructions; crypto +// lands at destinationAddress. No user-signed transactions; fiatAccountId is NOT required. +// +// Requires a running backend (default http://localhost:3000) with Alfredpay enabled. +// +// Prerequisites: authenticate with the user's own user-linked secretKey (their sk_* key), and +// that same user must have completed Alfredpay MXN KYC. Onboard the user through the Vortex app +// first; the SDK cannot mint keys or run KYC. +// +// Config is read from packages/sdk/.env (bun auto-loads it). See .env.example. +// Env: VORTEX_API_URL, VORTEX_PUBLIC_KEY, VORTEX_SECRET_KEY (user-linked), +// DESTINATION_ADDRESS, WALLET_ADDRESS. +// +// Run: +// cd packages/sdk +// bun run examples/exampleMxnOnramp.ts + +import * as fs from "fs"; +import { CreateQuoteRequest, EPaymentMethod, EvmToken, FiatToken, Networks, QuoteResponse, RampDirection } from "../src/index"; +import { VortexSdkConfig } from "../src/types"; +import { VortexSdk } from "../src/VortexSdk"; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}. Copy .env.example to .env and set it.`); + } + return value; +} + +const DESTINATION_ADDRESS = requireEnv("DESTINATION_ADDRESS"); +const WALLET_ADDRESS = requireEnv("WALLET_ADDRESS"); + +// USDT on Polygon — the token Alfredpay mints; what you deposit to the ephemeral to simulate the fiat pay-in. +const USDT_POLYGON_ADDRESS = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"; // 6 decimals + +// The SDK writes ephemeral keys to ./ephemerals_.json (storeEphemeralKeys: true). +function readEphemeralEvmAddress(rampId: string): string | undefined { + try { + const items = JSON.parse(fs.readFileSync(`ephemerals_${rampId}.json`, "utf-8")); + return items.find((i: { type: string; address: string }) => i.type === "EVM")?.address; + } catch { + return undefined; + } +} + +async function runMxnOnrampExample() { + try { + console.log("Starting MXN Onramp Example...\n"); + + console.log("📝 Step 1: Initializing VortexSdk..."); + const config: VortexSdkConfig = { + apiBaseUrl: process.env.VORTEX_API_URL ?? "http://localhost:3000", + autoReconnect: true, + publicKey: requireEnv("VORTEX_PUBLIC_KEY"), + // Must be the user's user-linked sk_* key, not a partner-only key. + secretKey: requireEnv("VORTEX_SECRET_KEY"), + storeEphemeralKeys: true + }; + + const sdk = new VortexSdk(config); + console.log("✅ VortexSdk initialized successfully\n"); + + console.log("📝 Step 2: Creating quote for MXN onramp (MXN -> USDC via SPEI)..."); + const quoteRequest: CreateQuoteRequest = { + from: EPaymentMethod.SPEI, + inputAmount: "150", + inputCurrency: FiatToken.MXN, + network: Networks.Polygon, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Polygon + }; + + const quote = (await sdk.createQuote(quoteRequest)) as QuoteResponse; + console.log("✅ Quote created successfully:"); + console.log(` Quote ID: ${quote.id}`); + console.log(` Input: ${quote.inputAmount} ${quote.inputCurrency}`); + console.log(` Output: ${quote.outputAmount} ${quote.outputCurrency}`); + console.log(` Total Fee: ${quote.totalFeeFiat} ${quote.feeCurrency}`); + console.log(` Expires at: ${quote.expiresAt}\n`); + + console.log("📝 Step 3: Registering onramp (destinationAddress only — fiatAccountId optional)..."); + const { rampProcess } = await sdk.registerRamp(quote, { + destinationAddress: DESTINATION_ADDRESS, + walletAddress: WALLET_ADDRESS + }); + console.log(`✅ MXN Onramp registered successfully. Ramp ID: ${rampProcess.id}`); + + console.log("📝 Step 4: Starting onramp..."); + const startedRamp = await sdk.startRamp(rampProcess.id); + + // To complete the onramp WITHOUT paying fiat, deposit USDT to the ephemeral so the + // alfredpayOnrampMint balance check passes (it trusts the on-chain balance as ground truth). + const ephemeralEvmAddress = readEphemeralEvmAddress(rampProcess.id); + console.log("\n🏦 To complete the onramp, deposit USDT on POLYGON to the ephemeral address:"); + console.log(` • Send USDT to: ${ephemeralEvmAddress ?? ``}`); + console.log(` • USDT token (Polygon): ${USDT_POLYGON_ADDRESS} (6 decimals)`); + console.log(` • Amount: a little more than ${quote.outputAmount} USDC-equivalent in USDT`); + console.log(" (exact raw amount is in the backend 'AlfredpayOnrampMintHandler: Waiting for ...' log)"); + + if (startedRamp.achPaymentData) { + console.log("\n(SPEI fiat instructions, not needed for the deposit-to-ephemeral test):"); + console.log(startedRamp.achPaymentData); + } + } catch (error) { + console.error("❌ Error in MXN Onramp Example:", error); + if (error instanceof Error) { + console.error("Error message:", error.message); + } + process.exit(1); + } +} + +if (require.main === module) { + runMxnOnrampExample() + .then(() => { + console.log("\n✨ Example execution completed"); + process.exit(0); + }) + .catch(error => { + console.error("\n💥 Example execution failed:", error); + process.exit(1); + }); +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e18c2109e..fe23d8520 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "@vortexfi/shared": "=0.1.2" + "@vortexfi/shared": "=0.2.0" }, "devDependencies": { "@types/bun": "^1.3.1", @@ -46,10 +46,11 @@ "format": "prettier --write .", "lint": "eslint . --ext .ts", "prepublishOnly": "bun run build", - "test": "bun run build && node -e \"require('./dist/index.js')\"", + "test": "bun test && bun run build && node -e \"require('./dist/index.js')\"", + "test:coverage": "bun test --coverage --coverage-reporter=lcov && bun ../../scripts/check-coverage.ts coverage/lcov.info 0.32 0.15", "typecheck": "bun x --bun tsc" }, "type": "module", "types": "./dist/index.d.ts", - "version": "0.6.0" + "version": "0.7.0" } diff --git a/packages/sdk/scripts/create-api-key.ts b/packages/sdk/scripts/create-api-key.ts new file mode 100644 index 000000000..233c20e43 --- /dev/null +++ b/packages/sdk/scripts/create-api-key.ts @@ -0,0 +1,87 @@ +// Create a new public + secret API key pair (POST /v1/api-keys). +// Requires a valid auth token from scripts/login.ts. +// +// Run: +// cd packages/sdk +// bun run scripts/create-api-key.ts +// +// Env overrides: +// API_BASE_URL default http://localhost:3000 +// AUTH_TOKEN_FILE default .auth-token.json +// API_KEY_NAME default sdk-test +// API_KEY_OUTFILE default .api-key.json + +import * as fs from "fs"; + +const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:3000"; +const AUTH_TOKEN_FILE = process.env.AUTH_TOKEN_FILE ?? ".auth-token.json"; +const API_KEY_NAME = process.env.API_KEY_NAME ?? "sdk-test"; +const API_KEY_OUTFILE = process.env.API_KEY_OUTFILE ?? ".api-key.json"; + +interface AuthToken { + accessToken: string; + userId: string; +} + +interface ApiKeyResponse { + createdAt: string; + expiresAt: string; + isActive: boolean; + publicKey: { id: string; key: string; keyPrefix: string; name: string; type: "public" }; + secretKey: { id: string; key: string; keyPrefix: string; name: string; type: "secret" }; +} + +function loadAuthToken(): AuthToken { + if (!fs.existsSync(AUTH_TOKEN_FILE)) { + throw new Error(`No ${AUTH_TOKEN_FILE} found. Run scripts/login.ts first.`); + } + return JSON.parse(fs.readFileSync(AUTH_TOKEN_FILE, "utf-8")) as AuthToken; +} + +async function main(): Promise { + const auth = loadAuthToken(); + + console.log(`🗝️ Creating api-key "${API_KEY_NAME}" ...`); + const response = await fetch(`${API_BASE_URL}/v1/api-keys`, { + body: JSON.stringify({ name: API_KEY_NAME }), + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${response.status} /v1/api-keys: ${text}`); + } + const keyPair = JSON.parse(text) as ApiKeyResponse; + + console.log(" publicKey:", keyPair.publicKey.key); + console.log(" secretKey:", keyPair.secretKey.key, " (shown once)"); + + const out = { + apiUrl: API_BASE_URL, + createdAt: keyPair.createdAt, + expiresAt: keyPair.expiresAt, + name: API_KEY_NAME, + publicKey: keyPair.publicKey.key, + publicKeyId: keyPair.publicKey.id, + secretKey: keyPair.secretKey.key, + secretKeyId: keyPair.secretKey.id, + userId: auth.userId + }; + fs.writeFileSync(API_KEY_OUTFILE, JSON.stringify(out, null, 2)); + console.log(`\n💾 Wrote ${API_KEY_OUTFILE}`); +} + +if (import.meta.main) { + main() + .then(() => { + console.log("\n✨ Done"); + process.exit(0); + }) + .catch(error => { + console.error("\n💥 Failed:", error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/packages/sdk/scripts/delete-api-key.ts b/packages/sdk/scripts/delete-api-key.ts new file mode 100644 index 000000000..2833bbeff --- /dev/null +++ b/packages/sdk/scripts/delete-api-key.ts @@ -0,0 +1,136 @@ +// Delete (revoke) a user API key pair (DELETE /v1/api-keys/:keyId). +// Requires a valid auth token from scripts/login.ts. +// +// Select a key; if its paired counterpart (same base name, opposite type) exists, +// the script asks whether to delete both together. +// +// Run: +// cd packages/sdk +// bun run scripts/delete-api-key.ts +// +// Env overrides: +// API_BASE_URL default http://localhost:3000 +// AUTH_TOKEN_FILE default .auth-token.json + +import * as fs from "fs"; +import * as readline from "readline"; + +const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:3000"; +const AUTH_TOKEN_FILE = process.env.AUTH_TOKEN_FILE ?? ".auth-token.json"; + +interface AuthToken { + accessToken: string; +} + +interface ApiKeyEntry { + id: string; + key?: string; + name: string; + type: "public" | "secret"; +} + +interface ListApiKeysResponse { + apiKeys: ApiKeyEntry[]; +} + +function stripSuffix(name: string): string { + return name.replace(/\s*\((Public|Secret)\)$/, ""); +} + +function askQuestion(query: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(query, (ans: string) => { + rl.close(); + resolve(ans.trim()); + }); + }); +} + +function loadAuthToken(): AuthToken { + if (!fs.existsSync(AUTH_TOKEN_FILE)) { + throw new Error(`No ${AUTH_TOKEN_FILE} found. Run scripts/login.ts first.`); + } + return JSON.parse(fs.readFileSync(AUTH_TOKEN_FILE, "utf-8")) as AuthToken; +} + +async function main(): Promise { + const auth = loadAuthToken(); + + console.log("📋 Fetching API keys ..."); + const response = await fetch(`${API_BASE_URL}/v1/api-keys`, { + headers: { Authorization: `Bearer ${auth.accessToken}` } + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${response.status} /v1/api-keys: ${text}`); + } + const data = JSON.parse(text) as ListApiKeysResponse; + + if (data.apiKeys.length === 0) { + console.log("No active API keys to delete."); + return; + } + + console.log("\nActive keys:\n"); + data.apiKeys.forEach((key, i) => { + const typeLabel = key.type === "public" ? "PUBLIC" : "SECRET"; + const displayKey = key.key ?? "(hidden)"; + console.log(` ${i + 1}. [${key.id}] ${typeLabel} ${key.name} — ${displayKey}`); + }); + + const choice = await askQuestion(`\n➡️ Enter the number (1-${data.apiKeys.length}) of the key to delete: `); + const index = Number.parseInt(choice, 10) - 1; + if (Number.isNaN(index) || index < 0 || index >= data.apiKeys.length) { + throw new Error(`Invalid selection: "${choice}"`); + } + + const selected = data.apiKeys[index]; + const baseName = stripSuffix(selected.name); + const paired = data.apiKeys.find(k => k.id !== selected.id && k.type !== selected.type && stripSuffix(k.name) === baseName); + + let pairedKeyId: string | undefined; + let keyId = selected.id; + + if (paired) { + const typeLabel = paired.type === "public" ? "PUBLIC" : "SECRET"; + console.log(`\n🔗 Found paired ${typeLabel} key: ${paired.id} (${paired.name})`); + + const deleteBoth = await askQuestion("➡️ Delete both as a pair? (y/N): "); + if (deleteBoth.toLowerCase() === "y") { + pairedKeyId = selected.type === "secret" ? paired.id : selected.id; + keyId = selected.type === "secret" ? selected.id : paired.id; + console.log(`\n🗑️ Revoking key pair: ${keyId} + ${pairedKeyId}`); + } + } + + if (!pairedKeyId) { + console.log(`\n🗑️ Revoking key: ${selected.id} (${selected.type} — ${selected.name})`); + } + + const deleteResponse = await fetch(`${API_BASE_URL}/v1/api-keys/${keyId}`, { + body: pairedKeyId ? JSON.stringify({ pairedKeyId }) : undefined, + headers: { + ...(pairedKeyId ? { "Content-Type": "application/json" } : {}), + Authorization: `Bearer ${auth.accessToken}` + }, + method: "DELETE" + }); + if (!deleteResponse.ok) { + const errText = await deleteResponse.text(); + throw new Error(`${deleteResponse.status} /v1/api-keys/${keyId}: ${errText}`); + } + console.log(pairedKeyId ? "✅ Key pair revoked." : "✅ Key revoked."); +} + +if (import.meta.main) { + main() + .then(() => { + console.log("\n✨ Done"); + process.exit(0); + }) + .catch(error => { + console.error("\n💥 Failed:", error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/packages/sdk/scripts/fetch-api-keys.ts b/packages/sdk/scripts/fetch-api-keys.ts new file mode 100644 index 000000000..df987b3a0 --- /dev/null +++ b/packages/sdk/scripts/fetch-api-keys.ts @@ -0,0 +1,90 @@ +// List active user API keys (GET /v1/api-keys). +// Requires a valid auth token from scripts/login.ts. +// +// Run: +// cd packages/sdk +// bun run scripts/fetch-api-keys.ts +// +// Env overrides: +// API_BASE_URL default http://localhost:3000 +// AUTH_TOKEN_FILE default .auth-token.json + +import * as fs from "fs"; + +const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:3000"; +const AUTH_TOKEN_FILE = process.env.AUTH_TOKEN_FILE ?? ".auth-token.json"; + +interface AuthToken { + accessToken: string; +} + +interface ApiKeyEntry { + createdAt: string; + expiresAt: string; + id: string; + isActive: boolean; + key?: string; + keyPrefix: string; + lastUsedAt: string | null; + name: string; + type: "public" | "secret"; + updatedAt: string; +} + +interface ListApiKeysResponse { + apiKeys: ApiKeyEntry[]; +} + +function loadAuthToken(): AuthToken { + if (!fs.existsSync(AUTH_TOKEN_FILE)) { + throw new Error(`No ${AUTH_TOKEN_FILE} found. Run scripts/login.ts first.`); + } + return JSON.parse(fs.readFileSync(AUTH_TOKEN_FILE, "utf-8")) as AuthToken; +} + +async function main(): Promise { + const auth = loadAuthToken(); + + console.log("📋 Fetching API keys ..."); + const response = await fetch(`${API_BASE_URL}/v1/api-keys`, { + headers: { Authorization: `Bearer ${auth.accessToken}` } + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${response.status} /v1/api-keys: ${text}`); + } + const data = JSON.parse(text) as ListApiKeysResponse; + + if (data.apiKeys.length === 0) { + console.log("No active API keys found."); + return; + } + + console.log(`\n${data.apiKeys.length} active key(s):\n`); + for (const key of data.apiKeys) { + const typeLabel = key.type === "public" ? "PUBLIC (pk_*)" : "SECRET (sk_*)"; + const displayKey = key.key ?? "(only shown at creation)"; + console.log(` ${key.id}`); + console.log(` Type: ${typeLabel}`); + console.log(` Name: ${key.name}`); + console.log(` Prefix: ${key.keyPrefix}`); + console.log(` Key: ${displayKey}`); + console.log(` Created: ${key.createdAt}`); + console.log(` Expires: ${key.expiresAt}`); + console.log(` Last used: ${key.lastUsedAt ?? "never"}`); + console.log(` Active: ${key.isActive}`); + console.log(); + } +} + +if (import.meta.main) { + main() + .then(() => { + console.log("✨ Done"); + process.exit(0); + }) + .catch(error => { + console.error("\n💥 Failed:", error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/packages/sdk/scripts/login-and-create-api-key.ts b/packages/sdk/scripts/login-and-create-api-key.ts new file mode 100644 index 000000000..b0f3083cd --- /dev/null +++ b/packages/sdk/scripts/login-and-create-api-key.ts @@ -0,0 +1,127 @@ +// Headless api-key provisioning for authenticated SDK testing. +// +// Flow (all REST, no SDK): +// 1. POST /v1/auth/request-otp { email } -> Supabase emails a one-time code +// 2. (prompt) read the 6-digit code from the inbox +// 3. POST /v1/auth/verify-otp { email, token } -> { access_token, refresh_token, user_id } +// 4. POST /v1/api-keys { Authorization: Bearer ... } -> { publicKey, secretKey } (sk_* returned ONCE) +// 5. persist keys to .api-key.json for the next script +// +// The minted pair is user-scoped (partner_name = NULL): the X-API-Key header authenticates +// as the linked user on quote/ramp endpoints, with default `vortex` partner pricing. +// +// Run: +// cd packages/sdk +// bun run scripts/login-and-create-api-key.ts +// +// Env overrides: +// API_BASE_URL default http://localhost:3000 +// API_KEY_NAME default sdk-test +// API_KEY_OUTFILE default .api-key.json + +import * as fs from "fs"; +import * as readline from "readline"; + +const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:3000"; +const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL ?? "test@email.io"; +const API_KEY_NAME = process.env.API_KEY_NAME ?? "sdk-test"; +const API_KEY_OUTFILE = process.env.API_KEY_OUTFILE ?? ".api-key.json"; + +interface ApiKeyResponse { + createdAt: string; + expiresAt: string; + isActive: boolean; + publicKey: { id: string; key: string; keyPrefix: string; name: string; type: "public" }; + secretKey: { id: string; key: string; keyPrefix: string; name: string; type: "secret" }; +} + +interface VerifyOtpResponse { + access_token: string; + refresh_token: string; + success: boolean; + user_id: string; +} + +function askQuestion(query: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(query, (ans: string) => { + rl.close(); + resolve(ans.trim()); + }); + }); +} + +async function postJson(path: string, body: unknown, bearer?: string): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (bearer) headers.Authorization = `Bearer ${bearer}`; + const response = await fetch(`${API_BASE_URL}/v1${path}`, { + body: JSON.stringify(body), + headers, + method: "POST" + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${response.status} ${path}: ${text}`); + } + return JSON.parse(text) as T; +} + +async function requestOtp(email: string): Promise { + console.log(`📨 Requesting OTP for ${email} ...`); + const data = await postJson<{ message: string; success: boolean }>("/auth/request-otp", { email }); + console.log(` ${data.message}`); +} + +async function verifyOtp(email: string, token: string): Promise { + return postJson("/auth/verify-otp", { email, token }); +} + +async function createApiKey(accessToken: string, name: string): Promise { + return postJson("/api-keys", { name }, accessToken); +} + +async function main(): Promise { + console.log(`API base URL: ${API_BASE_URL}`); + console.log(`Test user: ${TEST_USER_EMAIL}\n`); + + await requestOtp(TEST_USER_EMAIL); + + const token = await askQuestion("➡️ Enter the OTP code from the email: "); + if (!token) throw new Error("No OTP code provided."); + + console.log("\n🔒 Verifying OTP ..."); + const auth = await verifyOtp(TEST_USER_EMAIL, token); + console.log(` user_id: ${auth.user_id}`); + + console.log(`\n🗝️ Creating user-scoped api-key "${API_KEY_NAME}" ...`); + const keyPair = await createApiKey(auth.access_token, API_KEY_NAME); + console.log(" publicKey:", keyPair.publicKey.key); + console.log(" secretKey:", keyPair.secretKey.key, " (shown once)"); + + const out = { + apiUrl: API_BASE_URL, + createdAt: keyPair.createdAt, + expiresAt: keyPair.expiresAt, + name: API_KEY_NAME, + publicKey: keyPair.publicKey.key, + publicKeyId: keyPair.publicKey.id, + secretKey: keyPair.secretKey.key, + secretKeyId: keyPair.secretKey.id, + userId: auth.user_id + }; + fs.writeFileSync(API_KEY_OUTFILE, JSON.stringify(out, null, 2)); + console.log(`\n💾 Wrote ${API_KEY_OUTFILE} (consumed by test-authenticated-quote.ts)`); +} + +if (import.meta.main) { + main() + .then(() => { + console.log("\n✨ Done"); + process.exit(0); + }) + .catch(error => { + console.error("\n💥 Failed:", error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/packages/sdk/scripts/login.ts b/packages/sdk/scripts/login.ts new file mode 100644 index 000000000..6a2bb29ed --- /dev/null +++ b/packages/sdk/scripts/login.ts @@ -0,0 +1,97 @@ +// Standalone OTP login — saves auth token for reuse by other scripts. +// +// Flow: +// 1. POST /v1/auth/request-otp { email } +// 2. (prompt) read the 6-digit code from the inbox +// 3. POST /v1/auth/verify-otp { email, token } -> { access_token, refresh_token, user_id } +// 4. persist to .auth-token.json +// +// Run: +// cd packages/sdk +// bun run scripts/login.ts +// +// Env overrides: +// API_BASE_URL default http://localhost:3000 +// TEST_USER_EMAIL default test@email.io +// AUTH_TOKEN_FILE default .auth-token.json + +import * as fs from "fs"; +import * as readline from "readline"; + +const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:3000"; +const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL ?? "test@email.io"; +const AUTH_TOKEN_FILE = process.env.AUTH_TOKEN_FILE ?? ".auth-token.json"; + +interface VerifyOtpResponse { + access_token: string; + refresh_token: string; + success: boolean; + user_id: string; +} + +interface AuthToken { + accessToken: string; + apiUrl: string; + refreshToken: string; + userId: string; +} + +function askQuestion(query: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(query, (ans: string) => { + rl.close(); + resolve(ans.trim()); + }); + }); +} + +async function postJson(path: string, body: unknown): Promise { + const response = await fetch(`${API_BASE_URL}/v1${path}`, { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${response.status} ${path}: ${text}`); + } + return JSON.parse(text) as T; +} + +async function main(): Promise { + console.log(`API base URL: ${API_BASE_URL}`); + console.log(`User: ${TEST_USER_EMAIL}\n`); + + console.log("📨 Requesting OTP ..."); + const data = await postJson<{ message: string; success: boolean }>("/auth/request-otp", { email: TEST_USER_EMAIL }); + console.log(` ${data.message}`); + + const otp = await askQuestion("➡️ Enter the OTP code from the email: "); + if (!otp) throw new Error("No OTP code provided."); + + console.log("\n🔒 Verifying OTP ..."); + const auth = await postJson("/auth/verify-otp", { email: TEST_USER_EMAIL, token: otp }); + console.log(` user_id: ${auth.user_id}`); + + const out: AuthToken = { + accessToken: auth.access_token, + apiUrl: API_BASE_URL, + refreshToken: auth.refresh_token, + userId: auth.user_id + }; + fs.writeFileSync(AUTH_TOKEN_FILE, JSON.stringify(out, null, 2)); + console.log(`\n💾 Wrote ${AUTH_TOKEN_FILE}`); +} + +if (import.meta.main) { + main() + .then(() => { + console.log("\n✨ Done"); + process.exit(0); + }) + .catch(error => { + console.error("\n💥 Failed:", error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index 29cc06210..afd7fc69d 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -1,11 +1,15 @@ import { AccountMeta, + AlfredPayCountry, + AlfredpayFiatAccount, CreateQuoteRequest, createMoonbeamEphemeral, createPendulumEphemeral, EphemeralAccount, EphemeralAccountType, + EvmTransactionData, GetRampStatusResponse, + isAlfredpayToken, isEvmTransactionData, isSignedTypedData, isSignedTypedDataArray, @@ -14,20 +18,32 @@ import { QuoteResponse, RampDirection, RampProcess, + SignedTypedData, signUnsignedTransactions, UnsignedTx } from "@vortexfi/shared"; +import { attachSignatures, typedDataToSign, type UserTransactionType, userTransactionType } from "./eip712"; import { TransactionSigningError } from "./errors"; +import { AlfredpayHandler } from "./handlers/AlfredpayHandler"; import { BrlHandler } from "./handlers/BrlHandler"; +import { MykoboHandler } from "./handlers/MykoboHandler"; +import { assertSufficientOfframpBalance } from "./preflight"; import { ApiService } from "./services/ApiService"; import { NetworkManager } from "./services/NetworkManager"; import { storeEphemeralKeys } from "./storage"; import type { + AlfredpayOfframpAdditionalData, + AlfredpayOfframpUpdateAdditionalData, + AlfredpayOnrampAdditionalData, BrlOfframpAdditionalData, BrlOfframpUpdateAdditionalData, BrlOnrampAdditionalData, + EurOfframpAdditionalData, + EurOfframpUpdateAdditionalData, + EurOnrampAdditionalData, ExtendedQuoteResponse, RegisterRampAdditionalData, + SubmitUserTransactionsHandlers, UpdateRampAdditionalData, VortexSdkConfig } from "./types"; @@ -35,8 +51,11 @@ import type { export class VortexSdk { private apiService: ApiService; private publicKey: string | undefined; + private secretKey: string | undefined; private networkManager: NetworkManager; private brlHandler: BrlHandler; + private alfredpayHandler: AlfredpayHandler; + private mykoboHandler: MykoboHandler; private initializationPromise: Promise; private storeEphemeralKeys: boolean; @@ -45,6 +64,7 @@ export class VortexSdk { this.networkManager = new NetworkManager(config); this.storeEphemeralKeys = config.storeEphemeralKeys ?? true; this.publicKey = config.publicKey; + this.secretKey = config.secretKey; this.brlHandler = new BrlHandler( this.apiService, @@ -53,10 +73,26 @@ export class VortexSdk { this.signTransactions.bind(this) ); + this.alfredpayHandler = new AlfredpayHandler( + this.apiService, + this, + this.generateEphemerals.bind(this), + this.signTransactions.bind(this) + ); + + this.mykoboHandler = new MykoboHandler( + this.apiService, + this, + this.generateEphemerals.bind(this), + this.signTransactions.bind(this) + ); + this.initializationPromise = this.networkManager.waitForInitialization(); } async createQuote(request: T): Promise> { + // Quotes are anonymous-eligible for every corridor (rate discovery); the user-linked + // secretKey is only required at registerRamp. const apiRequest = { ...request, api: true, apiKey: this.publicKey }; const baseQuote = await this.apiService.createQuote(apiRequest); return baseQuote as ExtendedQuoteResponse; @@ -66,6 +102,10 @@ export class VortexSdk { return this.apiService.getQuote(quoteId); } + async listAlfredpayFiatAccounts(country: AlfredPayCountry): Promise { + return this.apiService.listAlfredpayFiatAccounts(country); + } + async getRampStatus(rampId: string): Promise { return this.apiService.getRampStatus(rampId); } @@ -85,27 +125,50 @@ export class VortexSdk { rampProcess: RampProcess; unsignedTransactions: UnsignedTx[]; }> { + if (!this.secretKey) { + throw new Error( + "Ramp registration requires a user-linked secretKey (sk_*) in VortexSdkConfig. Onboard the user and complete KYC via the Vortex app first." + ); + } + await this.ensureInitialized(); let rampProcess: RampProcess; let unsignedTransactions: UnsignedTx[] = []; if (quote.rampType === RampDirection.BUY) { - if (quote.from === "pix") { + if (isAlfredpayToken(quote.inputCurrency)) { + rampProcess = await this.alfredpayHandler.registerAlfredpayOnramp( + quote.id, + additionalData as AlfredpayOnrampAdditionalData + ); + unsignedTransactions = []; + } else if (quote.from === "pix") { rampProcess = await this.brlHandler.registerBrlOnramp(quote.id, additionalData as BrlOnrampAdditionalData); unsignedTransactions = []; } else if (quote.from === "sepa") { - throw new Error("Euro onramp handler not implemented yet"); + rampProcess = await this.mykoboHandler.registerMykoboOnramp(quote.id, additionalData as EurOnrampAdditionalData); + unsignedTransactions = []; } else { throw new Error(`Unsupported onramp from: ${quote.from}`); } } else if (quote.rampType === RampDirection.SELL) { - if (quote.to === "pix") { + // Every offramp corridor moves quote.inputAmount out of the user's wallet on-chain. Check + // the balance up front so we never register a ramp whose user transactions can only revert + // (or request a single-use permit the backend cannot execute). + await assertSufficientOfframpBalance(quote, (additionalData as { walletAddress?: string }).walletAddress); + if (isAlfredpayToken(quote.outputCurrency)) { + const offrampData = additionalData as AlfredpayOfframpAdditionalData; + rampProcess = await this.alfredpayHandler.registerAlfredpayOfframp(quote.id, offrampData); + unsignedTransactions = await this.getUserTransactions(rampProcess, offrampData.walletAddress); + } else if (quote.to === "pix") { rampProcess = await this.brlHandler.registerBrlOfframp(quote.id, additionalData as BrlOfframpAdditionalData); const userAddress = (additionalData as BrlOfframpAdditionalData).walletAddress; unsignedTransactions = await this.getUserTransactions(rampProcess, userAddress); } else if (quote.to === "sepa") { - throw new Error("Euro offramp handler not implemented yet"); + rampProcess = await this.mykoboHandler.registerMykoboOfframp(quote.id, additionalData as EurOfframpAdditionalData); + const userAddress = (additionalData as EurOfframpAdditionalData).walletAddress; + unsignedTransactions = await this.getUserTransactions(rampProcess, userAddress); } else { throw new Error(`Unsupported offramp to: ${quote.to}`); } @@ -122,16 +185,23 @@ export class VortexSdk { additionalUpdateData: UpdateRampAdditionalData ): Promise { if (quote.rampType === RampDirection.BUY) { - if (quote.from === "pix") { + if (isAlfredpayToken(quote.inputCurrency)) { + throw new Error("Alfredpay onramp does not require any further data"); + } else if (quote.from === "pix") { throw new Error("Brl onramp does not require any further data"); } else if (quote.from === "sepa") { - throw new Error("Euro onramp handler not implemented yet"); + throw new Error("Euro onramp does not require any further data"); } } else if (quote.rampType === RampDirection.SELL) { - if (quote.to === "pix") { + if (isAlfredpayToken(quote.outputCurrency)) { + return this.alfredpayHandler.updateAlfredpayOfframp( + rampId, + additionalUpdateData as AlfredpayOfframpUpdateAdditionalData + ); + } else if (quote.to === "pix") { return this.brlHandler.updateBrlOfframp(rampId, additionalUpdateData as BrlOfframpUpdateAdditionalData); } else if (quote.to === "sepa") { - throw new Error("Euro offramp handler not implemented yet"); + return this.mykoboHandler.updateMykoboOfframp(rampId, additionalUpdateData as EurOfframpUpdateAdditionalData); } } @@ -139,7 +209,119 @@ export class VortexSdk { } async startRamp(rampId: string): Promise { - return this.brlHandler.startBrlRamp(rampId); + return this.apiService.startRamp({ rampId }); + } + + /** + * Submit a user signature for an EIP-712 typed-data transaction (e.g. an offramp permit) returned + * by registerRamp in `unsignedTransactions`. The user's wallet signs the typed data off-chain + * (e.g. eth_signTypedData_v4 / wagmi signTypedData); pass the resulting 65-byte hex signature here. + * The signature is attached to the transaction and submitted to Vortex. + */ + async submitUserSignature(rampId: string, tx: UnsignedTx, signatures: string | string[]): Promise { + if (userTransactionType(tx) !== "evm-typed-data") { + throw new Error( + `submitUserSignature: phase ${tx.phase} is not a typed-data transaction; use submitUserTxHash for evm-transaction types.` + ); + } + const sigList = Array.isArray(signatures) ? signatures : [signatures]; + const signedTxData = attachSignatures(tx, sigList); + return this.apiService.updateRamp({ additionalData: {}, presignedTxs: [{ ...tx, txData: signedTxData }], rampId }); + } + + /** + * Classify a user transaction returned in `unsignedTransactions`: + * - "evm-typed-data": sign it (e.g. an offramp permit) and submit via submitUserSignature. + * - "evm-transaction": broadcast it from the user wallet and submit the hash via submitUserTxHash. + * - "unsupported": the SDK cannot broadcast this transaction type; handle it with a network-specific wallet flow. + */ + getUserTransactionType(tx: UnsignedTx): UserTransactionType { + return userTransactionType(tx); + } + + /** + * Return the EIP-712 payload(s) to sign for a typed-data user transaction. A single transaction + * may carry more than one payload (e.g. a permit + a relayer payload) — sign each, in order, and + * pass the signatures to submitUserSignature in the same order. + * + * Default output is ready for wagmi / viem `signTypedData` (they derive EIP712Domain themselves). + * Pass { includeDomainType: true } to also emit the EIP712Domain type entry, as required by the + * low-level `eth_signTypedData_v4` JSON-RPC call. + */ + getTypedDataToSign(tx: UnsignedTx, options: { includeDomainType?: boolean } = {}): SignedTypedData[] { + return typedDataToSign(tx, options); + } + + /** + * Return the EVM transaction to broadcast for an "evm-transaction" user transaction. + */ + getTransactionToBroadcast(tx: UnsignedTx): EvmTransactionData { + if (!isEvmTransactionData(tx.txData)) { + throw new Error(`getTransactionToBroadcast: phase ${tx.phase} is not a broadcastable EVM transaction.`); + } + return tx.txData; + } + + /** + * Submit the hash of a user-broadcast EVM transaction (e.g. squidRouter approve/swap) to Vortex. + * The hash is recorded against the transaction's phase (e.g. `squidRouterApproveHash`). + */ + async submitUserTxHash(rampId: string, tx: UnsignedTx, hash: string): Promise { + return this.apiService.updateRamp({ additionalData: { [`${tx.phase}Hash`]: hash }, presignedTxs: [], rampId }); + } + + /** + * Process all user-owned transactions returned by registerRamp. The SDK classifies each entry, + * asks the caller's wallet callbacks to sign or broadcast it, then submits the result to Vortex. + */ + async submitUserTransactions( + rampId: string, + unsignedTransactions: UnsignedTx[], + handlers: SubmitUserTransactionsHandlers + ): Promise { + let rampProcess: RampProcess | undefined; + + for (const tx of unsignedTransactions) { + const txType = this.getUserTransactionType(tx); + + if (txType === "evm-typed-data") { + if (!handlers.signTypedData) { + throw new Error(`submitUserTransactions: signTypedData handler is required for phase ${tx.phase}.`); + } + + const payloads = this.getTypedDataToSign(tx, { includeDomainType: handlers.includeDomainType }); + const signatures: string[] = []; + + for (let payloadIndex = 0; payloadIndex < payloads.length; payloadIndex++) { + signatures.push( + await handlers.signTypedData(payloads[payloadIndex], { + payloadCount: payloads.length, + payloadIndex, + unsignedTransaction: tx + }) + ); + } + + rampProcess = await this.submitUserSignature(rampId, tx, signatures); + } else if (txType === "evm-transaction") { + if (!handlers.sendTransaction) { + throw new Error(`submitUserTransactions: sendTransaction handler is required for phase ${tx.phase}.`); + } + + const transaction = this.getTransactionToBroadcast(tx); + const hash = await handlers.sendTransaction(transaction, { unsignedTransaction: tx }); + rampProcess = await this.submitUserTxHash(rampId, tx, hash); + } else { + if (!handlers.handleUnsupported) { + throw new Error( + `submitUserTransactions: no handler provided for unsupported transaction type at phase ${tx.phase} on ${tx.network}.` + ); + } + await handlers.handleUnsupported(tx); + } + } + + return rampProcess ?? this.getRampStatus(rampId); } public async storeEphemerals( diff --git a/packages/sdk/src/eip712.ts b/packages/sdk/src/eip712.ts new file mode 100644 index 000000000..618c1c2ad --- /dev/null +++ b/packages/sdk/src/eip712.ts @@ -0,0 +1,114 @@ +import { + type EvmTransactionData, + isEvmTransactionData, + isSignedTypedData, + isSignedTypedDataArray, + type SignedTypedData, + type UnsignedTx +} from "@vortexfi/shared"; + +export type UserTransactionType = "evm-typed-data" | "evm-transaction" | "unsupported"; + +export const EIP712_DOMAIN_FIELD_TYPES: Record = { + chainId: "uint256", + name: "string", + salt: "bytes32", + verifyingContract: "address", + version: "string" +}; + +// Canonical EIP712Domain field order (ethers/viem use this). Required when emitting a payload for +// the low-level eth_signTypedData_v4 JSON-RPC call, which needs the EIP712Domain type spelled out. +// A non-canonical order produces a different domain hash and breaks signature recovery. +export const EIP712_DOMAIN_FIELD_ORDER = ["name", "version", "chainId", "verifyingContract", "salt"]; + +export function userTransactionType(tx: UnsignedTx): UserTransactionType { + if (isSignedTypedData(tx.txData) || isSignedTypedDataArray(tx.txData)) { + return "evm-typed-data"; + } + + if (isEvmTransactionData(tx.txData)) { + return "evm-transaction"; + } + + return "unsupported"; +} + +export function typedDataToSign(tx: UnsignedTx, options: { includeDomainType?: boolean } = {}): SignedTypedData[] { + const txDataArray = Array.isArray(tx.txData) ? tx.txData : [tx.txData]; + const items = txDataArray.filter((item): item is SignedTypedData => + isSignedTypedData(item as string | EvmTransactionData | SignedTypedData | SignedTypedData[]) + ); + if (!options.includeDomainType) { + return items; + } + return items.map(item => ({ + ...item, + types: { + ...item.types, + EIP712Domain: EIP712_DOMAIN_FIELD_ORDER.filter(field => field in item.domain).map(field => ({ + name: field, + type: EIP712_DOMAIN_FIELD_TYPES[field] + })) + } + })); +} + +function typedDataDeadline(item: SignedTypedData, phase: string): number { + const deadline = item.message.deadline; + if (deadline === undefined || deadline === null) { + throw new Error(`attachSignatures: phase ${phase} typed-data payload is missing a deadline.`); + } + + const numericDeadline = Number(deadline); + if (!Number.isFinite(numericDeadline) || !Number.isInteger(numericDeadline) || numericDeadline <= 0) { + throw new Error( + `attachSignatures: phase ${phase} typed-data deadline must be a positive integer, got ${String(deadline)}.` + ); + } + + return numericDeadline; +} + +/** + * Attach user signatures to the typed-data payload(s) of a transaction, producing the signed + * txData to submit. A transaction may carry several payloads (e.g. permit + relayer payload); + * `signatures` must line up one-to-one, in order. + */ +export function attachSignatures(tx: UnsignedTx, signatures: string[]): SignedTypedData[] { + const items = typedDataToSign(tx); + if (items.length === 0) { + throw new Error(`attachSignatures: phase ${tx.phase} has no typed-data payloads to sign.`); + } + if (signatures.length !== items.length) { + throw new Error(`attachSignatures: phase ${tx.phase} expects ${items.length} signature(s), got ${signatures.length}.`); + } + return items.map((item, i) => { + const { v, r, s } = splitSignature(signatures[i]); + const deadline = typedDataDeadline(item, tx.phase); + return { ...item, signature: { deadline, r, s, v } }; + }); +} + +// Split a 65-byte hex signature (eth_signTypedData_v4 / personal_sign output) into {v, r, s}. +export function splitSignature(signature: string): { v: number; r: `0x${string}`; s: `0x${string}` } { + // Tolerate pasted values wrapped in quotes / whitespace. + const cleaned = signature.trim().replace(/^['"]+|['"]+$/g, ""); + const hex = cleaned.startsWith("0x") ? cleaned.slice(2) : cleaned; + if (hex.length !== 130) { + throw new Error(`Invalid signature: expected a 65-byte hex string, got ${hex.length} hex chars.`); + } + if (!/^[0-9a-fA-F]+$/.test(hex)) { + throw new Error("Invalid signature: signature must contain only hex characters."); + } + const r = `0x${hex.slice(0, 64)}` as `0x${string}`; + const s = `0x${hex.slice(64, 128)}` as `0x${string}`; + let v = Number.parseInt(hex.slice(128, 130), 16); + if (v === 0 || v === 1) { + v += 27; + } + if (v !== 27 && v !== 28) { + throw new Error(`Invalid signature: v must be 0, 1, 27, or 28, got ${v}.`); + } + return { r, s, v }; +} diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 40f4f03ce..6f210296f 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -65,7 +65,17 @@ export class InvalidAdditionalDataError extends RegisterRampError { } } -export type EphemeralChain = "Substrate" | "EVM" | "Stellar"; +export class InsufficientBalanceError extends RegisterRampError { + constructor(requiredAmount: string, currency: string, network: string, walletAddress: string) { + super( + `Wallet ${walletAddress} holds less than the required ${requiredAmount} ${currency} on ${network} for this offramp`, + 400 + ); + this.name = "InsufficientBalanceError"; + } +} + +export type EphemeralChain = "Substrate" | "EVM"; export class EphemeralNotFreshError extends RegisterRampError { public readonly chain: EphemeralChain; @@ -163,7 +173,60 @@ export class InvalidPixKeyError extends BrlOfframpError { } } -// Monerium specific errors +// Alfredpay Onramp specific errors +export class AlfredpayOnrampError extends RegisterRampError { + constructor(message: string, status = 400) { + super(message, status); + this.name = "AlfredpayOnrampError"; + } +} + +export class MissingAlfredpayOnrampParametersError extends AlfredpayOnrampError { + constructor() { + super("Parameter destinationAddress is required for Alfredpay onramp", 400); + this.name = "MissingAlfredpayOnrampParametersError"; + } +} + +export class AlfredpayOnrampKycRequiredError extends AlfredpayOnrampError { + constructor(message: string, status = 400) { + super(message, status); + this.name = "AlfredpayOnrampKycRequiredError"; + } +} + +function extractErrorStatus(response: Record): number | undefined { + for (const key of ["status", "statusCode", "code"]) { + const value = response[key]; + if (typeof value === "number") { + return value; + } + } + + const nestedError = response.error; + if (nestedError && typeof nestedError === "object") { + return extractErrorStatus(nestedError as Record); + } + + return undefined; +} + +// Alfredpay Offramp specific errors +export class AlfredpayOfframpError extends RegisterRampError { + constructor(message: string, status = 400) { + super(message, status); + this.name = "AlfredpayOfframpError"; + } +} + +export class MissingAlfredpayOfframpParametersError extends AlfredpayOfframpError { + constructor(message = "Parameters fiatAccountId and walletAddress are required for Alfredpay offramp") { + super(message, 400); + this.name = "MissingAlfredpayOfframpParametersError"; + } +} + +// Monerium specific errors (deprecated — replaced by Mykobo) export class MoneriumError extends RegisterRampError { constructor(message: string, status = 400) { super(message, status); @@ -185,6 +248,39 @@ export class MissingMoneriumOfframpParametersError extends MoneriumError { } } +// Mykobo EUR specific errors +export class MykoboError extends RegisterRampError { + constructor(message: string, status = 400) { + super(message, status); + this.name = "MykoboError"; + } +} + +export class MissingMykoboOnrampParametersError extends MykoboError { + constructor() { + super("Parameters destinationAddress, email and ipAddress are required for Mykobo EUR onramp", 400); + this.name = "MissingMykoboOnrampParametersError"; + } +} + +export class MissingMykoboOfframpParametersError extends MykoboError { + constructor() { + super("Parameters walletAddress, email, ipAddress and destinationAddress are required for Mykobo EUR offramp", 400); + this.name = "MissingMykoboOfframpParametersError"; + } +} + +/** + * The effective user's Mykobo KYC is missing/not approved, or the supplied email does not + * match the profile bound to the authenticated user. Complete Mykobo KYC before EUR ramps. + */ +export class MykoboKycRequiredError extends MykoboError { + constructor(message: string, status = 400) { + super(message, status); + this.name = "MykoboKycRequiredError"; + } +} + /** * Update Ramp Error Types */ @@ -343,10 +439,10 @@ function extractErrorMessage(value: unknown): string | undefined { /** * Error parsing utilities */ -export function parseAPIError(response: unknown): VortexSdkError { +export function parseAPIError(response: unknown, fallbackStatus?: number): VortexSdkError { if (response && typeof response === "object") { - const { message, error, status, errors } = response as Record; - const normalizedStatus = typeof status === "number" ? status : 500; + const { message, error, errors } = response as Record; + const normalizedStatus = extractErrorStatus(response as Record) ?? fallbackStatus ?? 500; const errorMessage = extractErrorMessage(message) ?? extractErrorMessage(error); if (errorMessage) { @@ -365,15 +461,15 @@ export function parseAPIError(response: unknown): VortexSdkError { return new InvalidNetworkError(network); } - const freshnessMatch = errorMessage.match(/^(Substrate|EVM|Stellar) ephemeral (\S+) (?:is not fresh|already exists)/); + const freshnessMatch = errorMessage.match(/^(Substrate|EVM) ephemeral (\S+) (?:is not fresh|already exists)/); if (freshnessMatch) { return new EphemeralNotFreshError(errorMessage, freshnessMatch[1] as EphemeralChain, freshnessMatch[2]); } - const freshnessCheckMatch = errorMessage.match(/^Could not verify freshness of (Substrate|EVM|Stellar) ephemeral (\S+)/); + const freshnessCheckMatch = errorMessage.match(/^Could not verify freshness of (Substrate|EVM) ephemeral (\S+)/); if (freshnessCheckMatch) { return new EphemeralFreshnessCheckError(errorMessage, freshnessCheckMatch[1] as EphemeralChain, freshnessCheckMatch[2]); } - const missingEphemeralMatch = errorMessage.match(/^(Substrate|EVM|Stellar) ephemeral address is required/); + const missingEphemeralMatch = errorMessage.match(/^(Substrate|EVM) ephemeral address is required/); if (missingEphemeralMatch) { return new EphemeralNotFreshError(errorMessage, missingEphemeralMatch[1] as EphemeralChain, ""); } @@ -401,12 +497,45 @@ export function parseAPIError(response: unknown): VortexSdkError { if (errorMessage === "Invalid pixKey or receiverTaxId") { return new InvalidPixKeyError(); } + if (errorMessage === "Parameter destinationAddress is required for Alfredpay onramp") { + return new MissingAlfredpayOnrampParametersError(); + } + if ( + errorMessage === + "Alfredpay onramp requires a completed Alfredpay KYC profile. Partner API-key-only registration is not supported for this flow yet because no partner user-to-Alfredpay-customer mapping exists." || + errorMessage.startsWith("No completed Alfredpay KYC profile found") || + errorMessage.startsWith("Alfredpay KYC status is") + ) { + return new AlfredpayOnrampKycRequiredError(errorMessage, normalizedStatus); + } + if (errorMessage === "fiatAccountId is required for Alfredpay offramp") { + return new MissingAlfredpayOfframpParametersError(errorMessage); + } + if (errorMessage === "User address must be provided for offramping.") { + return new MissingAlfredpayOfframpParametersError(errorMessage); + } if (errorMessage === "Parameters moneriumAuthToken and destinationAddress are required for Monerium onramp") { return new MissingMoneriumOnrampParametersError(); } if (errorMessage === "Parameters walletAddress and moneriumAuthToken is required for Monerium onramp") { return new MissingMoneriumOfframpParametersError(); } + if (errorMessage === "Parameters destinationAddress and ipAddress are required for Mykobo EUR onramp") { + return new MissingMykoboOnrampParametersError(); + } + if (errorMessage === "ipAddress must be provided for Mykobo (EUR) offramp") { + return new MissingMykoboOfframpParametersError(); + } + if (errorMessage === "destinationAddress (user receiving wallet) must be provided for Mykobo offramp") { + return new MissingMykoboOfframpParametersError(); + } + if ( + errorMessage.startsWith("Mykobo KYC is not approved for this user") || + errorMessage === "Provided email does not match the profile bound to the authenticated user." || + errorMessage === "No profile found for this user; cannot resolve the Mykobo customer." + ) { + return new MykoboKycRequiredError(errorMessage, normalizedStatus); + } if (errorMessage === "Ramp not found") { return new RampNotFoundError(); @@ -442,7 +571,7 @@ export async function handleAPIResponse(response: Response, endpoint: string) throw new APIResponseError(endpoint, response.status, response.statusText); } - throw parseAPIError(errorData); + throw parseAPIError(errorData, response.status); } try { diff --git a/packages/sdk/src/handlers/AlfredpayHandler.ts b/packages/sdk/src/handlers/AlfredpayHandler.ts new file mode 100644 index 000000000..479a5744b --- /dev/null +++ b/packages/sdk/src/handlers/AlfredpayHandler.ts @@ -0,0 +1,161 @@ +import { + AccountMeta, + EphemeralAccount, + EphemeralAccountType, + PresignedTx, + RampProcess, + RegisterRampRequest, + UnsignedTx, + UpdateRampRequest +} from "@vortexfi/shared"; +import { MissingAlfredpayOfframpParametersError, MissingAlfredpayOnrampParametersError } from "../errors"; +import type { ApiService } from "../services/ApiService"; +import type { + AlfredpayOfframpAdditionalData, + AlfredpayOfframpUpdateAdditionalData, + AlfredpayOnrampAdditionalData, + RampHandler, + VortexSdkContext +} from "../types"; + +export class AlfredpayHandler implements RampHandler { + private apiService: ApiService; + private context: VortexSdkContext; + private generateEphemerals: () => Promise<{ + ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount }; + accountMetas: AccountMeta[]; + }>; + private signTransactions: ( + unsignedTxs: UnsignedTx[], + ephemerals: { + substrateEphemeral?: EphemeralAccount; + evmEphemeral?: EphemeralAccount; + } + ) => Promise; + + constructor( + apiService: ApiService, + context: VortexSdkContext, + generateEphemerals: () => Promise<{ + ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount }; + accountMetas: AccountMeta[]; + }>, + signTransactions: ( + unsignedTxs: UnsignedTx[], + ephemerals: { + substrateEphemeral?: EphemeralAccount; + evmEphemeral?: EphemeralAccount; + } + ) => Promise + ) { + this.apiService = apiService; + this.context = context; + this.generateEphemerals = generateEphemerals; + this.signTransactions = signTransactions; + } + + private getEphemeralTransactions( + unsignedTxs: UnsignedTx[], + ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount } + ): UnsignedTx[] { + const ephemeralSigners = new Set( + [ephemerals.EVM?.address, ephemerals.Substrate?.address] + .filter((address): address is string => Boolean(address)) + .map(address => address.toLowerCase()) + ); + + return unsignedTxs.filter(tx => ephemeralSigners.has(tx.signer.toLowerCase())); + } + + async registerAlfredpayOnramp(quoteId: string, additionalData: AlfredpayOnrampAdditionalData): Promise { + if (!additionalData.destinationAddress) { + throw new MissingAlfredpayOnrampParametersError(); + } + + const { ephemerals, accountMetas } = await this.generateEphemerals(); + + const registerRequest: RegisterRampRequest = { + additionalData: { + destinationAddress: additionalData.destinationAddress, + fiatAccountId: additionalData.fiatAccountId, + sessionId: additionalData.sessionId, + walletAddress: additionalData.walletAddress + }, + quoteId, + signingAccounts: accountMetas + }; + + const rampProcess = await this.apiService.registerRamp(registerRequest); + + await this.context.storeEphemerals(ephemerals, rampProcess.id); + + const ephemeralTxs = this.getEphemeralTransactions(rampProcess.unsignedTxs || [], ephemerals); + const signedTxs = await this.signTransactions(ephemeralTxs, { + evmEphemeral: ephemerals.EVM, + substrateEphemeral: ephemerals.Substrate + }); + + const updateRequest: UpdateRampRequest = { + additionalData: {}, + presignedTxs: signedTxs, + rampId: rampProcess.id + }; + + return this.apiService.updateRamp(updateRequest); + } + + async registerAlfredpayOfframp(quoteId: string, additionalData: AlfredpayOfframpAdditionalData): Promise { + if (!additionalData.fiatAccountId || !additionalData.walletAddress) { + throw new MissingAlfredpayOfframpParametersError(); + } + + const { ephemerals, accountMetas } = await this.generateEphemerals(); + + const registerRequest: RegisterRampRequest = { + additionalData: { + fiatAccountId: additionalData.fiatAccountId, + sessionId: additionalData.sessionId, + walletAddress: additionalData.walletAddress + }, + quoteId, + signingAccounts: accountMetas + }; + + const rampProcess = await this.apiService.registerRamp(registerRequest); + + await this.context.storeEphemerals(ephemerals, rampProcess.id); + + const ephemeralTxs = this.getEphemeralTransactions(rampProcess.unsignedTxs || [], ephemerals); + const signedTxs = await this.signTransactions(ephemeralTxs, { + evmEphemeral: ephemerals.EVM, + substrateEphemeral: ephemerals.Substrate + }); + + const updateRequest: UpdateRampRequest = { + additionalData: {}, + presignedTxs: signedTxs, + rampId: rampProcess.id + }; + + return this.apiService.updateRamp(updateRequest); + } + + async updateAlfredpayOfframp(rampId: string, additionalData: AlfredpayOfframpUpdateAdditionalData): Promise { + const rampProcess = await this.apiService.getRampStatus(rampId); + if (rampProcess.currentPhase !== "initial") { + throw new Error(`Ramp cannot be updated in its current phase. Expected initial phase, got: ${rampProcess.currentPhase}`); + } + + const updateRequest: UpdateRampRequest = { + additionalData: { + assethubToPendulumHash: additionalData.assethubToPendulumHash, + squidRouterApproveHash: additionalData.squidRouterApproveHash, + squidRouterSwapHash: additionalData.squidRouterSwapHash + }, + presignedTxs: [], + rampId: rampProcess.id + }; + + return this.apiService.updateRamp(updateRequest); + } +} diff --git a/packages/sdk/src/handlers/BrlHandler.ts b/packages/sdk/src/handlers/BrlHandler.ts index 117a9f990..7dd008bd3 100644 --- a/packages/sdk/src/handlers/BrlHandler.ts +++ b/packages/sdk/src/handlers/BrlHandler.ts @@ -56,19 +56,17 @@ export class BrlHandler implements RampHandler { } async registerBrlOnramp(quoteId: string, additionalData: BrlOnrampAdditionalData): Promise { - if (!additionalData.taxId) { - throw new Error("Tax ID is required for BRL onramp"); - } + // taxId is now derived server-side from the bound user + const taxId = additionalData.taxId ?? ""; - await this.validateBrlKyc(additionalData.taxId); - await this.assertWithinBrlLimit(additionalData.taxId, quoteId, RampDirection.BUY); + await this.assertWithinBrlLimit(taxId, quoteId, RampDirection.BUY); const { ephemerals, accountMetas } = await this.generateEphemerals(); const registerRequest: RegisterRampRequest = { additionalData: { destinationAddress: additionalData.destinationAddress, - taxId: additionalData.taxId + taxId: taxId || undefined }, quoteId, signingAccounts: accountMetas @@ -95,21 +93,19 @@ export class BrlHandler implements RampHandler { } async registerBrlOfframp(quoteId: string, additionalData: BrlOfframpAdditionalData): Promise { - if (!additionalData.taxId) { - throw new Error("Tax ID is required for BRL offramps"); - } + const taxId = additionalData.taxId ?? ""; + const receiverTaxId = additionalData.receiverTaxId ?? taxId; - await this.validateBrlKyc(additionalData.taxId); await this.assertValidPixKey(additionalData.pixDestination); - await this.assertWithinBrlLimit(additionalData.taxId, quoteId, RampDirection.SELL); + await this.assertWithinBrlLimit(taxId, quoteId, RampDirection.SELL); const { ephemerals, accountMetas } = await this.generateEphemerals(); const registerRequest: RegisterRampRequest = { additionalData: { pixDestination: additionalData.pixDestination, - receiverTaxId: additionalData.receiverTaxId, - taxId: additionalData.taxId, + receiverTaxId: receiverTaxId || undefined, + taxId: taxId || undefined, walletAddress: additionalData.walletAddress }, quoteId, @@ -158,22 +154,6 @@ export class BrlHandler implements RampHandler { return updatedRampProcess; } - async startBrlRamp(rampId: string): Promise { - const startRequest = { rampId }; - return this.apiService.startRamp(startRequest); - } - - private async validateBrlKyc(taxId: string): Promise { - if (!taxId) { - throw new BrlKycStatusError("Tax ID is required", 400); - } - - const kycStatus = await this.apiService.getBrlKycStatus(taxId); - if (kycStatus.kycLevel < 1) { - throw new Error(`Insufficient KYC level. Current: ${kycStatus.kycLevel}`); - } - } - private async assertValidPixKey(pixKey: string): Promise { let result: { valid: boolean }; try { @@ -211,7 +191,7 @@ export class BrlHandler implements RampHandler { } let remainingLimit: number; try { - ({ remainingLimit } = await this.apiService.getBrlRemainingLimit(taxId, direction)); + ({ remainingLimit } = await this.apiService.getBrlRemainingLimit(taxId || undefined, direction)); } catch (error) { // The backend returns 404 "Limits not found" for KYC-approved users whose // BRLA subaccount has not yet been initialized for limits. Treat this as diff --git a/packages/sdk/src/handlers/MykoboHandler.ts b/packages/sdk/src/handlers/MykoboHandler.ts new file mode 100644 index 000000000..7812f4d37 --- /dev/null +++ b/packages/sdk/src/handlers/MykoboHandler.ts @@ -0,0 +1,168 @@ +import { + AccountMeta, + EphemeralAccount, + EphemeralAccountType, + PresignedTx, + RampProcess, + RegisterRampRequest, + UnsignedTx, + UpdateRampRequest +} from "@vortexfi/shared"; +import { MissingMykoboOfframpParametersError, MissingMykoboOnrampParametersError } from "../errors"; +import type { ApiService } from "../services/ApiService"; +import type { + EurOfframpAdditionalData, + EurOfframpUpdateAdditionalData, + EurOnrampAdditionalData, + RampHandler, + VortexSdkContext +} from "../types"; + +export class MykoboHandler implements RampHandler { + private apiService: ApiService; + private context: VortexSdkContext; + private generateEphemerals: () => Promise<{ + ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount }; + accountMetas: AccountMeta[]; + }>; + private signTransactions: ( + unsignedTxs: UnsignedTx[], + ephemerals: { + substrateEphemeral?: EphemeralAccount; + evmEphemeral?: EphemeralAccount; + } + ) => Promise; + + constructor( + apiService: ApiService, + context: VortexSdkContext, + generateEphemerals: () => Promise<{ + ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount }; + accountMetas: AccountMeta[]; + }>, + signTransactions: ( + unsignedTxs: UnsignedTx[], + ephemerals: { + substrateEphemeral?: EphemeralAccount; + evmEphemeral?: EphemeralAccount; + } + ) => Promise + ) { + this.apiService = apiService; + this.context = context; + this.generateEphemerals = generateEphemerals; + this.signTransactions = signTransactions; + } + + private getEphemeralTransactions( + unsignedTxs: UnsignedTx[], + ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount } + ): UnsignedTx[] { + const ephemeralSigners = new Set( + [ephemerals.EVM?.address, ephemerals.Substrate?.address] + .filter((address): address is string => Boolean(address)) + .map(address => address.toLowerCase()) + ); + + return unsignedTxs.filter(tx => ephemeralSigners.has(tx.signer.toLowerCase())); + } + + async registerMykoboOnramp(quoteId: string, additionalData: EurOnrampAdditionalData): Promise { + if (!additionalData.destinationAddress || !additionalData.email || !additionalData.ipAddress) { + throw new MissingMykoboOnrampParametersError(); + } + + const { ephemerals, accountMetas } = await this.generateEphemerals(); + + const registerRequest: RegisterRampRequest = { + additionalData: { + destinationAddress: additionalData.destinationAddress, + email: additionalData.email, + ipAddress: additionalData.ipAddress + }, + quoteId, + signingAccounts: accountMetas + }; + + const rampProcess = await this.apiService.registerRamp(registerRequest); + + await this.context.storeEphemerals(ephemerals, rampProcess.id); + + const ephemeralTxs = this.getEphemeralTransactions(rampProcess.unsignedTxs || [], ephemerals); + const signedTxs = await this.signTransactions(ephemeralTxs, { + evmEphemeral: ephemerals.EVM, + substrateEphemeral: ephemerals.Substrate + }); + + const updateRequest: UpdateRampRequest = { + additionalData: {}, + presignedTxs: signedTxs, + rampId: rampProcess.id + }; + + return this.apiService.updateRamp(updateRequest); + } + + async registerMykoboOfframp(quoteId: string, additionalData: EurOfframpAdditionalData): Promise { + if ( + !additionalData.walletAddress || + !additionalData.email || + !additionalData.ipAddress || + !additionalData.destinationAddress + ) { + throw new MissingMykoboOfframpParametersError(); + } + + const { ephemerals, accountMetas } = await this.generateEphemerals(); + + const registerRequest: RegisterRampRequest = { + additionalData: { + destinationAddress: additionalData.destinationAddress, + email: additionalData.email, + ipAddress: additionalData.ipAddress, + walletAddress: additionalData.walletAddress + }, + quoteId, + signingAccounts: accountMetas + }; + + const rampProcess = await this.apiService.registerRamp(registerRequest); + + await this.context.storeEphemerals(ephemerals, rampProcess.id); + + const ephemeralTxs = this.getEphemeralTransactions(rampProcess.unsignedTxs || [], ephemerals); + const signedTxs = await this.signTransactions(ephemeralTxs, { + evmEphemeral: ephemerals.EVM, + substrateEphemeral: ephemerals.Substrate + }); + + const updateRequest: UpdateRampRequest = { + additionalData: {}, + presignedTxs: signedTxs, + rampId: rampProcess.id + }; + + return this.apiService.updateRamp(updateRequest); + } + + async updateMykoboOfframp(rampId: string, additionalData: EurOfframpUpdateAdditionalData): Promise { + const rampProcess = await this.apiService.getRampStatus(rampId); + if (rampProcess.currentPhase !== "initial") { + throw new Error( + `Invalid ramp id. Ramp must be on initial phase to be updated. Current phase: ${rampProcess.currentPhase}` + ); + } + + const updateRequest: UpdateRampRequest = { + additionalData: { + assethubToPendulumHash: additionalData.assethubToPendulumHash, + squidRouterApproveHash: additionalData.squidRouterApproveHash, + squidRouterSwapHash: additionalData.squidRouterSwapHash + }, + presignedTxs: [], + rampId: rampProcess.id + }; + + return this.apiService.updateRamp(updateRequest); + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 1f4875c15..d74a75045 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,3 +1,4 @@ +export type { UserTransactionType } from "./eip712"; export * from "./errors"; export * from "./types"; export { VortexSdk } from "./VortexSdk"; diff --git a/packages/sdk/src/preflight.ts b/packages/sdk/src/preflight.ts new file mode 100644 index 000000000..587509b21 --- /dev/null +++ b/packages/sdk/src/preflight.ts @@ -0,0 +1,62 @@ +import { + EvmClientManager, + getOnChainTokenDetails, + isEvmTokenDetails, + isNetworkEVM, + multiplyByPowerOfTen, + QuoteResponse +} from "@vortexfi/shared"; +import { erc20Abi } from "viem"; +import { InsufficientBalanceError } from "./errors"; + +/** + * Pre-flight guard for offramps: verify the user's source wallet holds the quoted input amount + * before the ramp is registered. Every offramp corridor moves `inputAmount` of `inputCurrency` + * out of that wallet on-chain (squidRouter approve/swap, a direct no-permit transfer, or an + * EIP-2612 permit the backend executes), so registering without funds only produces user + * transactions that revert — or hands the backend a single-use permit it cannot execute. + * + * Best-effort by design: an unknown token or an RPC failure skips the check instead of blocking + * registration, because the backend re-validates balances authoritatively before moving funds. + * Native gas is not checked here — the permit path needs none, and gas costs for the no-permit + * path are unknown at registration time. + */ +export async function assertSufficientOfframpBalance(quote: QuoteResponse, walletAddress: string | undefined): Promise { + if (!walletAddress) { + // A missing walletAddress is reported by the corridor handler's own parameter validation. + return; + } + + const network = quote.network; + if (!isNetworkEVM(network)) { + // AssetHub offramps are funded by a user extrinsic submitted outside the SDK; no pre-check. + return; + } + + let requiredRaw: bigint; + let balance: bigint; + try { + const tokenDetails = getOnChainTokenDetails(network, quote.inputCurrency); + if (!tokenDetails || !isEvmTokenDetails(tokenDetails)) { + return; + } + + requiredRaw = BigInt(multiplyByPowerOfTen(quote.inputAmount, tokenDetails.decimals).toFixed(0, 0)); + + const evmClientManager = EvmClientManager.getInstance(); + balance = tokenDetails.isNative + ? await evmClientManager.getBalanceWithRetry(network, walletAddress as `0x${string}`) + : await evmClientManager.readContractWithRetry(network, { + abi: erc20Abi, + address: tokenDetails.erc20AddressSourceChain, + args: [walletAddress as `0x${string}`], + functionName: "balanceOf" + }); + } catch { + return; + } + + if (balance < requiredRaw) { + throw new InsufficientBalanceError(quote.inputAmount, quote.inputCurrency, network, walletAddress); + } +} diff --git a/packages/sdk/src/services/ApiService.ts b/packages/sdk/src/services/ApiService.ts index 49d2ac5fc..ff4ac8b58 100644 --- a/packages/sdk/src/services/ApiService.ts +++ b/packages/sdk/src/services/ApiService.ts @@ -1,4 +1,6 @@ import type { + AlfredPayCountry, + AlfredpayFiatAccount, CreateQuoteRequest, GetRampStatusResponse, QuoteResponse, @@ -87,9 +89,11 @@ export class ApiService { return handleAPIResponse(response, `/v1/ramp/status?id=${rampId}`); } - async getBrlKycStatus(taxId: string): Promise { + async getBrlKycStatus(taxId?: string): Promise { const url = new URL(`${this.apiBaseUrl}/v1/brla/getUser`); - url.searchParams.append("taxId", taxId); + if (taxId) { + url.searchParams.append("taxId", taxId); + } const response = await fetch(url.toString(), { headers: this.buildHeaders(), @@ -99,9 +103,11 @@ export class ApiService { return handleAPIResponse(response, "/v1/brla/getUser"); } - async getBrlRemainingLimit(taxId: string, direction: RampDirection): Promise<{ remainingLimit: number }> { + async getBrlRemainingLimit(taxId: string | undefined, direction: RampDirection): Promise<{ remainingLimit: number }> { const url = new URL(`${this.apiBaseUrl}/v1/brla/getUserRemainingLimit`); - url.searchParams.append("taxId", taxId); + if (taxId) { + url.searchParams.append("taxId", taxId); + } url.searchParams.append("direction", direction); const response = await fetch(url.toString(), { @@ -123,4 +129,16 @@ export class ApiService { return handleAPIResponse<{ valid: boolean }>(response, "/v1/brla/validatePixKey"); } + + async listAlfredpayFiatAccounts(country: AlfredPayCountry): Promise { + const url = new URL(`${this.apiBaseUrl}/v1/alfredpay/fiatAccounts`); + url.searchParams.append("country", country); + + const response = await fetch(url.toString(), { + headers: this.buildHeaders(), + method: "GET" + }); + + return handleAPIResponse(response, `/v1/alfredpay/fiatAccounts?country=${country}`); + } } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 9aec268b6..34e152b41 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,132 +1,191 @@ // Types re-exported used to create quotes. -import type { CreateQuoteRequest, EvmTransactionData, PaymentMethod, QuoteResponse } from "@vortexfi/shared"; +import type { + AlfredpayFiatAccount, + CreateQuoteRequest, + EvmTransactionData, + PaymentMethod, + QuoteResponse, + SignedTypedData +} from "@vortexfi/shared"; import { + AlfredPayCountry, + AlfredpayFiatAccountType, EPaymentMethod, EphemeralAccount, EphemeralAccountType, EvmToken, FiatToken, Networks, - PaymentData, RampDirection, RampPhase, UnsignedTx } from "@vortexfi/shared"; -export { - EPaymentMethod, - EvmToken, - FiatToken, - Networks, - RampDirection, - PaymentMethod, - QuoteResponse, - CreateQuoteRequest, - EvmTransactionData -}; +export type { AlfredpayFiatAccount, CreateQuoteRequest, EvmTransactionData, PaymentMethod, QuoteResponse }; +export { AlfredPayCountry, AlfredpayFiatAccountType, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection }; + +export type AnyQuote = + | BrlOnrampQuote + | EurOnrampQuote + | AlfredpayOnrampQuote + | BrlOfframpQuote + | EurOfframpQuote + | AlfredpayOfframpQuote; -export type AnyQuote = BrlOnrampQuote | EurOnrampQuote | BrlOfframpQuote | EurOfframpQuote; +export type AlfredpayCurrency = FiatToken.USD | FiatToken.MXN | FiatToken.COP | FiatToken.ARS; export type BrlOnrampQuote = QuoteResponse & { rampType: RampDirection.BUY; - from: "pix"; + from: EPaymentMethod.PIX; }; export type EurOnrampQuote = QuoteResponse & { rampType: RampDirection.BUY; - from: "sepa"; + from: EPaymentMethod.SEPA; +}; + +export type AlfredpayOnrampQuote = QuoteResponse & { + rampType: RampDirection.BUY; + inputCurrency: AlfredpayCurrency; }; export type BrlOfframpQuote = QuoteResponse & { rampType: RampDirection.SELL; - to: "pix"; + to: EPaymentMethod.PIX; }; export type EurOfframpQuote = QuoteResponse & { rampType: RampDirection.SELL; - to: "sepa"; + to: EPaymentMethod.SEPA; +}; + +export type AlfredpayOfframpQuote = QuoteResponse & { + rampType: RampDirection.SELL; + outputCurrency: AlfredpayCurrency; }; -export type ExtendedQuoteResponse = T extends { rampType: RampDirection.BUY; from: "pix" } - ? BrlOnrampQuote - : T extends { rampType: RampDirection.BUY; from: "sepa" } - ? EurOnrampQuote - : T extends { rampType: RampDirection.SELL; to: "pix" } - ? BrlOfframpQuote - : T extends { rampType: RampDirection.SELL; to: "sepa" } - ? EurOfframpQuote - : AnyQuote; +// Alfredpay branches are checked before the pix/sepa branches to mirror the runtime routing in VortexSdk.registerRamp(). +export type ExtendedQuoteResponse = T extends { + rampType: RampDirection.BUY; + inputCurrency: AlfredpayCurrency; +} + ? AlfredpayOnrampQuote + : T extends { rampType: RampDirection.BUY; from: EPaymentMethod.PIX } + ? BrlOnrampQuote + : T extends { rampType: RampDirection.BUY; from: EPaymentMethod.SEPA } + ? EurOnrampQuote + : T extends { rampType: RampDirection.SELL; outputCurrency: AlfredpayCurrency } + ? AlfredpayOfframpQuote + : T extends { rampType: RampDirection.SELL; to: EPaymentMethod.PIX } + ? BrlOfframpQuote + : T extends { rampType: RampDirection.SELL; to: EPaymentMethod.SEPA } + ? EurOfframpQuote + : AnyQuote; export type AnyAdditionalData = | BrlOfframpAdditionalData | EurOfframpAdditionalData + | AlfredpayOfframpAdditionalData | BrlOnrampAdditionalData - | EurOnrampAdditionalData; - -export type RegisterRampAdditionalData = Q extends BrlOnrampQuote - ? BrlOnrampAdditionalData - : Q extends EurOnrampQuote - ? EurOnrampAdditionalData - : Q extends BrlOfframpQuote - ? BrlOfframpAdditionalData - : Q extends EurOfframpQuote - ? EurOfframpAdditionalData - : AnyAdditionalData; + | EurOnrampAdditionalData + | AlfredpayOnrampAdditionalData; + +// Branch order mirrors ExtendedQuoteResponse (Alfredpay first per direction). Keys are mutually exclusive, so order is cosmetic here. +export type RegisterRampAdditionalData = Q extends AlfredpayOnrampQuote + ? AlfredpayOnrampAdditionalData + : Q extends BrlOnrampQuote + ? BrlOnrampAdditionalData + : Q extends EurOnrampQuote + ? EurOnrampAdditionalData + : Q extends AlfredpayOfframpQuote + ? AlfredpayOfframpAdditionalData + : Q extends BrlOfframpQuote + ? BrlOfframpAdditionalData + : Q extends EurOfframpQuote + ? EurOfframpAdditionalData + : AnyAdditionalData; export interface BrlOnrampAdditionalData { destinationAddress: string; - taxId: string; + /** + * @deprecated The BRL subaccount is now derived server-side from + * `api_keys.user_id -> tax_ids.user_id`. The SDK still accepts the field + * for one release of backward compatibility, but the server rejects + * mismatches against the derived taxId. + */ + taxId?: string; } export interface EurOnrampAdditionalData { - moneriumAuthToken: string; + destinationAddress: string; + email: string; + ipAddress: string; +} + +export interface AlfredpayOnrampAdditionalData { + destinationAddress: string; + fiatAccountId?: string; + walletAddress?: string; + sessionId?: string; } export interface BrlOfframpAdditionalData { pixDestination: string; - receiverTaxId: string; - taxId: string; walletAddress: string; + receiverTaxId?: string; + /** + * @deprecated The BRL subaccount is now derived server-side from + * `api_keys.user_id -> tax_ids.user_id`. The SDK still accepts the field + * for one release of backward compatibility, but the server rejects + * mismatches against the derived taxId. + */ + taxId?: string; } export interface EurOfframpAdditionalData { - paymentData: PaymentData; + destinationAddress: string; + email: string; + ipAddress: string; + walletAddress: string; +} + +export interface AlfredpayOfframpAdditionalData { + fiatAccountId: string; walletAddress: string; + sessionId?: string; } export type AnyUpdateAdditionalData = - | EurOnrampUpdateAdditionalData | BrlOfframpUpdateAdditionalData - | EurOfframpUpdateAdditionalData; - -export type UpdateRampAdditionalData = Q extends BrlOnrampQuote - ? never // No additional data required from the user for this type of ramp. - : Q extends EurOnrampQuote - ? EurOnrampUpdateAdditionalData - : Q extends BrlOfframpQuote - ? BrlOfframpUpdateAdditionalData - : Q extends EurOfframpQuote - ? EurOfframpUpdateAdditionalData - : AnyUpdateAdditionalData; + | EurOfframpUpdateAdditionalData + | AlfredpayOfframpUpdateAdditionalData; -export interface EurOnrampUpdateAdditionalData { - squidRouterApproveHash: string; - squidRouterSwapHash: string; - moneriumOfframpSignature: string; -} +export type UpdateRampAdditionalData = Q extends AlfredpayOnrampQuote + ? never // Alfredpay onramp settles fiat off-chain; no user transactions to update. + : Q extends BrlOnrampQuote + ? never // No additional data required from the user for this type of ramp. + : Q extends EurOnrampQuote + ? never // No additional data required from the user for EUR onramp. + : Q extends AlfredpayOfframpQuote + ? AlfredpayOfframpUpdateAdditionalData + : Q extends BrlOfframpQuote + ? BrlOfframpUpdateAdditionalData + : Q extends EurOfframpQuote + ? EurOfframpUpdateAdditionalData + : AnyUpdateAdditionalData; -export interface BrlOfframpUpdateAdditionalData { - squidRouterApproveHash?: string; - squidRouterSwapHash?: string; - assethubToPendulumHash?: string; -} -export interface EurOfframpUpdateAdditionalData { +export interface OfframpUpdateAdditionalData { squidRouterApproveHash?: string; squidRouterSwapHash?: string; assethubToPendulumHash?: string; } +// BRL, EUR, and Alfredpay offramps all push back the same on-chain tx hashes. +export interface BrlOfframpUpdateAdditionalData extends OfframpUpdateAdditionalData {} +export interface EurOfframpUpdateAdditionalData extends OfframpUpdateAdditionalData {} +export interface AlfredpayOfframpUpdateAdditionalData extends OfframpUpdateAdditionalData {} + export interface BrlKycResponse { evmAddress: string; kycLevel: number; @@ -143,6 +202,23 @@ export interface RampState { unsignedTxs: UnsignedTx[]; } +export interface UserTypedDataSigningContext { + unsignedTransaction: UnsignedTx; + payloadIndex: number; + payloadCount: number; +} + +export interface UserEvmTransactionContext { + unsignedTransaction: UnsignedTx; +} + +export interface SubmitUserTransactionsHandlers { + includeDomainType?: boolean; + signTypedData?: (payload: SignedTypedData, context: UserTypedDataSigningContext) => Promise; + sendTransaction?: (transaction: EvmTransactionData, context: UserEvmTransactionContext) => Promise; + handleUnsupported?: (tx: UnsignedTx) => Promise; +} + export interface NetworkConfig { name: string; wsUrl: string; diff --git a/packages/sdk/test/alfredpayHandler.test.ts b/packages/sdk/test/alfredpayHandler.test.ts new file mode 100644 index 000000000..f9e0b3aca --- /dev/null +++ b/packages/sdk/test/alfredpayHandler.test.ts @@ -0,0 +1,234 @@ +// Regression coverage for the Alfredpay (USD / MXN / COP / ARS) on/off-ramp flows. +// Exercises the real AlfredpayHandler against an in-memory backend (no network, +// RPC, KYC, or on-chain funds) to lock in routing, request payloads, and call +// ordering. Run: cd packages/sdk && bun test + +import {describe, expect, test} from "bun:test"; +import { + EPaymentMethod, + EphemeralAccountType, + FiatToken, + GetRampStatusResponse, + isAlfredpayToken, + Networks, + PresignedTx, + RampDirection, + RampProcess, + RegisterRampRequest, + UnsignedTx, + UpdateRampRequest +} from "@vortexfi/shared"; +import {MissingAlfredpayOfframpParametersError, MissingAlfredpayOnrampParametersError} from "../src/errors"; +import {AlfredpayHandler} from "../src/handlers/AlfredpayHandler"; +import {ApiService} from "../src/services/ApiService"; +import type {VortexSdkContext} from "../src/types"; + +const WALLET = "0x1234567890123456789012345678901234567890"; +const FIAT_ACCOUNT = "fa_demo"; + +type Call = { method: string; payload: unknown }; + +const makeUnsignedTx = (phase: UnsignedTx["phase"], signer: string): UnsignedTx => ({ + meta: {}, + network: Networks.Polygon, + nonce: 0, + phase, + signer, + txData: { + data: "0x", + gas: "21000", + to: "0x0000000000000000000000000000000000000001", + value: "0" + } +}); + +const makeRampProcess = ( + id: string, + currentPhase: RampProcess["currentPhase"], + unsignedTxs: UnsignedTx[] +): RampProcess => ({ + createdAt: "2026-01-01T00:00:00.000Z", + currentPhase, + from: EPaymentMethod.ACH, + id, + inputAmount: "100", + inputCurrency: FiatToken.USD, + outputAmount: "10", + outputCurrency: "USDC", + paymentMethod: EPaymentMethod.ACH, + quoteId: "quote_1", + to: Networks.Polygon, + type: RampDirection.BUY, + unsignedTxs, + updatedAt: "2026-01-01T00:00:00.000Z" +}); + +const makeRampStatus = ( + id: string, + currentPhase: RampProcess["currentPhase"], + unsignedTxs: UnsignedTx[] +): GetRampStatusResponse => ({ + ...makeRampProcess(id, currentPhase, unsignedTxs), + anchorFeeFiat: "0", + anchorFeeUsd: "0", + feeCurrency: FiatToken.USD, + networkFeeFiat: "0", + networkFeeUsd: "0", + partnerFeeFiat: "0", + partnerFeeUsd: "0", + processingFeeFiat: "0", + processingFeeUsd: "0", + totalFeeFiat: "0", + totalFeeUsd: "0", + vortexFeeFiat: "0", + vortexFeeUsd: "0" +}); + +const payloadFor = (calls: Call[], method: string): T => calls.find(call => call.method === method)!.payload as T; + +function setup(overrides: { unsignedTxs?: UnsignedTx[]; currentPhase?: RampProcess["currentPhase"] } = {}) { + const calls: Call[] = []; + const unsignedTxs = overrides.unsignedTxs ?? []; + + const apiService = new ApiService("http://localhost:3000"); + apiService.getRampStatus = async (id: string) => { + calls.push({ method: "getRampStatus", payload: id }); + return makeRampStatus(id, overrides.currentPhase ?? "initial", unsignedTxs); + }; + apiService.registerRamp = async (req: RegisterRampRequest) => { + calls.push({ method: "registerRamp", payload: req }); + return makeRampProcess("ramp_1", "initial", unsignedTxs); + }; + apiService.updateRamp = async (req: UpdateRampRequest) => { + calls.push({ method: "updateRamp", payload: req }); + return makeRampProcess(req.rampId, "initial", unsignedTxs); + }; + + const context: VortexSdkContext = { + storeEphemerals: async (...args) => { + calls.push({ method: "storeEphemerals", payload: args }); + } + }; + + const generateEphemerals = async () => ({ + accountMetas: [ + { address: "5SUBSTRATE", type: EphemeralAccountType.Substrate }, + { address: "0xEVM", type: EphemeralAccountType.EVM } + ], + ephemerals: { + EVM: { address: "0xEVM", secret: "s" }, + Substrate: { address: "5SUBSTRATE", secret: "s" } + } + }); + + const signTransactions = async (txs: UnsignedTx[]): Promise => { + calls.push({ method: "signTransactions", payload: txs }); + return txs.map((t, i) => ({ ...t, txData: `presigned_${i}` })); + }; + + const handler = new AlfredpayHandler(apiService, context, generateEphemerals, signTransactions); + return { calls, handler }; +} + +describe("isAlfredpayToken routing", () => { + test("USD/MXN/COP/ARS route to Alfredpay", () => { + for (const t of [FiatToken.USD, FiatToken.MXN, FiatToken.COP, FiatToken.ARS]) { + expect(isAlfredpayToken(t)).toBe(true); + } + }); + + test("BRL/EURC do not route to Alfredpay", () => { + for (const t of [FiatToken.BRL, FiatToken.EURC]) { + expect(isAlfredpayToken(t)).toBe(false); + } + }); +}); + +describe("AlfredpayHandler onramp", () => { + test("registers, stores ephemerals, signs, then updates", async () => { + const { calls, handler } = setup({ unsignedTxs: [makeUnsignedTx("fundEphemeral", "5SUBSTRATE")] }); + + const result = await handler.registerAlfredpayOnramp("quote_1", { + destinationAddress: WALLET, + fiatAccountId: FIAT_ACCOUNT, + walletAddress: WALLET + }); + + expect(result.id).toBe("ramp_1"); + expect(calls.map(c => c.method)).toEqual(["registerRamp", "storeEphemerals", "signTransactions", "updateRamp"]); + + const reg = payloadFor(calls, "registerRamp"); + expect(reg.quoteId).toBe("quote_1"); + expect(reg.additionalData).toMatchObject({ destinationAddress: WALLET, fiatAccountId: FIAT_ACCOUNT }); + expect(reg.signingAccounts).toHaveLength(2); + + const upd = payloadFor(calls, "updateRamp"); + expect(upd.presignedTxs).toHaveLength(1); + expect(upd.additionalData).toEqual({}); + }); + + test("throws when destinationAddress missing", async () => { + const { handler } = setup(); + await expect(handler.registerAlfredpayOnramp("q", { destinationAddress: "", fiatAccountId: FIAT_ACCOUNT })).rejects.toBeInstanceOf( + MissingAlfredpayOnrampParametersError + ); + }); + + test("registers without fiatAccountId (backend only requires destinationAddress for onramp)", async () => { + const { calls, handler } = setup(); + + const result = await handler.registerAlfredpayOnramp("quote_1", { destinationAddress: WALLET }); + + expect(result.id).toBe("ramp_1"); + const reg = payloadFor(calls, "registerRamp"); + expect(reg.additionalData).toMatchObject({ destinationAddress: WALLET }); + expect(reg.additionalData?.fiatAccountId).toBeUndefined(); + }); +}); + +describe("AlfredpayHandler offramp", () => { + test("registers with fiatAccountId + walletAddress (no destinationAddress)", async () => { + const { calls, handler } = setup({ unsignedTxs: [makeUnsignedTx("squidRouterApprove", WALLET)] }); + + const result = await handler.registerAlfredpayOfframp("quote_2", { fiatAccountId: FIAT_ACCOUNT, walletAddress: WALLET }); + + expect(result.id).toBe("ramp_1"); + expect(calls.map(c => c.method)).toEqual(["registerRamp", "storeEphemerals", "signTransactions", "updateRamp"]); + + const reg = payloadFor(calls, "registerRamp"); + expect(reg.additionalData).toMatchObject({ fiatAccountId: FIAT_ACCOUNT, walletAddress: WALLET }); + expect(reg.additionalData?.destinationAddress).toBeUndefined(); + }); + + test("throws when fiatAccountId missing", async () => { + const { handler } = setup(); + await expect(handler.registerAlfredpayOfframp("q", { fiatAccountId: "", walletAddress: WALLET })).rejects.toBeInstanceOf( + MissingAlfredpayOfframpParametersError + ); + }); + + test("throws when walletAddress missing", async () => { + const { handler } = setup(); + await expect(handler.registerAlfredpayOfframp("q", { fiatAccountId: FIAT_ACCOUNT, walletAddress: "" })).rejects.toBeInstanceOf( + MissingAlfredpayOfframpParametersError + ); + }); +}); + +describe("AlfredpayHandler offramp update", () => { + test("forwards squidRouter hashes with no presignedTxs after phase check", async () => { + const { calls, handler } = setup({ currentPhase: "initial" }); + + await handler.updateAlfredpayOfframp("ramp_1", { squidRouterApproveHash: "0xA", squidRouterSwapHash: "0xS" }); + + expect(calls.map(c => c.method)).toEqual(["getRampStatus", "updateRamp"]); + const upd = payloadFor(calls, "updateRamp"); + expect(upd.additionalData).toMatchObject({ squidRouterApproveHash: "0xA", squidRouterSwapHash: "0xS" }); + expect(upd.presignedTxs).toHaveLength(0); + }); + + test("throws when ramp is not on the initial phase", async () => { + const { handler } = setup({ currentPhase: "fundEphemeral" }); + await expect(handler.updateAlfredpayOfframp("ramp_1", {})).rejects.toThrow(/current phase/); + }); +}); diff --git a/packages/sdk/test/eip712.test.ts b/packages/sdk/test/eip712.test.ts new file mode 100644 index 000000000..d5db8e428 --- /dev/null +++ b/packages/sdk/test/eip712.test.ts @@ -0,0 +1,188 @@ +import {describe, expect, it} from "bun:test"; +import type {RampProcess} from "@vortexfi/shared"; +import {attachSignatures, splitSignature, typedDataToSign, userTransactionType} from "../src/eip712"; +import {VortexSdk} from "../src/VortexSdk"; + +const tx = (txData: unknown) => ({ meta: {}, network: "polygon", nonce: 0, phase: "squidRouterPermitExecute", signer: "0xabc", txData }) as never; + +const saltPermit = { + // domain key order is intentionally non-canonical (as Polygon USDC returns it) + domain: { name: "USD Coin", salt: "0x0000000000000000000000000000000000000000000000000000000000000089", verifyingContract: "0xc2", version: "2" }, + message: { deadline: "1700000000", nonce: "0", owner: "0xabc", spender: "0xdef", value: "10" }, + primaryType: "Permit", + types: { Permit: [{ name: "owner", type: "address" }] } +}; + +describe("userTransactionType", () => { + it("classifies a single typed-data payload", () => { + expect(userTransactionType(tx(saltPermit))).toBe("evm-typed-data"); + }); + it("classifies an array of typed-data payloads", () => { + expect(userTransactionType(tx([saltPermit, saltPermit]))).toBe("evm-typed-data"); + }); + it("classifies a raw EVM transaction (object)", () => { + expect(userTransactionType(tx({ data: "0x", gas: "21000", to: "0x1", value: "0" }))).toBe("evm-transaction"); + }); + it("does not classify Substrate hex strings as EVM transactions", () => { + expect(userTransactionType(tx("0xdeadbeef"))).toBe("unsupported"); + }); +}); + +describe("typedDataToSign", () => { + it("returns raw payloads for wagmi/viem (no EIP712Domain) by default", () => { + const [payload] = typedDataToSign(tx(saltPermit)); + expect("EIP712Domain" in payload.types).toBe(false); + }); + + it("returns every payload in a multi-payload tx", () => { + expect(typedDataToSign(tx([saltPermit, saltPermit]))).toHaveLength(2); + }); + + it("emits EIP712Domain in CANONICAL order for eth_signTypedData_v4 (not domain key order)", () => { + const [payload] = typedDataToSign(tx(saltPermit), { includeDomainType: true }); + // Domain object order is name, salt, verifyingContract, version — but ethers/viem require + // name, version, verifyingContract, salt. A wrong order broke signature recovery in testing. + expect((payload.types.EIP712Domain as { name: string }[]).map(f => f.name)).toEqual([ + "name", + "version", + "verifyingContract", + "salt" + ]); + }); + + it("includes chainId (and omits salt) for a standard domain", () => { + const standard = { ...saltPermit, domain: { chainId: 137, name: "X", verifyingContract: "0x1", version: "1" } }; + const [payload] = typedDataToSign(tx(standard), { includeDomainType: true }); + expect((payload.types.EIP712Domain as { name: string }[]).map(f => f.name)).toEqual([ + "name", + "version", + "chainId", + "verifyingContract" + ]); + }); + + it("overrides a non-canonical existing EIP712Domain entry", () => { + const withDomainType = { + ...saltPermit, + types: { + EIP712Domain: [ + { name: "salt", type: "bytes32" }, + { name: "name", type: "string" } + ], + Permit: [{ name: "owner", type: "address" }] + } + }; + + const [payload] = typedDataToSign(tx(withDomainType), { includeDomainType: true }); + + expect((payload.types.EIP712Domain as { name: string }[]).map(f => f.name)).toEqual([ + "name", + "version", + "verifyingContract", + "salt" + ]); + }); +}); + +describe("attachSignatures", () => { + const sig = `0x${"a".repeat(64)}${"b".repeat(64)}1b`; + + it("attaches a {v,r,s,deadline} signature to each payload", () => { + const [signed] = attachSignatures(tx(saltPermit), [sig]); + expect(signed.signature).toEqual({ deadline: 1700000000, r: `0x${"a".repeat(64)}`, s: `0x${"b".repeat(64)}`, v: 27 }); + }); + + it("signs every payload of a multi-payload tx", () => { + expect(attachSignatures(tx([saltPermit, saltPermit]), [sig, sig])).toHaveLength(2); + }); + + it("throws when the signature count does not match the payload count", () => { + expect(() => attachSignatures(tx([saltPermit, saltPermit]), [sig])).toThrow(); + }); + + it("throws when the transaction has no typed-data payloads", () => { + expect(() => attachSignatures(tx({ data: "0x", to: "0x1", value: "0" }), [sig])).toThrow(); + }); + + it("throws when a typed-data payload has no deadline", () => { + const { deadline: _deadline, ...messageWithoutDeadline } = saltPermit.message; + expect(() => attachSignatures(tx({ ...saltPermit, message: messageWithoutDeadline }), [sig])).toThrow(/missing a deadline/); + }); + + it("throws when a typed-data payload deadline is not numeric", () => { + expect(() => attachSignatures(tx({ ...saltPermit, message: { ...saltPermit.message, deadline: "soon" } }), [sig])).toThrow( + /positive integer/ + ); + }); +}); + +describe("splitSignature", () => { + const sig = `0x${"a".repeat(64)}${"b".repeat(64)}1b`; + it("splits a 65-byte hex signature into v/r/s", () => { + expect(splitSignature(sig)).toEqual({ r: `0x${"a".repeat(64)}`, s: `0x${"b".repeat(64)}`, v: 27 }); + }); + it("tolerates surrounding quotes and whitespace", () => { + expect(splitSignature(` '${sig}' `)).toEqual({ r: `0x${"a".repeat(64)}`, s: `0x${"b".repeat(64)}`, v: 27 }); + }); + it("normalizes v from 0/1 to 27/28", () => { + expect(splitSignature(`0x${"a".repeat(64)}${"b".repeat(64)}00`).v).toBe(27); + }); + it("rejects a wrong-length signature", () => { + expect(() => splitSignature("0x1234")).toThrow(/got 4 hex chars/); + }); + it("rejects non-hex signature bytes", () => { + expect(() => splitSignature(`0x${"a".repeat(64)}${"b".repeat(64)}zz`)).toThrow(/only hex characters/); + }); + it("rejects unsupported v values", () => { + expect(() => splitSignature(`0x${"a".repeat(64)}${"b".repeat(64)}02`)).toThrow(/v must be/); + }); +}); + +describe("submitUserTransactions", () => { + function sdkWithCapturedUpdates(updates: unknown[]): VortexSdk { + const sdk = Object.create(VortexSdk.prototype) as VortexSdk; + Object.defineProperty(sdk, "apiService", { + value: { + getRampStatus: async () => ({ id: "ramp-1" }) as RampProcess, + updateRamp: async (request: unknown) => { + updates.push(request); + return { id: "ramp-1" } as RampProcess; + } + } + }); + return sdk; + } + + it("signs typed-data payloads in order and submits the signed tx data", async () => { + const updates: unknown[] = []; + const sdk = sdkWithCapturedUpdates(updates); + const sig = `0x${"a".repeat(64)}${"b".repeat(64)}1b`; + + await sdk.submitUserTransactions("ramp-1", [tx([saltPermit, saltPermit])], { + signTypedData: async (payload, context) => { + expect(payload.primaryType).toBe("Permit"); + expect(context.payloadIndex).toBeLessThan(context.payloadCount); + return sig; + } + }); + + const [request] = updates as Array<{ presignedTxs: Array<{ txData: Array<{ signature: { v: number } }> }> }>; + expect(request.presignedTxs[0].txData).toHaveLength(2); + expect(request.presignedTxs[0].txData[0].signature.v).toBe(27); + }); + + it("broadcasts EVM transactions and submits the resulting phase hash", async () => { + const updates: unknown[] = []; + const sdk = sdkWithCapturedUpdates(updates); + + await sdk.submitUserTransactions("ramp-1", [tx({ data: "0x1234", to: "0x1", value: "0" })], { + sendTransaction: async transaction => { + expect(transaction.data).toBe("0x1234"); + return "0xhash"; + } + }); + + const [request] = updates as Array<{ additionalData: Record }>; + expect(request.additionalData.squidRouterPermitExecuteHash).toBe("0xhash"); + }); +}); diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts new file mode 100644 index 000000000..6321c268d --- /dev/null +++ b/packages/sdk/test/errors.test.ts @@ -0,0 +1,30 @@ +import {describe, expect, test} from "bun:test"; +import {AlfredpayOnrampKycRequiredError, parseAPIError, VortexSdkError} from "../src/errors"; + +describe("parseAPIError", () => { + test("uses API error code when status is omitted", () => { + const error = parseAPIError({ code: 401, message: "Authentication required" }); + + expect(error).toBeInstanceOf(VortexSdkError); + expect(error.status).toBe(401); + expect(error.message).toBe("Authentication required"); + }); + + test("uses nested API error status", () => { + const error = parseAPIError({ error: { message: "Invalid or expired Bearer token.", status: 401 } }); + + expect(error.status).toBe(401); + expect(error.message).toBe("Invalid or expired Bearer token."); + }); + + test("maps Alfredpay onramp auth and KYC errors", () => { + const error = parseAPIError({ + code: 401, + message: + "Alfredpay onramp requires a completed Alfredpay KYC profile. Partner API-key-only registration is not supported for this flow yet because no partner user-to-Alfredpay-customer mapping exists." + }); + + expect(error).toBeInstanceOf(AlfredpayOnrampKycRequiredError); + expect(error.status).toBe(401); + }); +}); diff --git a/packages/shared/bunfig.toml b/packages/shared/bunfig.toml new file mode 100644 index 000000000..939afd37e --- /dev/null +++ b/packages/shared/bunfig.toml @@ -0,0 +1,6 @@ +[test] +# Coverage report config; the gate itself lives in scripts/check-coverage.ts. +# Generated contract ABIs are const data that count as 100% covered on import, +# which inflates the ratchet without any test protecting anything. +coverageSkipTestFiles = true +coveragePathIgnorePatterns = ["**/src/contracts/**"] diff --git a/packages/shared/package.json b/packages/shared/package.json index 759123877..d969dc0d3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -58,8 +58,10 @@ "build": "rm -rf ./dist && bun build ./src/index.ts --outdir ./dist/node --target=node && bun build ./src/index.ts --outdir ./dist/browser --target=browser && tsc -p tsconfig.json --emitDeclarationOnly --outDir dist", "dev": "bun build ./src/index.ts --outdir ./dist/node --target=node & bun build ./src/index.ts --outdir ./dist/browser --target=browser", "format": "prettier . --write", - "prepublishOnly": "bun run build" + "prepublishOnly": "bun run build", + "test": "bun test", + "test:coverage": "bun test --coverage --coverage-reporter=lcov && bun ../../scripts/check-coverage.ts coverage/lcov.info 0.32 0.26" }, "types": "./dist/index.d.ts", - "version": "0.1.2" + "version": "0.2.0" } diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 45db37891..ac86adbb4 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -180,6 +180,12 @@ export interface RegisterRampRequest { paymentData?: PaymentData; pixDestination?: string; receiverTaxId?: string; + /** + * @deprecated Derived server-side from `api_keys.user_id -> tax_ids.user_id` + * for linked secret-key callers and Supabase-authenticated callers. The + * server accepts a value for one release of backward compatibility, but + * mismatches against the derived taxId are rejected. + */ taxId?: string; sessionId?: string; email?: string; // Required for Mykobo EUR ramps (binds ramp to anchor profile) diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index 46fed69b9..ec63b3374 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -10,7 +10,9 @@ export interface EvmNetworkConfig { } const VIEM_DEFAULT_TRANSPORT_CACHE_KEY = ""; -const NON_RETRYABLE_READ_CONTRACT_REVERTS = ["EXCEEDS_MAX_COVERAGE_RATIO"]; +// Any on-chain revert is deterministic for a given block state: retrying against a different RPC +// node will not change the outcome, so retrying just wastes calls and delays failure reporting. +const NON_RETRYABLE_READ_CONTRACT_ERROR_PATTERNS = [/execution reverted/i]; export function redactRpcUrlForLogs(rpcUrl: string): string { if (!rpcUrl) { @@ -53,7 +55,7 @@ function createRpcTransport(network: EvmNetworkConfig, rpcUrl?: string): Transpo } function isNonRetryableReadContractError(error: Error): boolean { - return NON_RETRYABLE_READ_CONTRACT_REVERTS.some(revertReason => error.message.includes(revertReason)); + return NON_RETRYABLE_READ_CONTRACT_ERROR_PATTERNS.some(pattern => pattern.test(error.message)); } function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { diff --git a/packages/shared/src/services/pendulum/apiManager.test.ts b/packages/shared/src/services/pendulum/apiManager.test.ts new file mode 100644 index 000000000..191362bf7 --- /dev/null +++ b/packages/shared/src/services/pendulum/apiManager.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { NetworkConfig } from "./apiManager"; + +const ORIGINAL_ENV = { ...process.env }; + +async function loadConfiguredNetworks(): Promise { + const modulePath = `./apiManager.ts?test=${Date.now()}-${Math.random()}`; + const { getConfiguredNetworks } = await import(modulePath); + return getConfiguredNetworks(); +} + +describe("ApiManager network configuration", () => { + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("uses default RPC URLs when no overrides are configured", async () => { + delete process.env.ASSETHUB_WSS; + delete process.env.HYDRATION_WSS; + delete process.env.MOONBEAM_WSS; + + const networks = await loadConfiguredNetworks(); + + expect(networks.find(network => network.name === "assethub")?.wsUrls).toEqual(["wss://dot-rpc.stakeworld.io/assethub"]); + expect(networks.find(network => network.name === "hydration")?.wsUrls).toEqual(["wss://rpc.hydradx.cloud"]); + expect(networks.find(network => network.name === "moonbeam")?.wsUrls).toEqual([ + "wss://wss.api.moonbeam.network", + "wss://moonbeam.api.onfinality.io/public-ws", + "wss://moonbeam.ibp.network" + ]); + }); + + it("uses configured RPC URL overrides", async () => { + process.env.ASSETHUB_WSS = "wss://asset-hub.example"; + process.env.HYDRATION_WSS = "wss://hydration.example"; + process.env.MOONBEAM_WSS = "wss://moonbeam.example"; + + const networks = await loadConfiguredNetworks(); + + expect(networks.find(network => network.name === "assethub")?.wsUrls).toEqual(["wss://asset-hub.example"]); + expect(networks.find(network => network.name === "hydration")?.wsUrls).toEqual(["wss://hydration.example"]); + expect(networks.find(network => network.name === "moonbeam")?.wsUrls).toEqual(["wss://moonbeam.example"]); + }); + + it("supports comma-separated RPC URL overrides", async () => { + process.env.MOONBEAM_WSS = "wss://moonbeam-one.example, wss://moonbeam-two.example"; + + const networks = await loadConfiguredNetworks(); + + expect(networks.find(network => network.name === "moonbeam")?.wsUrls).toEqual([ + "wss://moonbeam-one.example", + "wss://moonbeam-two.example" + ]); + }); +}); diff --git a/packages/shared/src/services/xcm/assethubToMoonbeam.test.ts b/packages/shared/src/services/xcm/assethubToMoonbeam.test.ts index b0e66f01a..1230a3fdb 100644 --- a/packages/shared/src/services/xcm/assethubToMoonbeam.test.ts +++ b/packages/shared/src/services/xcm/assethubToMoonbeam.test.ts @@ -1,25 +1,27 @@ -import {test} from "bun:test"; +import {expect, test} from "bun:test"; import {dryRunExtrinsic,} from "../../index"; import {createAssethubToMoonbeamTransferWithSwapOnHydration} from "./assethubToMoonbeam"; -test("dry-run assethub to moonbeam with swap on hydration", async () => { - // Hardcoded values for testing purposes +// Hits live AssetHub/Hydration RPCs; opt-in only (see docs/testing-strategy.md). +test.skipIf(!process.env.RUN_LIVE_TESTS)("dry-run assethub to moonbeam with swap on hydration", async () => { + // Hardcoded values for testing purposes. The transferred asset is USDT on AssetHub + // (hardcoded in the production function). const rawAmount = "1000000"; - const assetAccountKey = "0xFFfffffF7D2B0B761Af01Ca8e25242976ac0aD7D"; // xcUSDC const receiverAddress = "0x7Ba99e99Bc669B3508AFf9CC0A898E869459F877"; // Example account ID for dry-run origin const accountKey = "5DqTNJsGp6UayR5iHAZvH4zquY6ni6j35ZXLtJA6bXwsfixg"; // Example address // 1. Create the extrinsic - const extrinsic = await createAssethubToMoonbeamTransferWithSwapOnHydration( - receiverAddress, - rawAmount, - assetAccountKey - ); + const extrinsic = await createAssethubToMoonbeamTransferWithSwapOnHydration(receiverAddress, rawAmount); // 2. Dry-run the extrinsic const network = "assethub"; const dryRunResult = await dryRunExtrinsic(extrinsic, network, accountKey); - - // 3. Log the result console.log("Dry-run result:", JSON.stringify(dryRunResult.toHuman(), null, 2)); + + // 3. The dry run must report successful local execution — a Result payload that + // carries an XCM error (e.g. Filtered/FailedToTransactAsset) is a failure. + expect(dryRunResult.isOk).toBe(true); + // biome-ignore lint/suspicious/noExplicitAny: runtime-call codec typing is too loose here + const effects = dryRunResult.asOk as any; + expect(effects.executionResult.isOk).toBe(true); }, 30000); // Set a timeout of 30 seconds for the test diff --git a/packages/shared/src/services/xcm/assethubToMoonbeam.ts b/packages/shared/src/services/xcm/assethubToMoonbeam.ts index 43f64710e..cabf4ad77 100644 --- a/packages/shared/src/services/xcm/assethubToMoonbeam.ts +++ b/packages/shared/src/services/xcm/assethubToMoonbeam.ts @@ -4,10 +4,10 @@ import { ISubmittableResult } from "@polkadot/types/types"; import { u8aToHex } from "@polkadot/util"; import { ApiManager } from "../../index"; +// The transferred asset is fixed to USDT on AssetHub (PalletInstance 50 / GeneralIndex 1984). export async function createAssethubToMoonbeamTransferWithSwapOnHydration( receiverAddress: string, - rawAmount: string, - assetAccountKey: string + rawAmount: string ): Promise> { const apiManager = ApiManager.getInstance(); const networkName = "assethub"; diff --git a/packages/shared/src/services/xcm/moonbeamToAssethub.test.ts b/packages/shared/src/services/xcm/moonbeamToAssethub.test.ts deleted file mode 100644 index cbcfb351a..000000000 --- a/packages/shared/src/services/xcm/moonbeamToAssethub.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {test} from "bun:test"; -import {createMoonbeamToAssethubTransferWithSwapOnHydration, dryRunExtrinsic,} from "../../index" - -test("dry-run moonbeam to assethub with swap on hydration", async () => { - // Hardcoded values for testing purposes - const receiverAddress = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; // Example address - const rawAmount = "1000000"; - const assetAccountKey = "0xFFfffffF7D2B0B761Af01Ca8e25242976ac0aD7D"; // xcUSDC - const accountId = "0x7Ba99e99Bc669B3508AFf9CC0A898E869459F877"; // Example account ID for dry-run origin - - const network = "moonbeam"; - - // 1. Create the extrinsic - const extrinsic = await createMoonbeamToAssethubTransferWithSwapOnHydration( - receiverAddress, - rawAmount, - assetAccountKey - ); - - // 2. Dry-run the extrinsic - const dryRunResult = await dryRunExtrinsic(extrinsic, network, accountId); - - // 3. Log the result - console.log("Dry-run result:", JSON.stringify(dryRunResult.toHuman(), null, 2)); -}, 30000); // Set a timeout of 30 seconds for the test diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index 9838b146b..52c6fbdd9 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -48,12 +48,12 @@ const MXN_LIMITS: AlfredpayLimitsTable = { }, onramp: { USDC: { - BUSINESS: { maxRaw: "8699689121", minRaw: "20000" }, - INDIVIDUAL: { maxRaw: "8699689121", minRaw: "20000" } + BUSINESS: { maxRaw: "8699689121", minRaw: "15000" }, + INDIVIDUAL: { maxRaw: "8699689121", minRaw: "15000" } }, USDT: { - BUSINESS: { maxRaw: "8695217304", minRaw: "20000" }, - INDIVIDUAL: { maxRaw: "8695217304", minRaw: "20000" } + BUSINESS: { maxRaw: "8695217304", minRaw: "150000" }, + INDIVIDUAL: { maxRaw: "8695217304", minRaw: "150000" } } } }; @@ -71,12 +71,12 @@ const COP_LIMITS: AlfredpayLimitsTable = { }, onramp: { USDC: { - BUSINESS: { maxRaw: "110596799945", minRaw: "3500000" }, - INDIVIDUAL: { maxRaw: "36865599982", minRaw: "3500000" } + BUSINESS: { maxRaw: "110596799945", minRaw: "3300000" }, + INDIVIDUAL: { maxRaw: "36865599982", minRaw: "3300000" } }, USDT: { - BUSINESS: { maxRaw: "110596799945", minRaw: "3500000" }, - INDIVIDUAL: { maxRaw: "36865599982", minRaw: "3500000" } + BUSINESS: { maxRaw: "110596799945", minRaw: "3300000" }, + INDIVIDUAL: { maxRaw: "36865599982", minRaw: "3300000" } } } }; @@ -147,7 +147,7 @@ export const freeTokenConfig: Partial> = }, maxBuyAmountRaw: "8695217304", maxSellAmountRaw: "5000000000000", - minBuyAmountRaw: "20000", + minBuyAmountRaw: "15000", minSellAmountRaw: "1000000", type: TokenType.Fiat }, @@ -162,7 +162,7 @@ export const freeTokenConfig: Partial> = }, maxBuyAmountRaw: "36865599982", maxSellAmountRaw: "100000000000", - minBuyAmountRaw: "3500000", + minBuyAmountRaw: "3300000", minSellAmountRaw: "1000000", type: TokenType.Fiat }, diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts new file mode 100644 index 000000000..41c936d4d --- /dev/null +++ b/scripts/check-coverage.ts @@ -0,0 +1,55 @@ +/** + * Coverage gate for the bun workspaces. Reads an LCOV report produced by + * `bun test --coverage --coverage-reporter=lcov` and fails when the aggregate + * line/function coverage drops below the given floors. + * + * bun scripts/check-coverage.ts + * + * The floors are a ratchet: each workspace's package.json passes values set + * just under the coverage measured when they were last raised. If you add + * meaningfully-tested code, raise them; never lower them to make CI pass. + * + * (bunfig's `coverageThreshold` is not used because bun 1.3.1 enforces it in a + * way that fails on any single uncovered file, which makes a total-level + * ratchet impossible.) + */ +const [lcovPath, lineFloorArg, functionFloorArg] = process.argv.slice(2); +const lineFloor = Number(lineFloorArg); +const functionFloor = Number(functionFloorArg); + +if (!lcovPath || Number.isNaN(lineFloor) || Number.isNaN(functionFloor)) { + console.error("usage: bun scripts/check-coverage.ts "); + process.exit(1); +} + +const lcov = await Bun.file(lcovPath).text(); + +let linesFound = 0; +let linesHit = 0; +let functionsFound = 0; +let functionsHit = 0; + +for (const line of lcov.split("\n")) { + if (line.startsWith("LF:")) linesFound += Number(line.slice(3)); + else if (line.startsWith("LH:")) linesHit += Number(line.slice(3)); + else if (line.startsWith("FNF:")) functionsFound += Number(line.slice(4)); + else if (line.startsWith("FNH:")) functionsHit += Number(line.slice(4)); +} + +if (linesFound === 0 || functionsFound === 0) { + console.error(`check-coverage: ${lcovPath} contains no coverage data — did the coverage run succeed?`); + process.exit(1); +} + +const lineCoverage = linesHit / linesFound; +const functionCoverage = functionsHit / functionsFound; + +console.log( + `check-coverage: lines ${(lineCoverage * 100).toFixed(2)}% (floor ${lineFloor * 100}%), ` + + `functions ${(functionCoverage * 100).toFixed(2)}% (floor ${functionFloor * 100}%)` +); + +if (lineCoverage < lineFloor || functionCoverage < functionFloor) { + console.error("check-coverage: coverage fell below the ratchet floor. Add tests for the code you added."); + process.exit(1); +} diff --git a/scripts/coverage-report.ts b/scripts/coverage-report.ts new file mode 100644 index 000000000..aee3e4f85 --- /dev/null +++ b/scripts/coverage-report.ts @@ -0,0 +1,235 @@ +/** + * Per-area coverage report across all workspaces. Reads the LCOV files produced + * by each workspace's `test:coverage` script and prints line/function coverage + * aggregated per source directory, worst-covered first. + * + * bun run test:coverage # from the repo root: runs all suites, then this + * bun scripts/coverage-report.ts # re-print from existing lcov files + * bun scripts/coverage-report.ts --html coverage/index.html + * # additionally write a browsable HTML report + * # (per-file drill-down incl. uncovered line ranges) + * + * The pass/fail gates stay where they are (scripts/check-coverage.ts and the + * frontend vitest thresholds) — this script is purely a report. + */ +import { dirname, join } from "node:path"; + +const ROOT = join(import.meta.dir, ".."); + +const htmlFlagIndex = process.argv.indexOf("--html"); +const htmlPath = htmlFlagIndex === -1 ? null : (process.argv[htmlFlagIndex + 1] ?? "coverage/index.html"); + +const WORKSPACES = [ + { dir: "apps/api", name: "apps/api" }, + { dir: "packages/shared", name: "packages/shared" }, + { dir: "apps/frontend", name: "apps/frontend" }, + { dir: "apps/rebalancer", name: "apps/rebalancer" } +]; + +// Directory depth to aggregate at, e.g. src/api/services/quote. +const DEPTH = 4; + +interface FileCov { + path: string; + lf: number; + lh: number; + fnf: number; + fnh: number; + /** Consecutive un-hit source lines, merged into ranges like "120-134". */ + uncovered: string[]; +} + +interface Agg { + lf: number; + lh: number; + fnf: number; + fnh: number; + files: FileCov[]; +} + +function dirKey(file: string): string { + const parts = file.replace(/^\.\//, "").split("/"); + parts.pop(); + return parts.slice(0, DEPTH).join("/") || "."; +} + +function pct(hit: number, found: number): string { + return found === 0 ? " n/a" : `${((hit / found) * 100).toFixed(1).padStart(5)}%`; +} + +function parseLcov(lcov: string): FileCov[] { + const files: FileCov[] = []; + let cur: FileCov | null = null; + let missedLines: number[] = []; + for (const line of lcov.split("\n")) { + if (line.startsWith("SF:")) { + cur = { fnf: 0, fnh: 0, lf: 0, lh: 0, path: line.slice(3).replace(/^\.\//, ""), uncovered: [] }; + missedLines = []; + files.push(cur); + } else if (cur && line.startsWith("DA:")) { + const [lineNo, hits] = line.slice(3).split(","); + if (Number(hits) === 0) missedLines.push(Number(lineNo)); + } else if (cur && line.startsWith("LF:")) cur.lf = Number(line.slice(3)); + else if (cur && line.startsWith("LH:")) cur.lh = Number(line.slice(3)); + else if (cur && line.startsWith("FNF:")) cur.fnf = Number(line.slice(4)); + else if (cur && line.startsWith("FNH:")) cur.fnh = Number(line.slice(4)); + else if (cur && line === "end_of_record") { + missedLines.sort((a, b) => a - b); + for (let i = 0; i < missedLines.length; i++) { + const start = missedLines[i]; + while (i + 1 < missedLines.length && missedLines[i + 1] === missedLines[i] + 1) i++; + cur.uncovered.push(start === missedLines[i] ? String(start) : `${start}-${missedLines[i]}`); + } + cur = null; + } + } + return files; +} + +function groupByArea(files: FileCov[]): Map { + const dirs = new Map(); + for (const f of files) { + const key = dirKey(f.path); + const agg = dirs.get(key) ?? { files: [], fnf: 0, fnh: 0, lf: 0, lh: 0 }; + agg.files.push(f); + agg.lf += f.lf; + agg.lh += f.lh; + agg.fnf += f.fnf; + agg.fnh += f.fnh; + dirs.set(key, agg); + } + return dirs; +} + +const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); + +function coverageClass(hit: number, found: number): string { + if (found === 0) return "ok"; + const p = hit / found; + return p >= 0.5 ? "ok" : p >= 0.25 ? "mid" : "low"; +} + +function htmlPct(hit: number, found: number): string { + return found === 0 ? "n/a" : `${((hit / found) * 100).toFixed(1)}%`; +} + +function renderFileRow(f: FileCov): string { + const ranges = f.uncovered.length + ? `${escapeHtml(f.uncovered.slice(0, 12).join(", "))}${f.uncovered.length > 12 ? ", …" : ""}` + : ""; + return ` + ${escapeHtml(f.path)}${ranges} + ${htmlPct(f.lh, f.lf)}${htmlPct(f.fnh, f.fnf)} + ${f.lf}`; +} + +function renderWorkspace(name: string, dirs: Map): string { + const rows = [...dirs.entries()].sort(([, a], [, b]) => (a.lf ? a.lh / a.lf : 1) - (b.lf ? b.lh / b.lf : 1)); + const total: Agg = { files: [], fnf: 0, fnh: 0, lf: 0, lh: 0 }; + let body = ""; + for (const [dir, a] of rows) { + total.lf += a.lf; + total.lh += a.lh; + total.fnf += a.fnf; + total.fnh += a.fnh; + const files = a.files + .sort((x, y) => (x.lf ? x.lh / x.lf : 1) - (y.lf ? y.lh / y.lf : 1)) + .map(renderFileRow) + .join("\n"); + const width = a.lf ? Math.round((a.lh / a.lf) * 100) : 100; + body += ` + + ${escapeHtml(dir)} + + ${htmlPct(a.lh, a.lf)}${htmlPct(a.fnh, a.fnf)} + ${a.lf} + ${files} + \n`; + } + return `
+

${escapeHtml(name)} ${htmlPct(total.lh, total.lf)} lines · ${htmlPct(total.fnh, total.fnf)} functions · ${total.lf} LOC

+ + + ${body} +
area / file (click to expand)linesfnsLOC
+
`; +} + +const STYLE = ` + body { font: 14px/1.5 -apple-system, system-ui, sans-serif; color: #1c2426; background: #f6f7f6; margin: 0; padding: 2rem 1rem; } + main { max-width: 960px; margin: 0 auto; } + h1 { font-size: 1.3rem; margin: 0 0 0.2rem; } + .sub { color: #5c6a6d; font-size: 0.85rem; margin: 0 0 1.5rem; } + h2 { font-size: 1rem; margin: 1.8rem 0 0.5rem; } + h2 small { color: #5c6a6d; font-weight: 400; font-size: 0.8rem; } + table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde3e2; border-radius: 6px; } + th { text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: #5c6a6d; padding: 0.5rem 0.75rem; border-bottom: 1px solid #dde3e2; } + td { padding: 0.35rem 0.75rem; border-bottom: 1px solid #eef1f0; font-variant-numeric: tabular-nums; } + td.num, th.num { text-align: right; white-space: nowrap; } + tr.area { cursor: pointer; font-weight: 600; } + tr.area:hover { background: #f2f5f4; } + tr.file { display: none; font-size: 0.82rem; } + tbody.open tr.file { display: table-row; } + tbody.open .arrow { display: inline-block; transform: rotate(90deg); } + .arrow { color: #5c6a6d; margin-right: 0.4rem; transition: transform 0.1s; } + tr.file .path { padding-left: 2rem; font-family: ui-monospace, Menlo, monospace; } + .ranges { display: block; color: #a05a48; font-size: 0.72rem; margin-top: 0.1rem; word-break: break-all; } + .bar { display: inline-block; vertical-align: middle; width: 110px; height: 8px; background: #e9edec; border-radius: 3px; margin-left: 0.6rem; overflow: hidden; } + .bar i { display: block; height: 100%; } + tr.ok .bar i { background: #2e7d4f; } tr.ok td.num { color: #2e7d4f; } + tr.mid .bar i { background: #a8741f; } tr.mid td.num { color: #a8741f; } + tr.low .bar i { background: #b04a32; } tr.low td.num { color: #b04a32; } +`; + +let missing = false; +const sections: string[] = []; + +for (const ws of WORKSPACES) { + const lcovPath = join(ROOT, ws.dir, "coverage/lcov.info"); + const lcovFile = Bun.file(lcovPath); + if (!(await lcovFile.exists())) { + console.error(`\n${ws.name}: no coverage/lcov.info — run \`bun run test:coverage\` in ${ws.dir} first`); + missing = true; + continue; + } + + const files = parseLcov(await lcovFile.text()); + const dirs = groupByArea(files); + const rows = [...dirs.entries()].sort(([, a], [, b]) => (a.lf ? a.lh / a.lf : 1) - (b.lf ? b.lh / b.lf : 1)); + const total: Agg = { files: [], fnf: 0, fnh: 0, lf: 0, lh: 0 }; + + console.log(`\n${ws.name}`); + console.log(" lines fns files LOC area"); + for (const [dir, a] of rows) { + total.lf += a.lf; + total.lh += a.lh; + total.fnf += a.fnf; + total.fnh += a.fnh; + total.files.push(...a.files); + console.log( + ` ${pct(a.lh, a.lf)} ${pct(a.fnh, a.fnf)} ${String(a.files.length).padStart(6)} ${String(a.lf).padStart(7)} ${dir}` + ); + } + console.log( + ` ${pct(total.lh, total.lf)} ${pct(total.fnh, total.fnf)} ${String(total.files.length).padStart(6)} ${String(total.lf).padStart(7)} TOTAL` + ); + + sections.push(renderWorkspace(ws.name, dirs)); +} + +if (missing) process.exit(1); + +if (htmlPath) { + const out = join(ROOT, htmlPath); + const html = ` + +Vortex coverage report
+

Vortex coverage report

+

Generated ${new Date().toISOString()} · areas sorted worst-covered first · click an area to see its files and their uncovered line ranges

+${sections.join("\n")} +
`; + const { mkdir } = await import("node:fs/promises"); + await mkdir(dirname(out), { recursive: true }); + await Bun.write(out, html); + console.log(`\nHTML report written to ${out}`); +}