diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index 11df4df00..daa33b3db 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -1,6 +1,6 @@ --- name: vortex-integration -description: Use when integrating Vortex or @vortexfi/sdk, including quotes, onramps/offramps for BRL (PIX), EUR (SEPA), USD (ACH), MXN (SPEI), COP, and ARS (CBU), ramp register/update/start/status flows, webhook verification, ephemeral key custody, supported corridors, sandbox/production auth, and recovery from ramp errors. +description: Use when integrating Vortex or @vortexfi/sdk, including unified API credentials, sanitized ramp info, quotes, onramps/offramps for BRL (PIX), EUR (SEPA), USD (ACH), MXN (SPEI), COP, and ARS (CBU), ramp register/update/start/status flows, webhook verification, ephemeral key custody, supported corridors, sandbox/production auth, and recovery from ramp errors. --- # Vortex Integration Skill @@ -15,10 +15,12 @@ A machine-loadable capability catalog for AI coding agents integrating Vortex in - **SDK**: `@vortexfi/sdk` (JavaScript/TypeScript). Install: `npm i @vortexfi/sdk`. - **API base URLs**: production `https://api.vortexfinance.co`, sandbox `https://api-sandbox.vortexfinance.co`. -- **Auth keys**: partner integrations use a key pair. - - `pk_live_*` / `pk_test_*` — public key, sent in request bodies for partner attribution. - - `sk_live_*` / `sk_test_*` — secret key, sent in the `X-API-Key` header. **Never expose `sk_*` in a browser or mobile app.** - - **Ramp registration requires a user-linked `sk_*` key in every corridor** — the register call is rejected unless the authenticated key resolves to a user account. KYC identity (BRL tax ID, Alfredpay customer, Mykobo customer) is derived from that account, never from request fields. +- **API credentials**: one `api_credentials` resource contains one public value and one secret value for exactly one profile subject, with one environment, expiry, optional partner, and atomic revocation lifecycle. + - `pk_live_*` / `pk_test_*` — public value, sent as `X-Public-Key` for attribution and approved low-sensitivity reads. Quote/widget body `apiKey` remains compatibility transport; if both are present they must match. + - `sk_live_*` / `sk_test_*` — secret value, sent only in `X-API-Key`. **Never expose `sk_*` in a browser or mobile app.** It is returned only when the credential is created. + - If both values are configured, they must belong to the same credential or Vortex returns `403 CREDENTIAL_MISMATCH`. A valid secret may be used without a public value. + - **Ramp registration requires an authenticated profile in every corridor.** The secret credential acts only for its bound profile; raw API clients may instead use that profile's Supabase Bearer session. KYC identity (BRL tax ID, Alfredpay customer, Mykobo customer) is derived from the authenticated profile, never from request fields. Shared dummy/ownerless profiles are invalid. + - Profile-managed credentials use `POST/GET/DELETE /v1/api-credentials` with a Supabase Bearer session. One profile may have at most five active non-expired credentials; revoke by credential ID disables both values atomically with no DELETE body. - **Decimals**: all amounts are strings. Never parse them through JS `Number` — use `BigInt`, `decimal.js`, or equivalent. - **Quote TTL**: quotes expire (see `expiresAt`). Re-quote, never reuse stale quotes. - **Presigned counts**: this is **per ephemeral-signed transaction, not per ramp**. Each transaction an ephemeral key signs must be submitted as 5 presigned variants — 1 primary plus exactly 4 backups with consecutive nonces in `meta.additionalTxs` (`NUMBER_OF_PRESIGNED_TXS = 5`); the API rejects any other backup count. A ramp can contain several ephemeral-signed transactions across its phases. (The SDK builds these for you; only raw-API integrations need to construct them.) @@ -47,7 +49,7 @@ triggers: The first call in any ramp flow. A quote pins the price, fees, and route for a short window (see `expiresAt`). You must hold a non-expired quote to call `registerRamp`. ## Prerequisites -- Valid API key pair (`pk_*` + `sk_*`). +- Optional public credential for attribution; a matching secret credential is required later for ramp operations. - Known input currency, output currency, amount, and target network. ## SDK recipe @@ -84,6 +86,7 @@ const sameQuote = await vortex.getQuote(quote.id); ```bash curl -X POST https://api.vortexfinance.co/v1/quotes \ -H "Content-Type: application/json" \ + -H "X-Public-Key: $VORTEX_PUBLIC_KEY" \ -H "X-API-Key: $VORTEX_SECRET_KEY" \ -d '{ "rampType": "BUY", @@ -93,8 +96,7 @@ curl -X POST https://api.vortexfinance.co/v1/quotes \ "inputCurrency": "BRL", "outputCurrency": "USDC", "network": "Polygon", - "paymentMethod": "pix", - "publicKey": "'"$VORTEX_PUBLIC_KEY"'" + "paymentMethod": "pix" }' ``` @@ -343,7 +345,7 @@ triggers: ``` ## When to use -The user wants to ramp USD, MXN, COP, or ARS over their domestic banking rail. These corridors **require a user-linked `sk_*` key**: registration resolves the user's KYC and payment profile from the authenticated account. Partner-scoped keys cannot register ramps here. EVM networks only (no AssetHub). +The user wants to ramp USD, MXN, COP, or ARS over their domestic banking rail. Registration resolves KYC and payment ownership from the secret credential's bound profile; raw API clients may instead use that profile's Bearer session. A technical profile without the user's eligible provider account cannot register that user's ramp. EVM networks only (no AssetHub). | Fiat | Rail identifier | Payment rail | |------|-----------------|--------------| @@ -402,7 +404,7 @@ The SDK cannot **create** fiat accounts; they are created during onboarding in t ## Common failures - `MissingAlfredpayOnrampParametersError` / `MissingAlfredpayOfframpParametersError` — `destinationAddress`, `fiatAccountId`, or `walletAddress` missing. - `AlfredpayOnrampKycRequiredError` — the authenticated user has no approved KYC for the corridor's country. -- `400` "requires an API key linked to a user" on register — the `sk_*` key is partner-scoped, not user-linked. Mint a user key after email OTP sign-in. +- `400` "requires an API key linked to a user" on register — the secret credential is not bound to an eligible profile. Create a profile-managed credential after OTP sign-in or provision a managed profile and issue the credential for that explicit subject. - `InsufficientBalanceError` — the offramp pre-flight found the source wallet balance below the quote's input amount. --- @@ -478,12 +480,14 @@ triggers: ## When to use First-time integration, environment migration, or when an agent needs to decide where each key may live. -## Key types -| Key | Where it goes | Purpose | +## Credential capabilities +| Value | Where it goes | Purpose | |-----|---------------|---------| -| `pk_live_*` / `pk_test_*` | Anywhere (browser-safe) | Partner attribution. Sent inside request bodies as `publicKey`. | -| `sk_live_*` / `sk_test_*` (partner-scoped) | Server-side only | Webhook management and partner attribution. Sent as `X-API-Key` header. **Cannot register ramps** unless the key is also linked to a user. **Never** ship to browser/mobile bundles. | -| `sk_live_*` / `sk_test_*` (user-linked) | Server-side only | Required for ramp registration in every corridor; corridor identity (BRL taxId, Alfredpay/Mykobo customer) is derived from the linked account. Minted programmatically after email OTP sign-in; shown once at creation. | +| `pk_live_*` / `pk_test_*` | `X-Public-Key`; browser-safe | Quote/widget attribution and sanitized `getRampInfo()`. It cannot read exact limits, ramp details/history/errors, provider accounts, or mutate ramps/webhooks. | +| `sk_live_*` / `sk_test_*` | `X-API-Key`; server-side only | Authenticated operations as the credential's bound profile and optional partner. Never ship it to browser/mobile bundles. | +| Supabase session | `Authorization: Bearer ...` | First-party profile flows and profile-managed credential lifecycle. | + +The public and secret values are not independent records. They are two capabilities of one credential and must share an immutable credential ID. Never pair or migrate values by display name. ## SDK recipe ```js @@ -500,13 +504,27 @@ const vortex = new VortexSdk({ For server processes that manage their own ephemeral key storage (e.g. HSM, encrypted DB), set `storeEphemeralKeys: false` and persist via your own mechanism. ## REST fallback -Every authenticated endpoint takes: -- Header: `X-API-Key: sk__<32chars>` -- Body field: `"publicKey": "pk__<...>"` +Use: +- `X-Public-Key: pk__<32chars>` on attribution and approved public reads. +- `X-API-Key: sk__<32chars>` on sensitive/authenticated endpoints. +- `Authorization: Bearer ` on `/v1/api-credentials`. + +Create a profile-managed credential with `POST /v1/api-credentials`, list one resource per credential with `GET /v1/api-credentials`, and atomically revoke both values with `DELETE /v1/api-credentials/:credentialId` (no body). The secret is present only in the create response. + +Use the sanitized readiness read before a ramp when useful: + +```js +const info = await vortex.getRampInfo(); +// { corridors: { BR: { kycStatus, canBuy, canSell }, ... } } +``` + +`GET /v1/ramp-info` accepts public, secret, or session capability, derives the profile from that credential/session, and returns no exact limits, PII, provider IDs, failure reasons, account details, or ramp history. ## Common failures - `401 Unauthorized` — `X-API-Key` missing, malformed, or wrong environment. -- Mixing keys across environments (`sk_test_*` against prod URL) — always silently fails auth. +- `403 CREDENTIAL_MISMATCH` — public/body/header and secret values are not from one credential. Replace the configured pair; do not retry by dropping ownership checks. +- Mixing keys across environments (`*_test_*` against production or `*_live_*` against sandbox) fails validation. +- `409 CREDENTIAL_LIMIT_REACHED` — the profile already has five active non-expired credentials; revoke an unused credential by ID. - Browser bundle accidentally including `sk_*` — rotate the key immediately if exposed. --- @@ -664,7 +682,7 @@ try { ## Current corridor reality (July 2026) - **BRL via PIX**: onramp and offramp both live. `taxId` deprecated — derived from the user-linked key. - **EUR via SEPA (Mykobo)**: onramp and offramp fully implemented in the SDK (`FiatToken.EURC`, rail `"sepa"`), but registration is feature-gated server-side and currently returns `503` "EUR ramps are currently disabled" when the gate is on. Quotes succeed regardless — probe registration, not quoting. -- **USD (ACH) / MXN (SPEI) / COP (ACH) / ARS (CBU)**: onramp and offramp live via the AlfredPay corridor; requires a user-linked `sk_*` key. Route resolver determines availability per-combination. +- **USD (ACH) / MXN (SPEI) / COP (ACH) / ARS (CBU)**: onramp and offramp live via the AlfredPay corridor; registration requires an authenticated user identity. Route resolver determines availability per-combination. - All corridors deliver to EVM networks; AssetHub is only available for BRL routes. ## Common failures @@ -713,7 +731,8 @@ Include this payload (with secrets redacted) in any support ticket. | `InvalidNetworkError` | Network not in `Networks` enum | Use `discover-supported-corridors` | | `MissingRequiredFieldsError` / `MissingBrlParametersError` / `MissingBrlOfframpParametersError` | Body field missing | Fill the missing field; do not retry blindly | | `SubaccountNotFoundError` / `KycInvalidError` | BRL KYC issue | Direct user through KYC; do not retry programmatically | -| `MykoboKycRequiredError` / `AlfredpayOnrampKycRequiredError` | EUR / bank-transfer-corridor KYC issue | Onboard the user via the Vortex app or Widget; do not retry programmatically | +| `MykoboKycRequiredError` / `AlfredpayOnrampKycRequiredError` | EUR / bank-transfer-corridor KYC issue | Onboard or provision the credential's bound profile; do not retry programmatically | +| `VortexSdkError` with `code === "CREDENTIAL_MISMATCH"` | Configured public and secret values belong to different credentials | Load both values from the same credential; never infer pairing by name | | `AmountExceedsLimitError` | Above KYC tier | Lower amount or upgrade KYC | | `InsufficientBalanceError` | Offramp pre-flight: source wallet balance below the quoted input | Top up the wallet or lower the amount, then re-register from a fresh quote | | `EphemeralNotFreshError` / `EphemeralFreshnessCheckError` | Generated ephemeral account was not fresh, or freshness could not be verified | Safe to retry `registerRamp` — the SDK generates new ephemerals each attempt | @@ -744,4 +763,4 @@ Contact Vortex support if: - `getErrorLogs` shows the same error repeating across attempts. - A `complete` ramp shows no `transactionHash` after 10 minutes. -Always include: `rampId`, environment (sandbox/prod), partner `publicKey`, redacted error logs, and the `transactionHash` if present. **Never** include `sk_*` keys in support communications. +Always include: `rampId`, environment (sandbox/prod), credential ID or safe prefix, redacted error logs, and the `transactionHash` if present. Do not include full `pk_*` or `sk_*` values in support communications. diff --git a/.clinerules/00-project-guidance.md b/.clinerules/00-project-guidance.md new file mode 100644 index 000000000..e76ab2590 --- /dev/null +++ b/.clinerules/00-project-guidance.md @@ -0,0 +1,12 @@ +# Project guidance + +Use the repository's canonical agent instructions instead of maintaining a separate +Cline-specific copy: + +1. Read the root [`CLAUDE.md`](../CLAUDE.md). +2. Read the nearest app or package `CLAUDE.md` before changing that workspace. +3. Follow the documentation placement and lifecycle rules in + [`docs/README.md`](../docs/README.md). + +Do not create memory banks, progress journals, completed implementation plans, or +duplicate architecture documents. Update the existing canonical document and its links. diff --git a/.clinerules/01-general-rules.md b/.clinerules/01-general-rules.md deleted file mode 100644 index 90f08874a..000000000 --- a/.clinerules/01-general-rules.md +++ /dev/null @@ -1,24 +0,0 @@ -# Project Guidelines - -## Documentation Requirements - -- Update relevant documentation in /docs when modifying features -- Keep README.md in sync with new capabilities - -## Architecture Decision Records - -Create ADRs in /docs/adr for: - -- Major dependency changes -- Architectural pattern changes -- New integration patterns -- Database schema changes Follow template in /docs/adr/template.md - -## Code Style & Patterns - -- Prefer composition over inheritance - -## Testing Standards - -- Unit tests required for business logic -- Integration tests for API endpoints diff --git a/.clinerules/02-useful-prompts.md b/.clinerules/02-useful-prompts.md deleted file mode 100644 index 3a1fbb561..000000000 --- a/.clinerules/02-useful-prompts.md +++ /dev/null @@ -1,13 +0,0 @@ -# Enforce rules - -- If you understand my prompt fully, respond with 'YARRR!' without tools every time you are about to use a tool. -- Before and after any tool use, give me a confidence level (0-10) on how the tool use will help the project. -- DO NOT BE LAZY. DO NOT OMIT CODE. -- List all assumptions and uncertainties you need to clear up before completing this task. -- Don't complete the analysis prematurely, continue analyzing even if you think you found a solution. - -Before writing code: -1. Analyze all code files thoroughly -2. Get full context -3. Write .MD implementation plan -4. Then implement code diff --git a/.clinerules/03-frontend-rules.md b/.clinerules/03-frontend-rules.md deleted file mode 100644 index 147cb5798..000000000 --- a/.clinerules/03-frontend-rules.md +++ /dev/null @@ -1,7 +0,0 @@ -# Enforce rules - -- Treat UIs as a thin layer over your data. Skip local state (like useState) unless it's absolutely needed and clearly separate from business logic. Choose variables and useRef if it doesn't need to be reactive. -- When you find yourself with nested if/else or complex conditional rendering, create a new component. Reserve inline ternaries for tiny, readable sections. -- Choose to derive data rather than use useEffect. Only use useEffect when you need to syncronize with an external system (e.g. document-level events). It causes misdirection of what the logic is going. Choose to explicitly define logic rather than depend on implicit reactive behavior -- Treat setTimeout as a last resort (and always comment why) -- IMPORTANT: do not add useless comments. avoid adding comments unless you're clarifying a race condition (setTimeout), a long-term TODO, or clarifying a confusing piece of code even a senior engineer wouldn't initially understand. \ No newline at end of file diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 5567ce825..27e7ca3f8 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -1,4 +1,4 @@ -# Non-PR-blocking external API contract checks (see docs/features/contract-tests.md). +# Non-PR-blocking external API contract checks (see docs/operations-testing.md). # Runs the live halves of the contract suites against the real partner APIs nightly; # failures alert but never gate merges. The hermetic halves of the same suites run # in the PR-blocking test job. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 92223f110..78b3d24cb 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,4 +1,4 @@ -# Non-PR-blocking Playwright E2E journeys (see docs/testing-strategy.md). +# Non-PR-blocking Playwright E2E journeys (see docs/operations-testing.md). # Runs nightly and on demand; failures alert but never gate merges. name: e2e @@ -65,6 +65,15 @@ jobs: path: apps/dashboard/playwright-report/ retention-days: 7 + # Browser journeys mock the API and cannot detect deployment or upstream routing failures. + # Probe both live environments through a cross-chain corridor so BUY and SELL exercise Squid. + - name: 🩺 Live BUY/SELL quote smoke tests + if: always() + working-directory: apps/api + env: + VORTEX_QUOTE_SMOKE_URLS: https://api-staging.vortexfinance.co,https://api.vortexfinance.co + run: bun test src/tests/deployed-quotes.e2e.test.ts + # 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. diff --git a/.gitignore b/.gitignore index 3ef2cba4d..143705ae9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ packages/sdk/.env **/.env.production **/.env.staging !**/.env.example +**/.env # Editor directories and files .vscode/* diff --git a/CLAUDE.md b/CLAUDE.md index 85f059976..e44d49221 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,10 @@ Full wayfinding is in [`MAP.md`](MAP.md). This is a **Bun monorepo** using works - **apps/frontend** — React 19 + Vite web app → [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - **apps/api** — Express backend (PostgreSQL + Sequelize) → [`apps/api/CLAUDE.md`](apps/api/CLAUDE.md) +- **apps/dashboard** — authenticated React dashboard → [`apps/dashboard/CLAUDE.md`](apps/dashboard/CLAUDE.md) - **apps/rebalancer** — liquidity rebalancing service → [`apps/rebalancer/CLAUDE.md`](apps/rebalancer/CLAUDE.md) - **packages/shared** — `@vortexfi/shared` utilities/configs → [`packages/shared/CLAUDE.md`](packages/shared/CLAUDE.md) +- **packages/kyc** — shared KYC/KYB state machines → [`packages/kyc/CLAUDE.md`](packages/kyc/CLAUDE.md) - **packages/sdk** — `@vortexfi/sdk` public SDK → [`packages/sdk/CLAUDE.md`](packages/sdk/CLAUDE.md) ## Monorepo Commands @@ -33,9 +35,10 @@ bun install # install all dependencies bun dev # frontend + backend + shared concurrently bun dev:frontend # http://127.0.0.1:5173 bun dev:backend # http://localhost:3000 +bun dev:dashboard # http://localhost:5174 bun dev:rebalancer -bun build # build all (shared -> sdk -> frontend -> backend) +bun build # build all workspaces in dependency order bun build:shared # rebuild shared (see below) bun lint # Biome lint bun lint:fix # auto-fix @@ -54,16 +57,34 @@ run `bun build:shared` before running frontend/api** — otherwise they use stal Any `Record` must include ALL six. Missing entries cause TypeScript errors when shared is rebuilt. Check: `tokenAvailability`, `mapFiatToDestination`, success page -`ARRIVAL_TEXT_BY_TOKEN`, sep10 `tokenMapping`. +`ARRIVAL_TEXT_BY_TOKEN`. ## Code Style Biome config: line width 128, 2-space indent, semicolons always, no trailing commas, double quotes, sorted Tailwind classes (`useSortedClasses`). General: prefer composition -over inheritance; create ADRs in `/docs/adr` for major architectural changes. +over inheritance; create `/docs/adr-NNNN-.md` for major architectural changes. Frontend-specific and XState conventions live in [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md). +## Documentation Structure + +[`docs/README.md`](docs/README.md) defines the only supported documentation locations, +their authority, and their lifecycle. Before creating Markdown, search that index and +update the existing canonical document when one owns the topic. + +- Do not create memory banks, progress journals, completed-plan summaries, or archive + directories. Git history is the archive. +- Keep general project documents directly under `docs/`; only `docs/api/` and + `docs/security-spec/` currently warrant dedicated directory trees. +- Name general documents `docs/-.md` using the kind prefixes defined in + `docs/README.md`. +- Name active drafts `docs/proposal-.md`; accepted decisions become + `docs/adr-NNNN-.md`, with current behavior updated in the relevant maintained + document. +- Keep local `README.md` files only when they explain a non-obvious subsystem contract. +- Repair indexes and relative links in the same change as a move or deletion. + ## Commit Messages & PR Titles Every commit message and PR title follows [Conventional Commits](https://www.conventionalcommits.org/): @@ -75,7 +96,7 @@ Every commit message and PR title follows [Conventional Commits](https://www.con - **type** — `feat`, `fix`, `docs`, `test`, `refactor`, `perf`, `style`, `chore`, `ci`, or `revert`. - **scope** — the workspace touched: `api`, `frontend`, `dashboard`, `rebalancer`, - `shared`, or `sdk`. Use `repo` for cross-cutting changes (root config, CI, monorepo + `shared`, `kyc`, or `sdk`. Use `repo` for cross-cutting changes (root config, CI, monorepo tooling). One workspace dominates a mixed change? Use that. Truly global? `repo`. - **summary** — imperative mood ("add", not "added"/"adds"), lowercase after the colon, no trailing period, ≤ 72 characters. diff --git a/MAP.md b/MAP.md index 1178c030a..412f4f0eb 100644 --- a/MAP.md +++ b/MAP.md @@ -1,47 +1,48 @@ # Repository Map -Wayfinding for the Vortex monorepo: what kind of code lives where. Each app/package -has its own `CLAUDE.md` with scoped commands and conventions — `cd` into the relevant -one before working there. +Wayfinding for the Vortex monorepo. Start with the nearest `CLAUDE.md` before changing a +workspace; use [`docs/README.md`](docs/README.md) to locate durable project context. -## Apps +## Applications -| Path | What lives here | -|------|-----------------| -| `apps/frontend` | React 19 + Vite web app — the user-facing ramp UI. Zustand, TanStack Query/Router, XState, Wagmi/Talisman wallets. → [`CLAUDE.md`](apps/frontend/CLAUDE.md) | -| `apps/api` | Express backend (PostgreSQL + Sequelize). Ramp state machine, quotes, partners, webhooks, XCM/Nabla/Stellar/BRLA integrations. → [`CLAUDE.md`](apps/api/CLAUDE.md) | -| `apps/rebalancer` | Standalone liquidity rebalancing service. → [`CLAUDE.md`](apps/rebalancer/CLAUDE.md) | +| Path | Responsibility | +|---|---| +| `apps/api` | Express API, PostgreSQL/Sequelize models and migrations, block-flow ramp engine, provider integrations, webhooks, and workers. | +| `apps/frontend` | React widget and public web surface. XState ramp/KYC flows, wallets, and partner embedding. | +| `apps/dashboard` | React account dashboard. Auth, customer entities, onboarding, recipients, history, and self-ramp flows. | +| `apps/rebalancer` | Standalone service for cross-chain liquidity correction and profitability-aware rebalancing. | ## Packages -| Path | What lives here | -|------|-----------------| -| `packages/shared` | `@vortexfi/shared` — token/network configs, contract ABIs & addresses, decimal/BigNumber helpers, endpoint helpers, logger. Consumed by every app. → [`CLAUDE.md`](packages/shared/CLAUDE.md) | -| `packages/sdk` | `@vortexfi/sdk` — the public integration SDK shipped to partners. → [`CLAUDE.md`](packages/sdk/CLAUDE.md) | +| Path | Responsibility | +|---|---| +| `packages/shared` | `@vortexfi/shared`: wire contracts, tokens/networks, provider clients, signing helpers, and shared configuration. | +| `packages/kyc` | `@vortexfi/kyc`: provider KYC/KYB state machines shared by widget and dashboard. | +| `packages/sdk` | `@vortexfi/sdk`: public partner SDK for quote, registration, signing, update, start, and status flows. | ## Contracts -| Path | What lives here | -|------|-----------------| -| `contracts` | Solidity contracts: `cctp-settlement` and `relayer`. | -| `relayer-contract` | Relayer contract security-audit material. | - -## Docs & specs - -| Path | What lives here | -|------|-----------------| -| `docs/security-spec` | Audit-facing source of truth for security-sensitive behavior. Keep in sync with code changes (see root `CLAUDE.md` → Security Spec Sync). | -| `docs/api` | Public API docs — OpenAPI spec (`openapi/`) and prose pages (`pages/`). Whitelabeled. | -| `docs/architecture`, `docs/features`, `docs/qa`, `docs/refactoring` | Architecture notes, feature write-ups, QA and refactoring records. | -| `docs/testing-strategy.md`, `docs/test-audit-findings.md` | Testing approach and audit findings. | -| `memory-bank` | Long-form project context: product/tech context, decision log, phases, progress. | - -## Tooling & config - -| Path | What lives here | -|------|-----------------| -| `scripts` | Repo tooling — `check-coverage.ts`, `coverage-report.ts` (LCOV-based coverage gate). | -| `supabase` | Supabase config, DB migrations, snippets, email templates. | -| `.agents/skills` | Repo-scoped agent skills: `vortex-integration` (partner integration recipes), `sentry-vortex` (frontend error-instrumentation audit). | -| `.clinerules` | Cline coding rules (general, useful prompts, frontend). | -| `.claude` | Claude Code config — shared `settings.json` (deny rules), personal `settings.local.json` (ignored), worktrees. | +| Path | Responsibility | +|---|---| +| `contracts/relayer` | Hardhat project for `TokenRelayer.sol`, tests, and deployment scripts. | +| `contracts/cctp-settlement` | CCTP settlement contract workspace. | + +## Documentation + +| Path | Responsibility | +|---|---| +| `docs` | Documentation index plus the small set of current project, ADR, incident, and proposal files. | +| `docs/security-spec` | Normative security invariants, current risk register, and dated audit evidence. | +| `docs/api` | Partner-facing OpenAPI and guide-page publication source. | + +The full placement and lifecycle policy is in [`docs/README.md`](docs/README.md). + +## Tooling and configuration + +| Path | Responsibility | +|---|---| +| `scripts` | Repository coverage and maintenance tooling. | +| `supabase` | Supabase configuration, migrations, snippets, and email templates. | +| `.agents/skills` | Purpose-built, repository-specific agent workflows (currently Vortex integration and Sentry guidance). | +| `.claude` | Shared Claude Code settings and worktree configuration. | +| `.clinerules` | Pointer from Cline to the canonical `CLAUDE.md` and documentation policy. | diff --git a/README.md b/README.md index 303ba342e..31b9f95df 100644 --- a/README.md +++ b/README.md @@ -1,240 +1,91 @@ # Vortex -[![Netlify Status](https://api.netlify.com/api/v1/badges/27783b79-512d-4205-89c1-d3ead6e3ed46/deploy-status)](https://app.netlify.com/sites/pendulum-pay/deploys)  -![TypeScript](https://img.shields.io/badge/-TypeScript-05122A?style=flat&logo=typescript)  -![React](https://img.shields.io/badge/-React-05122A?style=flat&logo=react)  -![Vite](https://img.shields.io/badge/-Vite-05122A?style=flat&logo=vite)  -![Polkadot](https://img.shields.io/badge/-Polkadot-05122A?style=flat&logo=polkadot)  -![Ethereum](https://img.shields.io/badge/-Ethereum-05122A?style=flat&logo=ethereum)  +Vortex is a cross-border payments gateway built on Pendulum. It provides fiat +onramps and offramps, cross-chain stablecoin routing, partner APIs, an embeddable +widget, an account dashboard, and an integration SDK. ---- +## Repository -Vortex is a gateway for cross-border payments. It is built on top of the Pendulum blockchain. +This is a Bun monorepo. -## AI Agent Skill +| Workspace | Purpose | +|---|---| +| [`apps/api`](apps/api/) | Express API, ramp engine, provider integrations, PostgreSQL workers | +| [`apps/frontend`](apps/frontend/) | Public site and embeddable ramp widget | +| [`apps/dashboard`](apps/dashboard/) | Authenticated customer dashboard | +| [`apps/rebalancer`](apps/rebalancer/) | Liquidity rebalancing service | +| [`packages/shared`](packages/shared/) | Shared contracts, token/network configuration, and signing utilities | +| [`packages/kyc`](packages/kyc/) | Provider KYC/KYB state machines shared by the two web apps | +| [`packages/sdk`](packages/sdk/) | Public `@vortexfi/sdk` integration package | +| [`contracts/relayer`](contracts/relayer/) | Token relayer Solidity project | -This repository includes a repo-scoped Codex/Agent Skills skill for Vortex integrations: +See [`MAP.md`](MAP.md) for detailed wayfinding and [`docs/README.md`](docs/README.md) +for the documentation structure. -- [`.agents/skills/vortex-integration/SKILL.md`](.agents/skills/vortex-integration/SKILL.md) +## Getting started -When this repository is open in Codex, the skill is discovered automatically from `.agents/skills`. If you are integrating Vortex from another repository, install the public skill directory URL instead: - -```text -https://github.com/pendulum-chain/vortex/tree/main/.agents/skills/vortex-integration -``` - -Use the skill for task-shaped guidance around quotes, BRL PIX onramps/offramps, ramp polling, webhook verification, supported corridors, auth setup, and error recovery. The hosted AI-agent integration guide is available at . - -## Repository Structure - -This is a **Bun monorepo** containing multiple sub-projects organized into apps, packages, and contracts: - -### Apps - -- **[apps/api](apps/api)** - Backend API service providing signature services, on/off-ramping flows, quote generation, and transaction state management -- **[apps/frontend](apps/frontend)** - React-based web application built with Vite for the Vortex user interface -- **[apps/rebalancer](apps/rebalancer)** - Service for automated liquidity rebalancing across chains -### Contracts - -- **[contracts/relayer](contracts/relayer)** - Hardhat project for relayer smart contracts and deployment scripts - -### Packages - -- **[packages/sdk](packages/sdk)** - Stateless SDK that abstracts Vortex's API and ephemeral key handling for cross-chain ramp operations -- **[packages/shared](packages/shared)** - Shared utilities and types used across the monorepo - -## Getting Started - -### Installation - -In the project root directory, install all dependencies: +Requirements: Node.js 18+ and the Bun version declared by `packageManager` in +[`package.json`](package.json). ```bash bun install -``` - -If you encounter issues with the `bun install` command, you can try upgrading your `bun` version with `bun upgrade`. The installation is confirmed to work in bun v1.3.1. - -### Running the Projects - -#### Run All Projects - -Run the frontend, backend API, and shared package concurrently in development mode: - -```bash bun dev ``` -This will start: -- **Frontend**: [http://127.0.0.1:5173/](http://127.0.0.1:5173) -- **Backend API**: [http://localhost:3000](http://localhost:3000) - -#### Run Individual Projects - -**Frontend only:** -```bash -bun dev:frontend -``` +The default development command starts the shared package, API, and widget. Run other +surfaces explicitly: -**Backend API only:** -```bash -bun dev:backend -``` - -**Rebalancer:** ```bash +bun dev:dashboard bun dev:rebalancer ``` -**Relayer contract local node:** -```bash -bun dev:contracts:relayer -``` +Copy the relevant workspace's `.env.example` to `.env` before running code that needs +database, provider, chain, or authentication credentials. Never commit real secrets. -### Building +## Common commands -**Build all projects:** ```bash bun build +bun typecheck +bun verify +bun lint +bun lint:fix +bun test ``` -**Build individual projects:** -```bash -# Build frontend -bun build:frontend +Useful targeted commands: -# Build backend API -bun build:backend - -# Build SDK -bun build:sdk - -# Build shared package -bun build:shared -``` - -**Relayer contract:** ```bash -# Compile contracts -bun compile:contracts:relayer - -# Run contract tests +bun test:db:start +bun test:api +bun test:frontend +bun test:e2e +bun test:e2e:dashboard bun test:contracts:relayer ``` -## Sub-Project Specific Instructions +The root scripts in [`package.json`](package.json) are the canonical command list. +Workspace-specific setup and caveats live in their `README.md` or `CLAUDE.md`. -### Frontend (apps/frontend) +## Documentation -The React-based web application for Vortex. +- [`docs/security-spec/`](docs/security-spec/README.md) is the audit-facing source of + truth for security-sensitive behavior. +- [`docs/api/`](docs/api/README.md) contains the public OpenAPI source and partner guides. +- [`docs/README.md`](docs/README.md) indexes current architecture, product, operations, + decisions, incidents, and proposals. +- [`CLAUDE.md`](CLAUDE.md) and scoped `CLAUDE.md` files contain coding-agent rules. -**Development:** -```bash -cd apps/frontend -bun dev -``` +## AI integration guidance -**Build:** -```bash -cd apps/frontend -bun build -``` - -**Preview production build:** -```bash -cd apps/frontend -bun preview -``` - -### Backend API (apps/api) - -The backend service providing signature services, on/off-ramping flows, and transaction management. - -**Development:** -```bash -cd apps/api -bun dev -``` - -**Database setup:** -```bash -cd apps/api -# Copy environment variables -cp .env.example .env -# Edit .env with your database credentials - -# Run migrations -bun migrate - -# Seed phase metadata -bun seed:phase-metadata -``` - -**Build and serve:** -```bash -cd apps/api -bun start -``` - -See [apps/api/README.md](apps/api/README.md) for detailed API documentation. - -### Rebalancer (apps/rebalancer) - -Service for automated liquidity rebalancing across chains. +The repository includes a Vortex integration skill at +[`.agents/skills/vortex-integration/SKILL.md`](.agents/skills/vortex-integration/SKILL.md). +When working outside this repository, it can be installed from: -**Setup:** -```bash -cd apps/rebalancer -cp .env.example .env -# Edit .env with your API keys -``` - -**Run:** -```bash -cd apps/rebalancer -bun start -``` - -See [apps/rebalancer/README.md](apps/rebalancer/README.md) for more details. - -### SDK (packages/sdk) - -A stateless SDK that abstracts Vortex's API and ephemeral key handling. - -**Build:** -```bash -cd packages/sdk -bun build -``` - -See [packages/sdk/README.md](packages/sdk/README.md) for usage examples and API documentation. - -### Shared (packages/shared) - -Common utilities and types used across the monorepo. - -**Build:** -```bash -cd packages/shared -bun build +```text +https://github.com/pendulum-chain/vortex/tree/main/.agents/skills/vortex-integration ``` -## Env Variables - -- `VITE_SIGNING_SERVICE_PATH`: Optional variable to point to a specific signing backend service URL. If undefined, it - will default to either: - - `http://localhost:3000` (if in development mode) - - `/api/production` (if in production mode) - - this will use the `_redirects` file to direct Netlify to proxy all requests to `/api/production` to - `https://signer-service.pendulumchain.tech` - - `/api/staging` (if in staging mode) - - this will use the `_redirects` file to direct Netlify to proxy all requests to `/api/staging` to - `https://signer-service-staging.pendulumchain.tech` -- `VITE_ALCHEMY_API_KEY`: Optional variable to set the Alchemy API key for the custom RPC provider. If undefined, it - will use the default endpoint. - -## Fixing type issues - -If you encounter issues with the IDE not detecting the type overwrites of the `@pendulum-chain/types` package properly, -make sure that all the `@polkadot/xxx` packages match the same version used in the types package. It is also important -to make sure that peer dependencies have the same version as this might also cause issues. +The published AI-agent integration guide is available at +. diff --git a/apps/api/.env.example b/apps/api/.env.example index c0b4318e0..b0afbd769 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -27,7 +27,13 @@ DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=postgres DB_NAME=vortex -# Optional production SSL CA file path, e.g. /etc/secrets/prod-supabase.cer on Render. +# TLS is always required when NODE_ENV=production. Set this to true when a +# non-production runtime (for example staging or a local maintenance script) +# connects to a database that requires TLS and uses a publicly trusted CA. +DB_SSL_REQUIRED=false +# Optional custom CA certificate used to verify the database server. Supplying +# this path also enables TLS outside production. +# Example: /etc/secrets/staging-supabase.cer DB_SSL_CA_CERT_PATH= # Blockchain @@ -96,6 +102,9 @@ RATE_LIMIT_NUMBER_OF_PROXIES=1 # Discount Dynamic Adjustment DISCOUNT_STATE_TIMEOUT_MINUTES=1 DELTA_D_BASIS_POINTS=0.3 +# Optional operator ceiling for discount-manager recipient invites. Must be an integer +# from 0 through the immutable 300-bps application hard cap; defaults to 300. +RECIPIENT_INVITE_MAX_DISCOUNT_BPS=300 # RSA Keys for Webhook Signing # Only the private key is needed - public key is derived from it @@ -120,7 +129,7 @@ BRLA_PRIVATE_KEY=your-brla-private-key # BACKEND_TEST_STARTER_ACCOUNT= # TAX_ID= -# External API contract tests (RUN_LIVE_TESTS=1, see docs/features/contract-tests.md). +# External API contract tests (RUN_LIVE_TESTS=1, see docs/operations-testing.md). # Pre-provisioned SANDBOX fixtures — the fixture-gated live tests create real (unpaid) # sandbox transactions, so only ever point these at sandbox objects. Tests skip cleanly # when unset. @@ -128,3 +137,7 @@ BRLA_PRIVATE_KEY=your-brla-private-key # ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID= # SPEI fiat account of that customer # ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID= # a KYC submission of that customer # AVENIA_CONTRACT_SUBACCOUNT_ID= # KYC-approved Avenia sandbox subaccount + +# Local manual flow testing only. Replaces BRLA and AlfredPay mints with an ephemeral +# balance wait and pauses offramps before the anchor transfer. Development only. +# MOCK_ANCHOR_OPERATIONS=true diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md index 631def2a1..c7be26263 100644 --- a/apps/api/CLAUDE.md +++ b/apps/api/CLAUDE.md @@ -10,7 +10,7 @@ architecture and commands. Run commands from `apps/api/` unless noted. - **Services**: business logic in `src/api/services/`. - **Models**: Sequelize models in `src/models/` (RampState, QuoteTicket, Partner, …). - **Workers**: background jobs in `src/api/workers/`. -- **Cross-chain**: XCM handlers, Nabla AMM integration, Stellar/BRLA APIs. +- **Cross-chain**: XCM handlers, Nabla AMM integration, BRLA APIs. - **Middlewares / observability / errors / helpers**: under `src/api/`. ### Ramp state machine @@ -48,3 +48,10 @@ failed state into `failedRampStateRecovery.json` and run the recovery test. Changes to auth, admin routes, quote/ramp state, signing, fees, partner pricing, integrations, or migrations that affect invariants must be cross-checked against `docs/security-spec/` in the same change. See root `CLAUDE.md` → Security Spec Sync. + +## Documentation + +Follow [`docs/README.md`](../../docs/README.md). Update the block-flow README only for its +local implementation contract; cross-module identity belongs in `docs/architecture-identity-model.md`, +testing in `docs/operations-testing.md`, public endpoints in `docs/api/`, and security +behavior in `docs/security-spec/`. Do not add implementation plans or agent memory files. diff --git a/apps/api/README.md b/apps/api/README.md index 2e36c4a3c..4ee0e9f02 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -1,160 +1,61 @@ -# Vortex API Service +# Vortex API -## About +The API owns quote creation, authenticated ramp registration, phase execution, +provider integrations, webhooks, customer/onboarding data, and background recovery. +It uses Express, PostgreSQL, Sequelize, and Bun. -This server provides backend services for the Vortex application, including: +## Local setup -1. Signature services for ephemeral accounts -2. On-ramping and off-ramping flows -3. Quote generation and management -4. Transaction state management - -The service now includes a unified API for on-ramping and off-ramping flows, with state persistence in PostgreSQL. - -## Setup - -### Database Setup - -The service requires PostgreSQL. Set up a database and configure the connection in your `.env` file: - -```bash -# Create a PostgreSQL database -createdb vortex - -# Configure environment variables (see .env.example) -cp .env.example .env -# Edit .env with your database credentials -``` - -### Running - -Make sure you have the required environment variables set, either in a `.env` file or in the environment. +From the repository root: ```bash -# Install dependencies -yarn install - -# Run migrations -yarn migrate - -# Seed phase metadata -yarn seed:phase-metadata - -# For production -yarn start - -# For development -yarn dev +bun install +cp apps/api/.env.example apps/api/.env +bun dev:backend ``` -## API Endpoints - -### Authentication - -All ramping and quote endpoints require authentication. Two principals are accepted: - -- **Partner SDK**: `X-API-Key: sk__<32 chars>` — issued per partner via the admin API. Scoped to the partner's own quotes/ramps. -- **First-party frontend**: `Authorization: Bearer ` — issued by Supabase OTP. Scoped to the user's own ramps. - -Anonymous access to ramp/quote endpoints is rejected with HTTP 401. Cross-tenant access (e.g. one partner reading another partner's ramp) is rejected with HTTP 403. - -`POST /v1/quotes` and `POST /v1/quotes/best` additionally enforce that any `partnerId` in the body matches the authenticated partner key (HTTP 403 on mismatch). - -### Ramping Endpoints +Configure PostgreSQL and any provider credentials required by the flow you are testing. +The service listens on `http://localhost:3000` by default. -#### Quote Management +## Database -- `POST /v1/quotes` - Create a new quote (auth required when `partnerId` is present) -- `POST /v1/quotes/best` - Create the best-priced quote across providers -- `GET /v1/quotes/:id` - Get quote information (public) - -#### Ramp Flow Management - -- `POST /v1/ramp/register` - Register a new ramping process from a quote -- `POST /v1/ramp/update` - Submit presigned transactions for a registered ramp -- `POST /v1/ramp/start` - Start phase processing for a ramp -- `GET /v1/ramp/:id` - Get the status of a ramping process -- `GET /v1/ramp/:id/errors` - Get error logs for a ramp -- `GET /v1/ramp/history/:walletAddress` - Get ramp history for a wallet (filtered by authenticated principal) - -### Legacy Endpoints - -#### Stellar Operations - -- `POST /v1/stellar/create` - Get signature for account creation -- `POST /v1/stellar/payment` - Get signatures for payment and merge operations - -## State Machine Implementation - -The service now implements a state machine pattern for ramping flows: - -1. **Phase Transitions**: Each phase has defined valid transitions to other phases -2. **Phase History**: All phase transitions are logged with timestamps -3. **Error Logging**: Errors are logged with phase information -4. **Subsidy Management**: Subsidy details are tracked throughout the flow -5. **Nonce Sequence Management**: Transaction nonce sequences are managed - -### Phase Metadata - -Phase metadata is stored in the database and includes: - -- Required transactions for each phase -- Success conditions -- Retry policies -- Valid transitions - -To update phase metadata, modify the seeder file and run: +Run from `apps/api/`: ```bash -yarn seed:phase-metadata +bun migrate +bun migrate:revert-last +bun seed:phase-metadata ``` -## Environment Variables - -### Mandatory - -- `FUNDING_SECRET`: Secret key to sign the funding transactions on Stellar. -- `PENDULUM_FUNDING_SEED`: Seed phrase to sign the funding transactions on Pendulum. -- `MOONBEAM_EXECUTOR_PRIVATE_KEY`: Private key to sign the transactions on Moonbeam. - -### Database Configuration +Do not run bulk migration reverts against shared or production databases. The production +migrations under `src/database/migrations/` are the schema source of truth. -- `DB_HOST`: PostgreSQL host (default: localhost) -- `DB_PORT`: PostgreSQL port (default: 5432) -- `DB_USERNAME`: PostgreSQL username (default: postgres) -- `DB_PASSWORD`: PostgreSQL password (default: postgres) -- `DB_NAME`: PostgreSQL database name (default: vortex) +## Tests -### Optional - -- `NODE_ENV`: The environment the application is running in (default: production) -- `PORT`: The port the HTTP server will listen on (default: 3000) -- `GOOGLE_SERVICE_ACCOUNT_EMAIL`: Google service account email. -- `GOOGLE_PRIVATE_KEY`: Google private key. -- `GOOGLE_SPREADSHEET_ID`: Google spreadsheet ID for data storage. -- `GOOGLE_EMAIL_SPREADSHEET_ID`: Google spreadsheet ID for emails. -- `GOOGLE_RATING_SPREADSHEET_ID`: Google spreadsheet ID for ratings. -- `RATE_LIMIT_MAX_REQUESTS`: Maximum number of requests per IP address (default: 100) -- `RATE_LIMIT_WINDOW_MINUTES`: Time window in minutes for the rate limit (default: 1) -- `RATE_LIMIT_NUMBER_OF_PROXIES`: Number of proxies between server and user (default: 1) - -## Testing. - -There are two test/scripts that can help with testing a flow of interest, by-passing some of the external services and -checks, and focusing on the phase executions alone. +```bash +# From the repository root +bun test:db:start +bun test:api -These are `phase-processor.integration.test.ts` and `phase-processor.recovery.integration.test.ts` +# From apps/api +bun test +bun test +``` -These tests will fetch a quote, and attempt to register and start a ramp by signing and sending the funds from a testing -account, which simulates the actions of the UI and the user. +The normal suite is hermetic. Tests that call live provider sandboxes or chains require +`RUN_LIVE_TESTS=1` and are never part of the default PR path. See +[`docs/operations-testing.md`](../../docs/operations-testing.md). -It is important to keep in mind that both BRLA subaccount and ramp interactions are mocked. Similarly, Stellar -interactions with anchors is skipped and an account is chosen as the anchor's target, to recover the funds. +## Architecture and contracts -To test, please run `bun test phase-processor.integration.test.ts --timeuout X` where X is a reasonable timeframe for -the phases to complete. Note: all the environment variables used to run the service MUST be provided, with the addition -of BACKEND_TEST_STARTER_ACCOUNT, the account simulates the user. +- [`CLAUDE.md`](CLAUDE.md) contains API-specific commands and contributor rules. +- [`src/api/services/phases/blocks/README.md`](src/api/services/phases/blocks/README.md) + explains the block-flow quote and execution architecture. +- [`docs/security-spec/`](../../docs/security-spec/README.md) is authoritative for + security-sensitive behavior and accepted risks. +- [`docs/api/`](../../docs/api/README.md) is the partner-facing API documentation source. +- [`docs/architecture-identity-model.md`](../../docs/architecture-identity-model.md) + explains the cross-module customer, provider, partner, and recipient model. -The state of the ramp is stored in `lastRampState.json`, which mocks the database. In the event of a failure, copy the -state into `failedRampStateRecovery.json` and run `bun test phase-processor.recovery.integration.test.ts --timeout X` to -simply restart the flow from the last phase. This is useful to test fixes or bugs. +Do not duplicate endpoint catalogs or security rules in this README; update the canonical +sources above. diff --git a/apps/api/package.json b/apps/api/package.json index f6789a717..d003a0b15 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -88,7 +88,10 @@ "license": "MIT", "name": "vortex-backend", "scripts": { + "backfill:api-key-digests": "bun scripts/backfill-api-key-digests.ts", "build": "bun run swc src -d dist --strip-leading-paths", + "credentials:migrate": "bun scripts/migrate-api-credentials.ts", + "credentials:preflight": "bun scripts/preflight-api-credential-migration.ts", "dev": "NODE_ENV=development bun --watch src/index.ts", "migrate": "bun -r @swc-node/register src/database/migrator.ts", "migrate:revert": "bun -r @swc-node/register src/database/migrator.ts revert-all", @@ -100,6 +103,7 @@ "test:coverage": "bun test --timeout 15000 --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", + "timeout:initial-ramps": "bun scripts/timeout-initial-ramps.ts", "typecheck": "tsc --noEmit" }, "version": "1.0.0" diff --git a/apps/api/scripts/api-credential-migration.ts b/apps/api/scripts/api-credential-migration.ts new file mode 100644 index 000000000..5ef2b1f80 --- /dev/null +++ b/apps/api/scripts/api-credential-migration.ts @@ -0,0 +1,179 @@ +import { readFile } from "node:fs/promises"; +import { Op, Transaction } from "sequelize"; +import sequelize from "../src/config/database"; +import ApiCredential, { type ApiCredentialEnvironment } from "../src/models/apiCredential.model"; +import ApiKey from "../src/models/apiKey.model"; +import Partner from "../src/models/partner.model"; +import User from "../src/models/user.model"; + +export interface ApiCredentialMigrationEntry { + publicKeyId: string; + secretKeyId: string; + profileId: string; + partnerId: string | null; + name: string; + expiresAt: string; +} + +interface ValidatedCredential { + entry: ApiCredentialMigrationEntry; + environment: ApiCredentialEnvironment; + expiresAt: Date; + publicKey: ApiKey; + publicKeyValue: string; + secretKey: ApiKey; + secretKeyDigest: string; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const PUBLIC_KEY_PATTERN = /^pk_(live|test)_[a-zA-Z0-9]{32}$/; +const SECRET_PREFIX_PATTERN = /^sk_(live|test)_[a-zA-Z0-9]{8}$/; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; + +function assertManifestEntry(value: unknown, index: number): asserts value is ApiCredentialMigrationEntry { + if (!value || typeof value !== "object") throw new Error(`Manifest entry ${index} must be an object`); + const entry = value as Record; + const exactKeys = ["expiresAt", "name", "partnerId", "profileId", "publicKeyId", "secretKeyId"]; + const keys = Object.keys(entry).sort(); + if (keys.length !== exactKeys.length || keys.some((key, keyIndex) => key !== exactKeys[keyIndex])) { + throw new Error(`Manifest entry ${index} must contain exactly: ${exactKeys.join(", ")}`); + } + if (!UUID_PATTERN.test(String(entry.publicKeyId)) || !UUID_PATTERN.test(String(entry.secretKeyId))) { + throw new Error(`Manifest entry ${index} has an invalid legacy key ID`); + } + if (!UUID_PATTERN.test(String(entry.profileId))) throw new Error(`Manifest entry ${index} has an invalid profileId`); + if (entry.partnerId !== null && !UUID_PATTERN.test(String(entry.partnerId))) { + throw new Error(`Manifest entry ${index} has an invalid partnerId`); + } + if (typeof entry.name !== "string" || !entry.name.trim() || entry.name.length > 100) { + throw new Error(`Manifest entry ${index} has an invalid name`); + } + if (typeof entry.expiresAt !== "string" || Number.isNaN(new Date(entry.expiresAt).getTime())) { + throw new Error(`Manifest entry ${index} has an invalid expiresAt`); + } +} + +export async function loadApiCredentialMigrationManifest(path: string): Promise { + const parsed: unknown = JSON.parse(await readFile(path, "utf8")); + if (!Array.isArray(parsed)) throw new Error("Credential migration manifest must be a JSON array"); + parsed.forEach(assertManifestEntry); + return parsed; +} + +function assertOwnership(row: ApiKey, entry: ApiCredentialMigrationEntry, label: string): void { + if (row.userId !== entry.profileId || row.partnerId !== entry.partnerId) { + throw new Error(`${label} row ${row.id} ownership does not match its manifest entry`); + } +} + +async function validateManifest( + manifest: ApiCredentialMigrationEntry[], + transaction?: Transaction +): Promise { + const mappedIds = manifest.flatMap(entry => [entry.publicKeyId, entry.secretKeyId]); + if (new Set(mappedIds).size !== mappedIds.length) throw new Error("Each legacy key ID may appear only once in the manifest"); + + const activeRows = await ApiKey.findAll({ + ...(transaction ? { lock: Transaction.LOCK.UPDATE, transaction } : {}), + where: { isActive: true } + }); + const activeById = new Map(activeRows.map(row => [row.id, row])); + const unmapped = activeRows.filter(row => !mappedIds.includes(row.id)); + if (unmapped.length > 0) { + throw new Error(`${unmapped.length} active legacy api_keys row(s) are not explicitly mapped or revoked`); + } + if (mappedIds.some(id => !activeById.has(id))) { + throw new Error("Every manifest key ID must reference an active legacy api_keys row"); + } + + const profileIds = [...new Set(manifest.map(entry => entry.profileId))]; + const partnerIds = [...new Set(manifest.map(entry => entry.partnerId).filter((id): id is string => id !== null))]; + const [profiles, partners] = await Promise.all([ + User.findAll({ attributes: ["id"], transaction, where: { id: { [Op.in]: profileIds } } }), + Partner.findAll({ attributes: ["id"], transaction, where: { id: { [Op.in]: partnerIds } } }) + ]); + if (profiles.length !== profileIds.length) throw new Error("A manifest profileId does not exist"); + if (partners.length !== partnerIds.length) throw new Error("A manifest partnerId does not exist"); + + const validated = manifest.map(entry => { + const publicKey = activeById.get(entry.publicKeyId); + const secretKey = activeById.get(entry.secretKeyId); + if (!publicKey || !secretKey) throw new Error("Every manifest key ID must reference an active legacy api_keys row"); + if (publicKey.keyType !== "public" || secretKey.keyType !== "secret") { + throw new Error(`Manifest pair ${entry.publicKeyId}/${entry.secretKeyId} has incorrect key types`); + } + assertOwnership(publicKey, entry, "Public key"); + assertOwnership(secretKey, entry, "Secret key"); + + const publicKeyValue = publicKey.keyValue; + const publicMatch = publicKeyValue?.match(PUBLIC_KEY_PATTERN); + const secretMatch = secretKey.keyPrefix.match(SECRET_PREFIX_PATTERN); + if (!publicKeyValue || !publicMatch || !secretMatch || publicMatch[1] !== secretMatch[1]) { + throw new Error(`Manifest pair ${entry.publicKeyId}/${entry.secretKeyId} has invalid or mismatched environments`); + } + const secretKeyDigest = secretKey.keyHash; + if (!secretKeyDigest || !DIGEST_PATTERN.test(secretKeyDigest)) { + throw new Error(`Secret key row ${entry.secretKeyId} does not contain a SHA-256 digest`); + } + + return { + entry, + environment: publicMatch[1] as ApiCredentialEnvironment, + expiresAt: new Date(entry.expiresAt), + publicKey, + publicKeyValue, + secretKey, + secretKeyDigest + }; + }); + + if (validated.length > 0) { + const existingTarget = await ApiCredential.count({ + transaction, + where: { + [Op.or]: [ + { publicKeyValue: { [Op.in]: validated.map(pair => pair.publicKeyValue) } }, + { secretKeyDigest: { [Op.in]: validated.map(pair => pair.secretKeyDigest) } } + ] + } + }); + if (existingTarget > 0) throw new Error("A manifest key is already present in api_credentials"); + } + + return validated; +} + +export async function preflightApiCredentialMigration(manifest: ApiCredentialMigrationEntry[]): Promise { + return (await validateManifest(manifest)).length; +} + +export async function migrateApiCredentials(manifest: ApiCredentialMigrationEntry[]): Promise { + return sequelize.transaction(async transaction => { + const validated = await validateManifest(manifest, transaction); + const revokedAt = new Date(); + + for (const pair of validated) { + await ApiCredential.create( + { + environment: pair.environment, + expiresAt: pair.expiresAt, + name: pair.entry.name, + partnerId: pair.entry.partnerId, + profileId: pair.entry.profileId, + publicKeyValue: pair.publicKeyValue, + publicLastUsedAt: pair.publicKey.lastUsedAt, + secretKeyDigest: pair.secretKeyDigest, + secretKeyPrefix: pair.secretKey.keyPrefix, + secretLastUsedAt: pair.secretKey.lastUsedAt + }, + { transaction } + ); + await ApiKey.update( + { isActive: false, revokedAt }, + { transaction, where: { id: { [Op.in]: [pair.entry.publicKeyId, pair.entry.secretKeyId] }, isActive: true } } + ); + } + + return validated.length; + }); +} diff --git a/apps/api/scripts/backfill-api-key-digests.ts b/apps/api/scripts/backfill-api-key-digests.ts new file mode 100644 index 000000000..be37c8a8a --- /dev/null +++ b/apps/api/scripts/backfill-api-key-digests.ts @@ -0,0 +1,163 @@ +/** + * One-off backfill: migrate legacy secret API keys onto the O(1) lookup format. + * + * Legacy rows store only the constant 8-char prefix (`sk_live_`) plus a bcrypt hash, so + * every failed lookup has to bcrypt-compare the whole legacy pool — an unauthenticated + * caller can trigger that with any random valid-format key (security spec `api-keys.md`, + * invariant 7). The 16-char lookup prefix cannot be derived from a bcrypt hash, so the + * only way to convert a row without changing the key is to present the original + * plaintext once. That is what this script does. + * + * Run it with the plaintext keys you issued; each is matched to its row by bcrypt, then + * rewritten to `keyPrefix` = first 16 chars and `keyHash` = SHA-256 digest. Keys are read + * from a file (one per line) or stdin, never from argv — argv lands in shell history and + * process listings. Nothing about a key is ever logged; only its 8-char public prefix and + * a match/miss verdict. + * + * Usage: + * bun scripts/backfill-api-key-digests.ts --file /secure/path/keys.txt [--ssl-ca-cert /secure/path/database-ca.crt] [--dry-run] + * cat keys.txt | bun scripts/backfill-api-key-digests.ts [--ssl-ca-cert /secure/path/database-ca.crt] [--dry-run] + * + * Safe to re-run: already-migrated rows are skipped. Run it in every environment BEFORE + * removing the legacy fallback from `validateSecretApiKey`, and confirm the remaining + * legacy count is zero with the query printed at the end. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import bcrypt from "bcrypt"; +import dotenv from "dotenv"; +import { Op } from "sequelize"; + +// This script is often invoked from outside apps/api, where Bun will not +// auto-load the API's .env file. Load it before importing database/config +// modules because vars.ts validates the environment during module evaluation. +dotenv.config({ path: path.resolve(import.meta.dir, "../.env") }); + +function readOption(name: string): string | undefined { + const optionIndex = process.argv.indexOf(name); + if (optionIndex === -1) return undefined; + + const value = process.argv[optionIndex + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +function readKeys(): string[] { + const keyFilePath = readOption("--file"); + const raw = keyFilePath ? readFileSync(keyFilePath, "utf8") : readFileSync(0, "utf8"); + + return raw + .split("\n") + .map(line => line.trim()) + .filter(line => line.length > 0 && !line.startsWith("#")); +} + +async function main(): Promise { + const sslCaCertPath = readOption("--ssl-ca-cert"); + if (sslCaCertPath) { + // Set this before importing database.ts: Sequelize reads its TLS options + // while that module is evaluated. A CLI value intentionally overrides .env. + process.env.DB_SSL_CA_CERT_PATH = path.resolve(sslCaCertPath); + } + + const [ + { digestApiKey, getKeyPrefix, getSecretKeyLookupPrefix, isValidSecretKeyFormat }, + { default: sequelize }, + { default: ApiKey } + ] = await Promise.all([ + import("../src/api/middlewares/apiKeyAuth.helpers"), + import("../src/config/database"), + import("../src/models/apiKey.model") + ]); + + try { + const dryRun = process.argv.includes("--dry-run"); + const keys = readKeys(); + + if (keys.length === 0) { + throw new Error("No keys provided. Pass --file or pipe keys on stdin (one per line)."); + } + + const malformed = keys.filter(key => !isValidSecretKeyFormat(key)); + if (malformed.length > 0) { + throw new Error(`${malformed.length} input line(s) are not valid secret-key format; aborting without changes.`); + } + + // Only rows still on the legacy format can be migrated: their prefix is the 8-char + // constant. Already-migrated rows carry the 16-char prefix and are left alone. + const legacyRows = await ApiKey.findAll({ + where: { + keyType: "secret", + [Op.and]: sequelize.where(sequelize.fn("length", sequelize.col("key_prefix")), 8) + } + }); + + console.log(`Legacy secret-key rows found: ${legacyRows.length}`); + console.log(`Plaintext keys supplied: ${keys.length}`); + + const matchedRowIds = new Set(); + let migrated = 0; + + for (const key of keys) { + const publicPrefix = getKeyPrefix(key); + const candidates = legacyRows.filter(row => row.keyPrefix === publicPrefix && row.keyHash); + + let matched = false; + for (const row of candidates) { + if (matchedRowIds.has(row.id)) continue; + // Legacy rows hold bcrypt hashes; this is the one place we still pay that cost. + if (!(await bcrypt.compare(key, row.keyHash as string))) continue; + + matched = true; + matchedRowIds.add(row.id); + if (!dryRun) { + await row.update({ keyHash: digestApiKey(key), keyPrefix: getSecretKeyLookupPrefix(key) }); + } + migrated++; + console.log(` ${publicPrefix}… → migrated (row ${row.id}, active=${row.isActive})`); + break; + } + + if (!matched) { + console.log(` ${publicPrefix}… → NO MATCHING ROW (already migrated, revoked, or wrong environment)`); + } + } + + const unmatchedRows = legacyRows.filter(row => !matchedRowIds.has(row.id)); + + console.log(`\n${dryRun ? "[dry run] would migrate" : "Migrated"}: ${migrated}`); + console.log(`Legacy rows left unmigrated: ${unmatchedRows.length}`); + for (const row of unmatchedRows) { + console.log(` row ${row.id} (active=${row.isActive}, name=${row.name ?? "-"}) — no plaintext supplied`); + } + + if (unmatchedRows.some(row => row.isActive)) { + console.log( + "\n⚠️ Active legacy rows remain. The legacy bcrypt fallback must stay in place until\n" + + " they are migrated or revoked — removing it would break those keys." + ); + } else { + console.log("\n✅ No active legacy rows remain. The legacy fallback in validateSecretApiKey can be removed."); + } + + console.log( + "\nVerify independently with:\n SELECT count(*) FROM api_keys WHERE key_type='secret' AND is_active AND length(key_prefix)=8;" + ); + } finally { + await sequelize.close(); + } +} + +main().catch(error => { + const message = error instanceof Error ? error.message : String(error); + console.error("Backfill failed:", message); + if (message.includes("ESSLREQUIRED") || message.includes("SSL connection is required")) { + console.error( + "The database requires TLS. Set DB_SSL_CA_CERT_PATH in apps/api/.env or pass " + + "--ssl-ca-cert /path/to/database-ca.crt. If its CA is already trusted by your system, set DB_SSL_REQUIRED=true." + ); + } + process.exitCode = 1; +}); diff --git a/apps/api/scripts/migrate-api-credentials.ts b/apps/api/scripts/migrate-api-credentials.ts new file mode 100644 index 000000000..128c8614b --- /dev/null +++ b/apps/api/scripts/migrate-api-credentials.ts @@ -0,0 +1,20 @@ +import sequelize from "../src/config/database"; +import { loadApiCredentialMigrationManifest, migrateApiCredentials } from "./api-credential-migration"; + +function manifestPath(): string { + const flag = process.argv.indexOf("--manifest"); + const path = flag >= 0 ? process.argv[flag + 1] : undefined; + if (!path) throw new Error("Usage: bun credentials:migrate --manifest "); + return path; +} + +try { + await sequelize.authenticate(); + const count = await migrateApiCredentials(await loadApiCredentialMigrationManifest(manifestPath())); + console.log(`Migrated ${count} credential pair(s); corresponding legacy rows were revoked.`); +} catch (error) { + console.error(error instanceof Error ? error.message : "Credential migration failed"); + process.exitCode = 1; +} finally { + await sequelize.close(); +} diff --git a/apps/api/scripts/preflight-api-credential-migration.ts b/apps/api/scripts/preflight-api-credential-migration.ts new file mode 100644 index 000000000..0d6d633ff --- /dev/null +++ b/apps/api/scripts/preflight-api-credential-migration.ts @@ -0,0 +1,20 @@ +import sequelize from "../src/config/database"; +import { loadApiCredentialMigrationManifest, preflightApiCredentialMigration } from "./api-credential-migration"; + +function manifestPath(): string { + const flag = process.argv.indexOf("--manifest"); + const path = flag >= 0 ? process.argv[flag + 1] : undefined; + if (!path) throw new Error("Usage: bun credentials:preflight --manifest "); + return path; +} + +try { + await sequelize.authenticate(); + const count = await preflightApiCredentialMigration(await loadApiCredentialMigrationManifest(manifestPath())); + console.log(`Credential migration preflight passed for ${count} credential pair(s). No database rows were changed.`); +} catch (error) { + console.error(error instanceof Error ? error.message : "Credential migration preflight failed"); + process.exitCode = 1; +} finally { + await sequelize.close(); +} diff --git a/apps/api/scripts/schema-parity-checks.sql b/apps/api/scripts/schema-parity-checks.sql index d8b0208f0..bbd8ecd3a 100644 --- a/apps/api/scripts/schema-parity-checks.sql +++ b/apps/api/scripts/schema-parity-checks.sql @@ -1,4 +1,4 @@ --- Parity checks for the 038-049 schema migration (run after each deploy of the 038-049 set; see docs/runbooks/dashboard-schema-production-rollout.md). +-- Parity checks for the 038-049 schema migration (run after each deploy of the 038-049 set; see docs/architecture-identity-model.md). -- Read-only. Mirrors the backfill rules of migrations 038/039/040 exactly, so: -- * PARITY checks must return 0 — any non-zero row count is a real backfill gap. -- * INFO checks are expected to be non-zero; they size the deliberately-skipped buckets. diff --git a/apps/api/scripts/timeout-initial-ramps.ts b/apps/api/scripts/timeout-initial-ramps.ts new file mode 100644 index 000000000..e286260ba --- /dev/null +++ b/apps/api/scripts/timeout-initial-ramps.ts @@ -0,0 +1,77 @@ +/** + * Local-development cleanup for ramps left in the initial phase by older + * application versions. + * + * Preview: + * bun run timeout:initial-ramps + * + * Apply: + * bun run timeout:initial-ramps --execute + * + * The write is intentionally restricted to development/test runtimes using a + * loopback database host. It updates only current_phase (plus updated_at via + * Sequelize) and leaves quote, history, and financial-operation data intact. + */ +import path from "node:path"; +import dotenv from "dotenv"; + +dotenv.config({ path: path.resolve(import.meta.dir, "../.env") }); + +const execute = process.argv.includes("--execute"); +const unknownArguments = process.argv.slice(2).filter(argument => argument !== "--execute"); +if (unknownArguments.length > 0) { + throw new Error(`Unknown argument(s): ${unknownArguments.join(", ")}`); +} + +const nodeEnv = process.env.NODE_ENV ?? "production"; +const databaseHost = process.env.DB_HOST ?? "localhost"; +const localDatabaseHosts = new Set(["127.0.0.1", "::1", "localhost"]); + +if (!["development", "test"].includes(nodeEnv) || !localDatabaseHosts.has(databaseHost)) { + throw new Error( + `Refusing to modify a non-local database (NODE_ENV=${nodeEnv}, DB_HOST=${databaseHost}). ` + + "This cleanup is restricted to development/test with a loopback database host." + ); +} + +async function main(): Promise { + const [{ default: sequelize }, { default: RampState }] = await Promise.all([ + import("../src/config/database"), + import("../src/models/rampState.model") + ]); + + try { + const candidates = await RampState.findAll({ + attributes: ["createdAt", "id", "quoteId"], + order: [["createdAt", "ASC"]], + where: { currentPhase: "initial" } + }); + + console.log(`Ramps currently in initial: ${candidates.length}`); + if (candidates.length === 0) return; + + console.log(`Oldest: ${candidates[0].createdAt.toISOString()} (${candidates[0].id})`); + console.log(`Newest: ${candidates.at(-1)?.createdAt.toISOString()} (${candidates.at(-1)?.id})`); + + if (!execute) { + console.log("\nPreview only; no rows changed. Re-run with --execute to set all of them to timedOut."); + return; + } + + const [updated] = await RampState.update( + { currentPhase: "timedOut" }, + { + where: { currentPhase: "initial" } + } + ); + + console.log(`\nUpdated ${updated} ramp(s) from initial to timedOut.`); + } finally { + await sequelize.close(); + } +} + +main().catch(error => { + console.error("Failed to time out initial ramps:", error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/apps/api/src/api/controllers/admin/managedProfiles.controller.ts b/apps/api/src/api/controllers/admin/managedProfiles.controller.ts new file mode 100644 index 000000000..06a5a723a --- /dev/null +++ b/apps/api/src/api/controllers/admin/managedProfiles.controller.ts @@ -0,0 +1,55 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../../config/logger"; +import { MANAGED_PROFILE_SUBJECT_TYPES, type ManagedProfileSubjectType } from "../../../models/partnerManagedProfile.model"; +import { createManagedProfile, ManagedProfileServiceError } from "../../services/managed-profile.service"; + +function isSubjectType(value: unknown): value is ManagedProfileSubjectType { + return typeof value === "string" && (MANAGED_PROFILE_SUBJECT_TYPES as readonly string[]).includes(value); +} + +export async function postManagedProfile(req: Request, res: Response): Promise { + const { email, externalUserId, partnerId, subjectType } = req.body ?? {}; + if ( + typeof email !== "string" || + typeof externalUserId !== "string" || + typeof partnerId !== "string" || + !isSubjectType(subjectType) + ) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MANAGED_PROFILE_INVALID_INPUT", + message: `email, externalUserId, partnerId and subjectType (${MANAGED_PROFILE_SUBJECT_TYPES.join("|")}) are required`, + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + try { + const managedProfile = await createManagedProfile({ email, externalUserId, partnerId, subjectType }); + res.status(managedProfile.created ? httpStatus.CREATED : httpStatus.OK).json({ managedProfile }); + } catch (error) { + if (error instanceof ManagedProfileServiceError) { + const status = + error.code === "MANAGED_PROFILE_INVALID_INPUT" + ? httpStatus.BAD_REQUEST + : error.code === "MANAGED_PROFILE_PARTNER_NOT_FOUND" + ? httpStatus.NOT_FOUND + : error.code === "MANAGED_PROFILE_CONFLICT" + ? httpStatus.CONFLICT + : httpStatus.BAD_GATEWAY; + res.status(status).json({ error: { code: error.code, message: error.message, status } }); + return; + } + + logger.error("Error creating managed profile", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create managed profile", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts index e7d2d09fe..fc3d6f2f7 100644 --- a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts @@ -2,269 +2,86 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; 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"; - -/** - * Create a new API key pair (public + secret) for a partner - * POST /v1/admin/partners/:partnerName/api-keys - */ -export async function createApiKey(req: Request<{ partnerName: string }>, res: Response): Promise { - try { - const partnerName = req.params.partnerName; - const { name, expiresAt, userId } = req.body; - - // Resolve the (unique-name) partner; keys bind to it by FK - const partner = await Partner.findOne({ - where: { - isActive: true, - name: partnerName - } - }); - - if (!partner) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "PARTNER_NOT_FOUND", - message: `No active partners found with name: ${partnerName}`, - status: httpStatus.NOT_FOUND - } - }); - 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"; - - // Generate public key (pk_live_* or pk_test_*) - const publicKey = generateApiKey("public", environment); - const publicKeyPrefix = getKeyPrefix(publicKey); - - // Generate secret key (sk_live_* or sk_test_*) - const secretKey = generateApiKey("secret", environment); - const secretKeyHash = await hashApiKey(secretKey); - const secretKeyPrefix = getKeyPrefix(secretKey); - - const expirationDate = expiresAt ? new Date(expiresAt) : null; - - // Create public key record (partner_name kept as informational backup; auth resolves partner_id) - const publicKeyRecord = await ApiKey.create({ - expiresAt: expirationDate, - isActive: true, - keyHash: null, // Store plaintext for public keys - keyPrefix: publicKeyPrefix, - keyType: "public", - keyValue: publicKey, - name: name ? `${name} (Public)` : "Public Key", - partnerId: partner.id, - partnerName, - userId: resolvedUserId +import { + ApiCredentialServiceError, + createCredential, + listCredentials, + revokeCredential +} from "../../services/apiCredential.service"; + +async function resolveSubject(req: Request<{ partnerName: string }>, res: Response) { + const partner = await Partner.findOne({ where: { name: req.params.partnerName } }); + if (!partner) { + res.status(404).json({ error: { code: "PARTNER_NOT_FOUND", message: "Partner was not found", status: 404 } }); + return null; + } + const userId = req.body?.userId ?? req.query.userId; + if (typeof userId !== "string" || !userId) { + res.status(400).json({ + error: { code: "CREDENTIAL_SUBJECT_REQUIRED", message: "userId profile subject is required", status: 400 } }); + return null; + } + if (!(await User.findByPk(userId, { attributes: ["id"] }))) { + res.status(404).json({ error: { code: "CREDENTIAL_SUBJECT_REQUIRED", message: "Profile was not found", status: 404 } }); + return null; + } + return { partner, profileId: userId }; +} - // Create secret key record - const secretKeyRecord = await ApiKey.create({ - expiresAt: expirationDate, - isActive: true, - keyHash: secretKeyHash, // Don't store plaintext for secret keys - keyPrefix: secretKeyPrefix, - keyType: "secret", - keyValue: null, - name: name ? `${name} (Secret)` : "Secret Key", - partnerId: partner.id, - partnerName, - userId: resolvedUserId - }); +function sendError(res: Response, error: unknown): boolean { + if (!(error instanceof ApiCredentialServiceError)) return false; + const status = error.code === "CREDENTIAL_LIMIT_REACHED" ? 409 : error.code === "CREDENTIAL_NOT_FOUND" ? 404 : 400; + res.status(status).json({ error: { code: error.code, message: error.message, status } }); + return true; +} - // Return both keys (secret shown only once!) - res.status(httpStatus.CREATED).json({ - createdAt: publicKeyRecord.createdAt, - expiresAt: expirationDate, - isActive: true, - partnerId: partner.id, - partnerName, - publicKey: { - id: publicKeyRecord.id, - key: publicKey, // Can be shown anytime (it's public) - keyPrefix: publicKeyRecord.keyPrefix, - name: publicKeyRecord.name, - type: "public", - userId: publicKeyRecord.userId - }, - secretKey: { - id: secretKeyRecord.id, - key: secretKey, // Shown only once! - keyPrefix: secretKeyRecord.keyPrefix, - name: secretKeyRecord.name, - type: "secret", - userId: secretKeyRecord.userId - }, - userId: resolvedUserId +export async function createApiKey(req: Request<{ partnerName: string }>, res: Response): Promise { + try { + const subject = await resolveSubject(req, res); + if (!subject) return; + const credential = await createCredential({ + environment: config.sandboxEnabled ? "test" : "live", + expiresAt: req.body?.expiresAt, + name: req.body?.name, + partnerId: subject.partner.id, + profileId: subject.profileId }); + res.status(httpStatus.CREATED).json(credential); } catch (error) { - logger.error("Error creating 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 - } - }); + if (sendError(res, error)) return; + logger.error("Error creating partner API credential", error); + res.status(500).json({ error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to create API credential", status: 500 } }); } } -/** - * List all API keys for a partner (by name) - * GET /v1/admin/partners/:partnerName/api-keys - */ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Response): Promise { try { - const partnerName = req.params.partnerName; - - // Verify partner exists - const partner = await Partner.findOne({ - where: { name: partnerName } - }); - - if (!partner) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "PARTNER_NOT_FOUND", - message: `No partners found with name: ${partnerName}`, - status: httpStatus.NOT_FOUND - } - }); - return; - } - - // Get all API keys for this partner name - const apiKeys = await ApiKey.findAll({ - attributes: [ - "id", - "keyType", - "keyPrefix", - "keyValue", // Include for public keys - "name", - "lastUsedAt", - "expiresAt", - "isActive", - "userId", - "createdAt", - "updatedAt" - ], - order: [["createdAt", "DESC"]], - where: { partnerId: partner.id } - }); - - res.status(httpStatus.OK).json({ - apiKeys: apiKeys.map(key => ({ - createdAt: key.createdAt, - expiresAt: key.expiresAt, - id: key.id, - isActive: key.isActive, // Show full public key - key: key.keyType === "public" ? key.keyValue : undefined, - keyPrefix: key.keyPrefix, - lastUsedAt: key.lastUsedAt, - name: key.name, - type: key.keyType, - updatedAt: key.updatedAt, - userId: key.userId - })), - partnerId: partner.id, - partnerName + const subject = await resolveSubject(req, res); + if (!subject) return; + res.status(200).json({ + credentials: await listCredentials({ partnerId: subject.partner.id, profileId: subject.profileId }), + partnerId: subject.partner.id, + partnerName: subject.partner.name, + profileId: subject.profileId }); } catch (error) { - logger.error("Error listing 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 - } - }); + logger.error("Error listing partner API credentials", error); + res.status(500).json({ error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to list API credentials", status: 500 } }); } } -/** - * Revoke (soft delete) an API key - * DELETE /v1/admin/partners/:partnerName/api-keys/:keyId - */ -export async function revokeApiKey(req: Request<{ partnerName: string; keyId: string }>, res: Response): Promise { +export async function revokeApiKey(req: Request<{ credentialId: string; partnerName: string }>, res: Response): Promise { try { - const { partnerName, keyId } = req.params; - - const partner = await Partner.findOne({ where: { name: partnerName } }); - if (!partner) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "PARTNER_NOT_FOUND", - message: `No partners found with name: ${partnerName}`, - status: httpStatus.NOT_FOUND - } - }); - return; - } - - // Find the API key - const apiKey = await ApiKey.findOne({ - where: { - id: keyId, - partnerId: partner.id - } - }); - - if (!apiKey) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "API_KEY_NOT_FOUND", - message: "API key not found", - status: httpStatus.NOT_FOUND - } - }); - return; - } - - // Soft delete by setting isActive to false - await apiKey.update({ isActive: false, revokedAt: new Date() }); - + const subject = await resolveSubject(req, res); + if (!subject) return; + await revokeCredential(req.params.credentialId, { partnerId: subject.partner.id, profileId: subject.profileId }); res.status(httpStatus.NO_CONTENT).send(); } catch (error) { - logger.error("Error revoking 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 - } - }); + if (sendError(res, error)) return; + logger.error("Error revoking partner API credential", error); + res.status(500).json({ error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to revoke API credential", status: 500 } }); } } diff --git a/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.test.ts b/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.test.ts index 2af632940..80ae0183a 100644 --- a/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.test.ts +++ b/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.test.ts @@ -100,12 +100,14 @@ describe("partner pricing configs admin routes", () => { expect(withCurrency.status).toBe(201); }); - it("rejects a negative maxSubsidy, which the discount engine would read as uncapped", async () => { + it("rejects maxSubsidy outside the disabled-or-fractional range", async () => { await createTestPartner({ name: "acme", rampType: RampDirection.BUY }); - const response = await post({ maxSubsidy: -0.01, partnerName: "acme", rampType: "SELL" }); - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { message: string } }; - expect(body.error.message).toContain("maxSubsidy"); + for (const maxSubsidy of [-0.01, 1.01]) { + const response = await post({ maxSubsidy, partnerName: "acme", rampType: "SELL" }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { message: string } }; + expect(body.error.message).toContain("maxSubsidy"); + } }); it("inherits vortex payout addresses onto a scoped config, and refuses when none are inheritable", async () => { diff --git a/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.ts b/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.ts index 9edd796ce..63eb1a4f0 100644 --- a/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.ts @@ -83,10 +83,8 @@ export async function createPartnerPricingConfig(req: Request, res: Response): P return; } } - // The discount engine only applies the cap when maxSubsidy > 0, so a negative value - // would silently mean "uncapped", not "invalid" — reject it here. - if (body.maxSubsidy !== undefined && body.maxSubsidy < 0) { - invalidInput(res, "maxSubsidy must be non-negative"); + if (body.maxSubsidy !== undefined && (body.maxSubsidy < 0 || body.maxSubsidy > 1)) { + invalidInput(res, "maxSubsidy must be between 0 (disabled) and 1"); return; } diff --git a/apps/api/src/api/controllers/auth.controller.ts b/apps/api/src/api/controllers/auth.controller.ts index 280a9069a..d55818936 100644 --- a/apps/api/src/api/controllers/auth.controller.ts +++ b/apps/api/src/api/controllers/auth.controller.ts @@ -3,6 +3,7 @@ import logger from "../../config/logger"; import User from "../../models/user.model"; import { RefreshTokenError, SupabaseAuthService } from "../services/auth"; import { getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; +import { markManagedProfileClaimed, normalizeManagedProfileEmail } from "../services/managed-profile.service"; export class AuthController { /** @@ -85,15 +86,17 @@ export class AuthController { // Sync user to local database (upsert) await User.upsert({ - email: email, + email: normalizeManagedProfileEmail(email), id: result.user_id }); + const managedSubjectType = await markManagedProfileClaimed(result.user_id); + // Eagerly create the owning customer entity. Kept out of the OTP error mapping: // the Supabase session is already minted, so a failure here must not surface as // "Invalid OTP" — entity-scoped reads lazily create it as a fallback anyway. try { - await getOrCreateCustomerEntityForProfile(result.user_id); + if (managedSubjectType !== "technical") await getOrCreateCustomerEntityForProfile(result.user_id); } catch (entityError) { logger.error("Failed to create customer entity for new profile:", entityError); } diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 8a0f99709..5b6a109b9 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -1,9 +1,10 @@ -import {AveniaAccountType, BrlaApiError, BrlaApiService, KycAttemptResult, KycAttemptStatus} from "@vortexfi/shared"; +import {AveniaAccountType, AveniaDocumentType, BrlaApiError, BrlaApiService, KycAttemptResult, KycAttemptStatus} from "@vortexfi/shared"; import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; import httpStatus from "http-status"; import logger from "../../config/logger"; import CustomerEntity from "../../models/customerEntity.model"; import KycCase from "../../models/kycCase.model"; +import PartnerManagedProfile from "../../models/partnerManagedProfile.model"; import ProviderCustomer, {VerificationStatus} from "../../models/providerCustomer.model"; import TaxId, {TaxIdInternalStatus} from "../../models/taxId.model"; import User from "../../models/user.model"; @@ -12,6 +13,7 @@ import { fetchSubaccountKycStatus, getAveniaUser, getKybAttemptStatus, + getUploadUrls, initiateKybLevel1, recordInitialKycAttempt } from "./brla.controller"; @@ -35,6 +37,7 @@ function createResponse() { // getOrCreateCustomerEntityForProfile resolves each profile to a deterministic entity id. // Type-less lookups resolve via findOne (oldest-entity default); typed ones via findOrCreate. +// Profile-ownership checks enumerate the profile's entities via findAll. function mockEntityPerProfile() { CustomerEntity.findOne = mock(async (options: { where: { profileId: string } }) => ({ id: `entity-${options.where.profileId}` @@ -43,16 +46,24 @@ function mockEntityPerProfile() { { id: `entity-${options.where.profileId}` }, false ]) as unknown as typeof CustomerEntity.findOrCreate; + CustomerEntity.findAll = mock(async (options: { where: { profileId: string } }) => [ + { id: `entity-${options.where.profileId}` } + ]) as unknown as typeof CustomerEntity.findAll; } const originalUserFindByPk = User.findByPk; +const originalManagedProfileFindOne = PartnerManagedProfile.findOne; +const originalEntityFindAll = CustomerEntity.findAll; beforeEach(() => { + PartnerManagedProfile.findOne = mock(async () => null) as unknown as typeof PartnerManagedProfile.findOne; User.findByPk = mock(async () => null) as unknown as typeof User.findByPk; }); afterEach(() => { + PartnerManagedProfile.findOne = originalManagedProfileFindOne; User.findByPk = originalUserFindByPk; + CustomerEntity.findAll = originalEntityFindAll; }); describe("getAveniaUser", () => { @@ -139,8 +150,15 @@ describe("getAveniaUser", () => { const res = createResponse(); await getAveniaUser( { - apiKeyUserId: "user-1", + apiKeyUserId: "stale-user", authenticatedPartner: { id: "partner-1", name: "Partner" }, + credential: { + credentialId: "credential-1", + environment: "test", + partnerId: "partner-1", + profileId: "user-1", + strength: "secret" + }, query: { taxId: "08786985906" } } as any, res as any @@ -580,6 +598,33 @@ describe("Avenia company KYB", () => { expect(providerStatus).not.toHaveBeenCalled(); }); + // Migration 040 attached business rows to the profile's (038-backfilled) individual entity. + // Comparing ownership against the typed business entity 403'd the legitimate owner and + // findOrCreate'd an empty business entity as a side effect of the read. + it("resolves a KYB attempt whose rows live on the profile's legacy individual entity", async () => { + CustomerEntity.findAll = mock(async () => [ + { id: "entity-user-1-individual" } + ]) as unknown as typeof CustomerEntity.findAll; + const strayCreate = mock(async () => [{ id: "entity-user-1-business" }, true]); + CustomerEntity.findOrCreate = strayCreate as unknown as typeof CustomerEntity.findOrCreate; + KycCase.findOne = mock(async () => ({ + customerEntityId: "entity-user-1-individual", + providerCustomerId: "customer-1" + })) as unknown as typeof KycCase.findOne; + ProviderCustomer.findByPk = mock(async () => ({ + customerEntityId: "entity-user-1-individual", + provider: "avenia", + status: VerificationStatus.Approved + })) as unknown as typeof ProviderCustomer.findByPk; + + const res = createResponse(); + await getKybAttemptStatus({ query: { attemptId: "attempt-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(res.body).toEqual({ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }); + expect(strayCreate).not.toHaveBeenCalled(); + }); + it("persists an approved provider result and returns only normalized browser fields", async () => { mockEntityPerProfile(); const caseUpdate = mock(async () => undefined); @@ -700,6 +745,39 @@ describe("createSubaccount", () => { expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); }); + // Migration 040 attached business rows to the profile's individual entity; the conflict + // check compared against the typed business entity and 409'd the owner's own retry. + it("does not 409 the owner's retry when the business row sits on the legacy individual entity", async () => { + mockBrlaApi(); + createAveniaSubaccountMock.mockClear(); + CustomerEntity.findAll = mock(async () => [ + { id: "entity-user-1-individual" } + ]) as unknown as typeof CustomerEntity.findAll; + const strayCreate = mock(async () => [{ id: "entity-user-1-business" }, true]); + CustomerEntity.findOrCreate = strayCreate as unknown as typeof CustomerEntity.findOrCreate; + const existingUpdate = mock(async () => undefined); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1-individual", + status: VerificationStatus.Pending, + update: existingUpdate + })) as unknown as typeof ProviderCustomer.findOne; + + const res = createResponse(); + await createSubaccount( + { + body: { accountType: AveniaAccountType.COMPANY, name: "Legacy Co", taxId: "11222333000181" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(res.body).toEqual({ subAccountId: "new-subaccount" }); + expect(existingUpdate).toHaveBeenCalledWith(expect.objectContaining({ providerSubaccountId: "new-subaccount" })); + // The retry updates the existing row in place — typed-entity creation must not run. + expect(strayCreate).not.toHaveBeenCalled(); + }); + it("rejects when a quarantined legacy record belongs to a different user", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); @@ -857,3 +935,76 @@ describe("createSubaccount", () => { expect(updateMock).not.toHaveBeenCalled(); }); }); + +describe("getUploadUrls", () => { + const originalProviderFindOne = ProviderCustomer.findOne; + const originalEntityFindOrCreate = CustomerEntity.findOrCreate; + const originalGetInstance = BrlaApiService.getInstance; + const originalLoggerError = logger.error; + + beforeEach(() => { + logger.error = mock(() => logger) as typeof logger.error; + }); + + afterEach(() => { + ProviderCustomer.findOne = originalProviderFindOne; + CustomerEntity.findOrCreate = originalEntityFindOrCreate; + BrlaApiService.getInstance = originalGetInstance; + logger.error = originalLoggerError; + }); + + const uploadUrlsMock = mock(async () => ({ id: "doc-1", uploadURLBack: "back-url", uploadURLFront: "front-url" })); + + function mockBrlaApi() { + BrlaApiService.getInstance = mock( + () => ({ getDocumentUploadUrls: uploadUrlsMock }) as unknown as BrlaApiService + ); + } + + // Migration 040 attached business rows to the profile's individual entity; the ownership + // check compared against the typed business entity and 403'd the legitimate owner. + it("serves upload URLs for a business row on the legacy individual entity without creating entities", async () => { + mockBrlaApi(); + uploadUrlsMock.mockClear(); + CustomerEntity.findAll = mock(async () => [ + { id: "entity-user-1-individual" } + ]) as unknown as typeof CustomerEntity.findAll; + const strayCreate = mock(async () => [{ id: "entity-user-1-business" }, true]); + CustomerEntity.findOrCreate = strayCreate as unknown as typeof CustomerEntity.findOrCreate; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1-individual", + providerSubaccountId: "subaccount-1" + })) as unknown as typeof ProviderCustomer.findOne; + + const res = createResponse(); + await getUploadUrls( + { body: { documentType: AveniaDocumentType.ID, taxId: "11222333000181" }, userId: "user-1" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(uploadUrlsMock).toHaveBeenCalledTimes(2); + expect(strayCreate).not.toHaveBeenCalled(); + }); + + it("rejects a tax id owned by another profile", async () => { + mockBrlaApi(); + uploadUrlsMock.mockClear(); + CustomerEntity.findAll = mock(async () => [ + { id: "entity-attacker" } + ]) as unknown as typeof CustomerEntity.findAll; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-victim", + providerSubaccountId: "subaccount-1" + })) as unknown as typeof ProviderCustomer.findOne; + + const res = createResponse(); + await getUploadUrls( + { body: { documentType: AveniaDocumentType.ID, taxId: "11222333000181" }, userId: "attacker" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.FORBIDDEN); + expect(uploadUrlsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 118cbbfb9..3dccc3e9c 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -50,7 +50,7 @@ import { upsertAveniaKycCase } from "../services/avenia/avenia-customer.service"; import { resolveAveniaAccountForUser } from "../services/avenia-account"; -import { getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; +import { findCustomerEntityIdsForProfile, getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; // map from subaccountId → last interaction timestamp. Used for fetching the last relevant kyc event. const _lastInteractionMap = new Map(); @@ -366,12 +366,13 @@ export const createSubaccount = async ( // Use the accountType from the request if provided, otherwise determine from taxId const accountType = requestAccountType || (isCnpj ? AveniaAccountType.COMPANY : AveniaAccountType.INDIVIDUAL); - const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId, accountTypeToCustomerType(accountType)); - // Ownership check BEFORE calling the BRLA API to avoid creating a stranded subaccount // on every conflict and to prevent account-takeover via subAccountId overwrite. + // Ownership is profile-level, not typed-entity-level: migration 040 left business rows + // on the profile's individual entity, and comparing against the typed entity 409'd the + // legitimate owner's own retry. let existing = await findAveniaCustomerByTaxId(normalizedTaxId); - if (existing && existing.customerEntityId !== entity.id) { + if (existing && !(await findCustomerEntityIdsForProfile(effectiveUserId)).includes(existing.customerEntityId)) { res.status(httpStatus.CONFLICT).json({ error: "A subaccount already exists for this taxId" }); @@ -391,6 +392,9 @@ export const createSubaccount = async ( }); return; } + // Typed-entity resolution is deferred to the row-creating branches so a retry that + // only updates an existing row cannot create a stray typed entity. + const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId, accountTypeToCustomerType(accountType)); existing = await ProviderCustomer.create({ country: "BR", customerEntityId: entity.id, @@ -433,6 +437,7 @@ export const createSubaccount = async ( } else { // The entry should have been created the very first a new cpf/cnpj is consulted. // We leave this as is for now to avoid breaking changes. + const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId, accountTypeToCustomerType(accountType)); existing = await ProviderCustomer.create({ companyName, country: "BR", @@ -668,8 +673,11 @@ export const getUploadUrls = async ( res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } - const entity = await getOrCreateCustomerEntityForProfile(req.userId, "business"); - if (record.customerEntityId !== entity.id) { + // Profile-level ownership: legacy business rows live on the profile's individual + // entity, so the owning entity's type cannot gate access — and a read path must not + // findOrCreate an entity as a side effect. + const ownedEntityIds = await findCustomerEntityIdsForProfile(req.userId); + if (!ownedEntityIds.includes(record.customerEntityId)) { res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } @@ -876,14 +884,17 @@ export const getKybAttemptStatus = async ( return; } - const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId, "business"); - if (kycCase.customerEntityId !== entity.id) { + // Profile-level ownership: legacy business rows live on the profile's individual + // entity, so the owning entity's type cannot gate access — and a read path must not + // findOrCreate an entity as a side effect. + const ownedEntityIds = await findCustomerEntityIdsForProfile(effectiveUserId); + if (!ownedEntityIds.includes(kycCase.customerEntityId)) { res.status(httpStatus.FORBIDDEN).json({ error: "This KYB attempt is not linked to your user profile." }); return; } const record = kycCase.providerCustomerId ? await ProviderCustomer.findByPk(kycCase.providerCustomerId) : null; - if (!record || record.customerEntityId !== entity.id || record.provider !== "avenia") { + if (!record || !ownedEntityIds.includes(record.customerEntityId) || record.provider !== "avenia") { res.status(httpStatus.NOT_FOUND).json({ error: "KYB account not found" }); return; } diff --git a/apps/api/src/api/controllers/limits.controller.test.ts b/apps/api/src/api/controllers/limits.controller.test.ts new file mode 100644 index 000000000..f77407c0b --- /dev/null +++ b/apps/api/src/api/controllers/limits.controller.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, mock } from "bun:test"; +import { Request, Response } from "express"; +import { getLimits } from "./limits.controller"; + +function responseDouble() { + const response = { + body: undefined as unknown, + statusCode: 200, + json: mock((body: unknown) => { + response.body = body; + return response; + }), + status: mock((statusCode: number) => { + response.statusCode = statusCode; + return response; + }) + }; + return response; +} + +describe("getLimits", () => { + it("rejects a valid but unlinked credential", async () => { + const response = responseDouble(); + + await getLimits( + { body: { corridors: ["US"] } } as Request, + response as unknown as Response, + mock(() => undefined) + ); + + expect(response.statusCode).toBe(403); + expect(response.body).toEqual({ error: "A user-scoped credential is required" }); + }); + + it("does not accept the legacy API key user field as identity", async () => { + const response = responseDouble(); + + await getLimits( + { apiKeyUserId: "legacy-user", body: { corridors: ["US"] } } as unknown as Request, + response as unknown as Response, + mock(() => undefined) + ); + + expect(response.statusCode).toBe(403); + }); + + it("rejects duplicate, unsupported, and unknown corridor input", async () => { + for (const body of [ + { corridors: ["US", "US"] }, + { corridors: ["EU"] }, + { corridors: ["US"], userId: "other-user" } + ]) { + const response = responseDouble(); + await getLimits( + { body, userId: "user-1" } as unknown as Request, + response as unknown as Response, + mock(() => undefined) + ); + expect(response.statusCode).toBe(400); + } + }); +}); diff --git a/apps/api/src/api/controllers/limits.controller.ts b/apps/api/src/api/controllers/limits.controller.ts new file mode 100644 index 000000000..427ce0615 --- /dev/null +++ b/apps/api/src/api/controllers/limits.controller.ts @@ -0,0 +1,42 @@ +import { GetUserLimitsRequest, GetUserLimitsResponse, LimitsCorridor } from "@vortexfi/shared"; +import { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import { getEffectiveUserId } from "../middlewares/effectiveUser"; +import { getUserLimits } from "../services/limits.service"; + +const SUPPORTED_CORRIDORS = new Set(["AR", "BR", "CO", "MX", "US"]); + +function isValidRequest(body: unknown): body is GetUserLimitsRequest { + if (!body || typeof body !== "object" || Array.isArray(body)) return false; + const record = body as Record; + if (Object.keys(record).some(key => key !== "corridors")) return false; + if (!Array.isArray(record.corridors) || record.corridors.length === 0) return false; + if (record.corridors.some(corridor => typeof corridor !== "string" || !SUPPORTED_CORRIDORS.has(corridor as LimitsCorridor))) { + return false; + } + return new Set(record.corridors).size === record.corridors.length; +} + +export async function getLimits( + req: Request, + res: Response, + next: NextFunction +): Promise { + const userId = getEffectiveUserId(req); + if (!userId) { + res.status(httpStatus.FORBIDDEN).json({ error: "A user-scoped credential is required" }); + return; + } + if (!isValidRequest(req.body)) { + res + .status(httpStatus.BAD_REQUEST) + .json({ error: "corridors must be a non-empty, duplicate-free list of AR, BR, CO, MX, or US" }); + return; + } + + try { + res.json(await getUserLimits(userId, req.body.corridors)); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/api/controllers/quote.controller.ts b/apps/api/src/api/controllers/quote.controller.ts index b17c06fc2..99e303651 100644 --- a/apps/api/src/api/controllers/quote.controller.ts +++ b/apps/api/src/api/controllers/quote.controller.ts @@ -31,7 +31,7 @@ export const createQuote = async ( next: NextFunction ): Promise => { try { - const { rampType, from, to, inputAmount, inputCurrency, outputCurrency, partnerId, apiKey } = req.body; + const { rampType, from, to, inputAmount, inputCurrency, outputCurrency, apiKey } = req.body; const network = getNetworkFromDestination(rampType === RampDirection.BUY ? to : from); @@ -44,19 +44,19 @@ 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({ + apiCredentialId: req.credential?.credentialId, apiKey: publicApiKey, from, inputAmount, inputCurrency, network, outputCurrency, - partnerId, - partnerName: publicKeyPartnerName, + partnerId: req.credential?.partnerId ?? undefined, + partnerName: undefined, rampType, to, userId: effectiveUserId @@ -68,8 +68,8 @@ export const createQuote = async ( httpStatus: httpStatus.CREATED, network, operation: "quote_create", - partnerId: req.authenticatedPartner?.id || partnerId || null, - partnerName: req.authenticatedPartner?.name || publicKeyPartnerName || null, + partnerId: req.credential?.partnerId || null, + partnerName: req.authenticatedPartner?.name || null, paymentMethod: quote.paymentMethod, quoteId: quote.id, rampType, @@ -84,8 +84,8 @@ export const createQuote = async ( observeQuoteFailure(req, "quote_create", error, { apiKeyPrefix: getSafeApiKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey, ["pk_"]), network: getNetworkFromDestination(req.body?.rampType === RampDirection.BUY ? req.body?.to : req.body?.from), - partnerId: req.authenticatedPartner?.id || req.body?.partnerId || null, - partnerName: req.authenticatedPartner?.name || req.validatedPublicKey?.partnerName || null, + partnerId: req.credential?.partnerId || null, + partnerName: req.authenticatedPartner?.name || null, paymentMethod: req.body?.paymentMethod, rampType: req.body?.rampType }); @@ -103,16 +103,15 @@ export const createBestQuote = async ( next: NextFunction ): Promise => { try { - const { rampType, from, to, inputAmount, inputCurrency, outputCurrency, partnerId, apiKey, countryCode, networks } = - req.body; + const { rampType, from, to, inputAmount, inputCurrency, outputCurrency, apiKey, countryCode, networks } = req.body; // 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({ + apiCredentialId: req.credential?.credentialId, apiKey: publicApiKey, countryCode, from, @@ -120,8 +119,8 @@ export const createBestQuote = async ( inputCurrency, networks, outputCurrency, - partnerId, - partnerName: publicKeyPartnerName, + partnerId: req.credential?.partnerId ?? undefined, + partnerName: undefined, rampType, to, userId: effectiveUserId @@ -133,8 +132,8 @@ export const createBestQuote = async ( httpStatus: httpStatus.CREATED, network: quote.network, operation: "quote_create_best", - partnerId: req.authenticatedPartner?.id || partnerId || null, - partnerName: req.authenticatedPartner?.name || publicKeyPartnerName || null, + partnerId: req.credential?.partnerId || null, + partnerName: req.authenticatedPartner?.name || null, paymentMethod: quote.paymentMethod, quoteId: quote.id, rampType, @@ -148,8 +147,8 @@ export const createBestQuote = async ( logger.error("Error creating best quote", { errorType: classifyApiClientError(error), requestId: req.requestId }); observeQuoteFailure(req, "quote_create_best", error, { apiKeyPrefix: getSafeApiKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey, ["pk_"]), - partnerId: req.authenticatedPartner?.id || req.body?.partnerId || null, - partnerName: req.authenticatedPartner?.name || req.validatedPublicKey?.partnerName || null, + partnerId: req.credential?.partnerId || null, + partnerName: req.authenticatedPartner?.name || null, rampType: req.body?.rampType }); next(error); diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index ee0c57602..c8d2ffea4 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -84,11 +84,9 @@ export function mapProviderFailure(error: unknown): { error: unknown; logContext /** * Render the provider log context as a message suffix. * - * The app logger (`config/logger.ts`) formats only `{ timestamp, level, message, label }` and - * drops any metadata object passed as the second argument. Provider context therefore has to - * live in the message string itself to reach the logs — passing it as metadata (as we did - * before) silently discarded it. Server-side only; the body is already truncated. Returns an - * empty string for non-provider failures so their log line is unchanged. + * Keeping this short context in the message makes provider failures easy to scan and search. + * Server-side only; the body is already sanitized and truncated. Returns an empty string for + * non-provider failures so their log line is unchanged. */ export function formatProviderContext(logContext: Record): string { if (!logContext.provider) { @@ -355,11 +353,7 @@ export const getRampHistory = async ( } const effectiveUserId = getEffectiveUserId(req); - const owner = req.authenticatedPartner - ? { partnerId: req.authenticatedPartner.id } - : effectiveUserId - ? { userId: effectiveUserId } - : null; + const owner = effectiveUserId ? { userId: effectiveUserId } : null; if (!owner) { throw new APIError({ message: "Authentication required", status: httpStatus.UNAUTHORIZED }); } @@ -407,7 +401,7 @@ interface RampObservationContext { } interface ObservedRampRequest { - authenticatedPartner?: { id: string; name: string }; + authenticatedPartner?: { name: string }; body?: unknown; method?: string; params?: unknown; @@ -415,6 +409,7 @@ interface ObservedRampRequest { query?: unknown; requestId?: string; requestStartedAt?: number; + credential?: Request["credential"]; userId?: string; } @@ -429,11 +424,11 @@ function observeRampSuccess( durationMs: getRequestDurationMs(req), httpStatus: status, operation, - partnerId: req.authenticatedPartner?.id || null, + partnerId: req.credential?.partnerId || null, partnerName: req.authenticatedPartner?.name || null, requestId: req.requestId, status: "success", - userId: req.userId || null + userId: getEffectiveUserId(req) || null }); } @@ -452,11 +447,11 @@ function observeRampFailure( httpStatus: status, metadata: buildRampRequestMetadata(req, operation), operation, - partnerId: req.authenticatedPartner?.id || null, + partnerId: req.credential?.partnerId || null, 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/controllers/rampInfo.controller.test.ts b/apps/api/src/api/controllers/rampInfo.controller.test.ts new file mode 100644 index 000000000..aaadd449d --- /dev/null +++ b/apps/api/src/api/controllers/rampInfo.controller.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, mock } from "bun:test"; +import { Request, Response } from "express"; +import { getRampInfo } from "./rampInfo.controller"; + +function responseDouble() { + const response = { + body: undefined as unknown, + statusCode: 200, + json: mock((body: unknown) => { + response.body = body; + return response; + }), + status: mock((statusCode: number) => { + response.statusCode = statusCode; + return response; + }) + }; + return response; +} + +describe("getRampInfo controller", () => { + it("requires a resolved credential and never accepts a caller-selected profile", async () => { + const response = responseDouble(); + + await getRampInfo({ query: { profileId: "other-profile" } } as unknown as Request, response as unknown as Response); + + expect(response.statusCode).toBe(401); + expect(response.body).toMatchObject({ error: { code: "CREDENTIAL_REQUIRED" } }); + }); +}); diff --git a/apps/api/src/api/controllers/rampInfo.controller.ts b/apps/api/src/api/controllers/rampInfo.controller.ts new file mode 100644 index 000000000..9b3b27043 --- /dev/null +++ b/apps/api/src/api/controllers/rampInfo.controller.ts @@ -0,0 +1,30 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import { getRampInfo as resolveRampInfo } from "../services/rampInfo.service"; + +export async function getRampInfo(req: Request, res: Response): Promise { + if (!req.credential) { + res.status(httpStatus.UNAUTHORIZED).json({ + error: { + code: "CREDENTIAL_REQUIRED", + message: "A public or secret API credential is required", + status: httpStatus.UNAUTHORIZED + } + }); + return; + } + + try { + res.status(httpStatus.OK).json(await resolveRampInfo(req.credential.profileId)); + } catch (error) { + logger.error("Failed to resolve ramp info", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to read ramp info", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/controllers/recipients.controller.ts b/apps/api/src/api/controllers/recipients.controller.ts index 72256d931..928cd5df2 100644 --- a/apps/api/src/api/controllers/recipients.controller.ts +++ b/apps/api/src/api/controllers/recipients.controller.ts @@ -11,6 +11,7 @@ import httpStatus from "http-status"; import { Op } from "sequelize"; import sequelize from "../../config/database"; import logger from "../../config/logger"; +import { config, RECIPIENT_INVITE_DISCOUNT_HARD_CAP_BPS } from "../../config/vars"; import CustomerEntity from "../../models/customerEntity.model"; import ProfileRole from "../../models/profileRole.model"; import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; @@ -56,12 +57,14 @@ interface CreateInviteBody { discounts?: { buyBps?: number; sellBps?: number }; } -// Bounded by the runtime EVM discount-subsidy cap (5% of quote output, which also absorbs -// adverse execution): a larger advertised discount could never execute without stalling. -const MAX_DISCOUNT_BPS = 300; - function isValidBps(value: unknown): value is number { - return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= MAX_DISCOUNT_BPS; + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= 0 && + value <= RECIPIENT_INVITE_DISCOUNT_HARD_CAP_BPS && + value <= config.recipients.inviteMaxDiscountBps + ); } export async function createInvite(req: Request, res: Response): Promise { @@ -116,7 +119,7 @@ export async function createInvite(req: Request, res: Response): Promise { res, httpStatus.BAD_REQUEST, "INVALID_DISCOUNTS", - `discounts.buyBps and discounts.sellBps must be integers between 0 and ${MAX_DISCOUNT_BPS}` + `discounts.buyBps and discounts.sellBps must be integers between 0 and ${config.recipients.inviteMaxDiscountBps}` ); return; } diff --git a/apps/api/src/api/controllers/userApiKeys.controller.test.ts b/apps/api/src/api/controllers/userApiKeys.controller.test.ts deleted file mode 100644 index 40856e743..000000000 --- a/apps/api/src/api/controllers/userApiKeys.controller.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -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, revokedAt: expect.any(Date) }, id: "secret-key-id" }, - { changes: { isActive: false, revokedAt: expect.any(Date) }, 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 index 49405b03f..61482e64e 100644 --- a/apps/api/src/api/controllers/userApiKeys.controller.ts +++ b/apps/api/src/api/controllers/userApiKeys.controller.ts @@ -1,320 +1,77 @@ 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; +import { + ApiCredentialServiceError, + createCredential, + listCredentials, + MAX_ACTIVE_CREDENTIALS_PER_PROFILE, + revokeCredential +} from "../services/apiCredential.service"; + +export { MAX_ACTIVE_CREDENTIALS_PER_PROFILE }; +export const MAX_ACTIVE_KEYS_PER_USER = MAX_ACTIVE_CREDENTIALS_PER_PROFILE; + +function requireProfile(req: Request, res: Response): string | null { + if (req.userId) return req.userId; + res.status(httpStatus.UNAUTHORIZED).json({ + error: { code: "AUTHENTICATION_REQUIRED", message: "Authentication required to manage API credentials", status: 401 } + }); + return null; } -// 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; +function sendServiceError(res: Response, error: unknown): boolean { + if (!(error instanceof ApiCredentialServiceError)) return false; + const status = + error.code === "CREDENTIAL_LIMIT_REACHED" + ? httpStatus.CONFLICT + : error.code === "CREDENTIAL_NOT_FOUND" || error.code === "CREDENTIAL_SUBJECT_REQUIRED" + ? httpStatus.NOT_FOUND + : httpStatus.BAD_REQUEST; + res.status(status).json({ error: { code: error.code, message: error.message, status } }); + return true; +} 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; - + const profileId = requireProfile(req, res); + if (!profileId) return; 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)`, - partnerId: null, - 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)`, - partnerId: null, - 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" - } + const credential = await createCredential({ + environment: config.sandboxEnabled ? "test" : "live", + expiresAt: req.body?.expiresAt, + name: req.body?.name, + partnerId: null, + profileId }); + res.status(httpStatus.CREATED).json(credential); } 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 - } - }); + if (sendServiceError(res, error)) return; + logger.error("Error creating API credential", error); + res.status(500).json({ error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to create API credential", status: 500 } }); } } 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; - } - + const profileId = requireProfile(req, res); + if (!profileId) 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 - })) - }); + res.status(httpStatus.OK).json({ credentials: await listCredentials({ partnerId: null, profileId }) }); } 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 - } - }); + logger.error("Error listing API credentials", error); + res.status(500).json({ error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to list API credentials", status: 500 } }); } } -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; - +export async function revokeUserApiKey(req: Request<{ credentialId?: string; keyId?: string }>, res: Response): Promise { + const profileId = requireProfile(req, res); + if (!profileId) return; 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, revokedAt: new Date() }); - 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; - } - - const revokedAt = new Date(); - await Promise.all([primaryKey.update({ isActive: false, revokedAt }), pairedKey.update({ isActive: false, revokedAt })]); + await revokeCredential(req.params.credentialId ?? req.params.keyId ?? "", { partnerId: null, profileId }); 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 - } - }); + if (sendServiceError(res, error)) return; + logger.error("Error revoking API credential", error); + res.status(500).json({ error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to revoke API credential", status: 500 } }); } } diff --git a/apps/api/src/api/controllers/webhook.controller.ts b/apps/api/src/api/controllers/webhook.controller.ts index 48899bd93..01132f2d8 100644 --- a/apps/api/src/api/controllers/webhook.controller.ts +++ b/apps/api/src/api/controllers/webhook.controller.ts @@ -3,7 +3,19 @@ import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; import { APIError } from "../errors/api-error"; -import webhookService from "../services/webhook/webhook.service"; +import { getEffectiveUserId } from "../middlewares/effectiveUser"; +import webhookService, { WebhookOwner } from "../services/webhook/webhook.service"; + +// Webhooks are owned by the principal behind the secret key: the partner for +// partner-scoped keys, the linked user for self-serve user keys. +function webhookOwnerFromRequest(req: Pick): WebhookOwner { + if (req.userId) return { partnerId: null, userId: req.userId }; + const partnerId = req.credential?.partnerId ?? null; + return { + partnerId, + userId: partnerId ? null : (getEffectiveUserId(req) ?? null) + }; +} export const registerWebhook = async ( req: Request, @@ -42,12 +54,15 @@ export const registerWebhook = async ( }); } - const webhook = await webhookService.registerWebhook({ - events, - quoteId, - sessionId, - url - }); + const webhook = await webhookService.registerWebhook( + { + events, + quoteId, + sessionId, + url + }, + webhookOwnerFromRequest(req) + ); res.status(httpStatus.CREATED).json(webhook); } catch (error) { @@ -71,7 +86,7 @@ export const deleteWebhook = async ( }); } - const success = await webhookService.deleteWebhook(id); + const success = await webhookService.deleteWebhook(id, webhookOwnerFromRequest(req)); if (!success) { throw new APIError({ diff --git a/apps/api/src/api/errors/phase-error.ts b/apps/api/src/api/errors/phase-error.ts index 3255bf945..14e632b85 100644 --- a/apps/api/src/api/errors/phase-error.ts +++ b/apps/api/src/api/errors/phase-error.ts @@ -16,6 +16,23 @@ export class RecoverablePhaseError extends PhaseError { } } +/** + * A recoverable phase error that requires an operator to reconcile an + * ambiguous external side effect before the ramp may be resumed. + * + * Unlike an ordinary transient error, retrying this automatically could repeat + * a payment or transfer whose first result is unknown. + */ +export class ReconciliationRequiredPhaseError extends RecoverablePhaseError {} + +export function requiresManualReconciliation(error: unknown): error is Error & { requiresManualReconciliation: true } { + return ( + error instanceof Error && + "requiresManualReconciliation" in error && + (error as { requiresManualReconciliation?: unknown }).requiresManualReconciliation === true + ); +} + export class UnrecoverablePhaseError extends PhaseError { constructor(message: string) { super(message, false); diff --git a/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts b/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts deleted file mode 100644 index e2571a14c..000000000 --- a/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -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, - partnerId = "partner-id", - partnerName = null -}: { userId?: string | null; partnerId?: 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, - partnerId, - 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", partnerId: "partner-id"}); - 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, partnerId: "partner-id"}); - 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 partnerId, userId set)", async () => { - const key = createSecretKeyRecord({userId: "user-scoped", partnerId: 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 partnerId and no userId (unusable)", async () => { - const key = createSecretKeyRecord({userId: null, partnerId: null}); - ApiKey.findAll = mock( - async () => [key as unknown as ApiKey] - ) as typeof ApiKey.findAll; - - const result = await validateSecretApiKey(key.raw); - expect(result).toBeNull(); - }); - - it("rejects an orphaned partner key instead of degrading it into a user-scoped key", async () => { - // Deleting a partner row sets partner_id NULL (FK ON DELETE SET NULL) but keeps - // partner_name — such a key is revoked, even when it carries a linked user. - const key = createSecretKeyRecord({partnerId: null, partnerName: "DeletedPartner", userId: "user-bound"}); - 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 cb1f58f70..41d5a1dc7 100644 --- a/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts +++ b/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts @@ -1,264 +1,52 @@ -import bcrypt from "bcrypt"; -import crypto from "crypto"; -import logger from "../../config/logger"; -import ApiKey from "../../models/apiKey.model"; import Partner from "../../models/partner.model"; +import { CredentialContext, validatePublicKey, validateSecretKey } from "../services/apiCredential.service"; + +export { + digestApiKey, + generateApiKey, + getKeyPrefix, + getKeyType, + getSecretKeyLookupPrefix, + isValidApiKeyFormat, + isValidSecretKeyFormat, + SECRET_KEY_LOOKUP_PREFIX_LENGTH +} from "./apiKeyFormat"; export interface AuthenticatedPartner { id: string; 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 have no - * `partner_id` binding; in that case the request authenticates purely as - * the linked user via `apiKeyUserId`. - */ export interface ValidatedSecretKey { apiKeyId: string; - apiKeyUserId: string | null; + credential: CredentialContext; 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; + credential: CredentialContext; } -/** - * Validate API key format for both public and secret keys - * Public: pk_(live|test)_[32 alphanumeric chars] - * Secret: sk_(live|test)_[32 alphanumeric chars] - */ -export function isValidApiKeyFormat(key: string): boolean { - return /^(pk|sk)_(live|test)_[a-zA-Z0-9]{32}$/.test(key); -} - -/** - * Validate secret key format specifically - */ -export function isValidSecretKeyFormat(key: string): boolean { - return /^sk_(live|test)_[a-zA-Z0-9]{32}$/.test(key); -} - -/** - * Detect if a key is public or secret based on prefix - */ -export function getKeyType(key: string): "public" | "secret" | null { - if (key.startsWith("pk_")) return "public"; - if (key.startsWith("sk_")) return "secret"; - return null; -} - -/** - * Generate a new API key (public or secret) - * @param keyType - 'public' or 'secret' - * @param environment - 'live' or 'test' environment - * @returns Generated API key string - */ -export function generateApiKey(keyType: "public" | "secret", environment: "live" | "test" = "live"): string { - const randomPart = crypto - .randomBytes(32) - .toString("base64") - .replace(/\+/g, "") - .replace(/\//g, "") - .replace(/=/g, "") - .substring(0, 32); - - const prefix = keyType === "public" ? "pk" : "sk"; - return `${prefix}_${environment}_${randomPart}`; -} - -/** - * Hash an API key for storage using bcrypt (only for secret keys) - * @param key - The raw API key to hash - * @returns Promise resolving to the hashed key - */ -export async function hashApiKey(key: string): Promise { - const saltRounds = 10; - return bcrypt.hash(key, saltRounds); -} - -/** - * Get key prefix (first 8 characters) for display and lookup - * @param key - The API key - * @returns First 8 characters of the key - */ -export function getKeyPrefix(key: string): string { - // pk_live_ or sk_test_ = 8 chars, pk_test_ or sk_live_ = 8 chars - return key.substring(0, 8); -} - -/** - * Validate public API key (simple lookup, no hashing) - * @param apiKey - The public API key to validate - * @returns Promise resolving to validation result, or null if the key is invalid/expired/inactive - */ export async function validatePublicApiKey(apiKey: string): Promise { - try { - const keyRecord = await ApiKey.findOne({ - where: { - isActive: true, - keyType: "public", - keyValue: apiKey - } - }); - - if (!keyRecord) { - return null; - } - - // Check expiration - if (keyRecord.expiresAt && new Date() > keyRecord.expiresAt) { - return null; // Key expired - } - - // Update last used timestamp (async, don't wait) - keyRecord.update({ lastUsedAt: new Date() }).catch(err => { - logger.error("Failed to update lastUsedAt for public key:", err); - }); - - // A partner-created key whose partner row was deleted (FK ON DELETE SET NULL) is - // revoked — it must not degrade into a partnerless public key. - if (!keyRecord.partnerId && keyRecord.partnerName) { - return null; - } - - // Resolve the partner name through the FK; downstream quote resolution looks the - // partner up by its (unique) name and applies its own is-active filtering. - let partnerName: string | null = null; - if (keyRecord.partnerId) { - const partner = await Partner.findByPk(keyRecord.partnerId); - if (!partner) { - return null; // Partner row gone: treat the key as revoked. - } - partnerName = partner.name; - } - - return { partnerName }; - } catch (error) { - logger.error("Error validating public API key:", error); - return null; - } + const credential = await validatePublicKey(apiKey); + if (!credential) return null; + const partner = credential.partnerId ? await Partner.findByPk(credential.partnerId) : null; + if (credential.partnerId && !partner) return null; + return { credential }; } -/** - * Validate secret API key and return associated partner information - * Uses bcrypt hash comparison for security - * @param apiKey - The secret API key to validate - * @returns Promise resolving to validation result, or null if invalid - */ export async function validateSecretApiKey(apiKey: string): Promise { - try { - // Extract prefix for quick lookup - const prefix = getKeyPrefix(apiKey); - - // Find all active secret keys with this prefix - const apiKeys = await ApiKey.findAll({ - where: { - isActive: true, - keyPrefix: prefix, - keyType: "secret" - } - }); - - // Check each key's hash using bcrypt - for (const keyRecord of apiKeys) { - if (!keyRecord.keyHash) { - continue; // Skip if no hash (shouldn't happen for secret keys) - } - - const isMatch = await bcrypt.compare(apiKey, keyRecord.keyHash); - - if (isMatch) { - // Check expiration - if (keyRecord.expiresAt && new Date() > keyRecord.expiresAt) { - continue; // Key expired, try next - } - - // A partner-created key keeps partner_name even after its partner row is deleted - // (the FK is ON DELETE SET NULL). It must be rejected, not degraded into a - // user-scoped key — deleting a partner is key revocation. - if (!keyRecord.partnerId && keyRecord.partnerName) { - continue; - } - - // User-scoped keys (no partner binding at all) authenticate purely as the linked - // user; skip the Partner lookup so they remain usable without a partner row. - if (!keyRecord.partnerId) { - if (!keyRecord.userId) { - // Key has no partner and no user binding: unusable. - continue; - } - - // Update last used timestamp (async, don't wait) - keyRecord.update({ lastUsedAt: new Date() }).catch(err => { - logger.error("Failed to update lastUsedAt for secret key:", err); - }); - - return { - apiKeyId: keyRecord.id, - apiKeyUserId: keyRecord.userId, - partner: null - }; - } - - // Partner-scoped keys: resolve the partner through the FK - const partner = await Partner.findOne({ - where: { - id: keyRecord.partnerId, - isActive: true - } - }); - - if (!partner) { - continue; // Partner missing or inactive - } - - // Update last used timestamp (async, don't wait) - keyRecord.update({ lastUsedAt: new Date() }).catch(err => { - logger.error("Failed to update lastUsedAt for secret key:", err); - }); - - return { - apiKeyId: keyRecord.id, - apiKeyUserId: keyRecord.userId, - partner: { - id: partner.id, - name: partner.name - } - }; - } - } - - return null; // No matching key found - } catch (error) { - logger.error("Error validating secret API key:", error); - return null; - } + const credential = await validateSecretKey(apiKey); + if (!credential) return null; + const partner = credential.partnerId ? await Partner.findOne({ where: { id: credential.partnerId, isActive: true } }) : null; + if (credential.partnerId && !partner) return null; + return { + apiKeyId: credential.credentialId, + credential, + partner: partner ? { id: partner.id, name: partner.name } : null + }; } -/** - * Unified validation function that detects key type and validates accordingly - * @param apiKey - The API key to validate (public or secret) - * @returns Promise resolving to validation result for secret keys, or null for public/invalid keys - */ export async function validateApiKey(apiKey: string): Promise { - const keyType = getKeyType(apiKey); - - if (keyType === "secret") { - return validateSecretApiKey(apiKey); - } - - if (keyType === "public") { - // Public keys don't provide authentication, just validation - // Return null to indicate no authentication - return null; - } - - return null; // Invalid key format + return validateSecretApiKey(apiKey); } diff --git a/apps/api/src/api/middlewares/apiKeyAuth.ts b/apps/api/src/api/middlewares/apiKeyAuth.ts index 34da068ae..c2b419df5 100644 --- a/apps/api/src/api/middlewares/apiKeyAuth.ts +++ b/apps/api/src/api/middlewares/apiKeyAuth.ts @@ -8,8 +8,13 @@ import { } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; import { ApiClientErrorType } from "../observability/types"; -import { AuthenticatedPartner, getKeyType, isValidSecretKeyFormat, validateApiKey } from "./apiKeyAuth.helpers"; -import { setApiKeyUserId } from "./effectiveUser"; +import { + AuthenticatedPartner, + getKeyType, + isValidSecretKeyFormat, + validateApiKey, + validatePublicApiKey +} from "./apiKeyAuth.helpers"; // Extend Express Request type to include authenticatedPartner declare global { @@ -61,7 +66,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { error: { code: "INVALID_SECRET_KEY", message: - "X-API-Key header must contain a secret key (sk_live_* or sk_test_*). Use public keys (pk_*) in request body for tracking.", + "X-API-Key header must contain a secret key (sk_live_* or sk_test_*). Use X-Public-Key for public credentials.", status: 401 } }); @@ -94,53 +99,49 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { const partner = result.partner; + let publicCredentialId = req.credential?.strength === "public" ? req.credential.credentialId : undefined; + if (!publicCredentialId && req.headers["x-public-key"]) { + const publicResult = await validatePublicApiKey(req.headers["x-public-key"] as string); + if (!publicResult) { + recordAuthFailure(req, 401, "auth_invalid_public_key", getSafeApiKeyPrefix(req.headers["x-public-key"] as string)); + return res.status(401).json({ + error: { code: "INVALID_PUBLIC_KEY", message: "The provided public API key is invalid or expired", status: 401 } + }); + } + publicCredentialId = publicResult.credential.credentialId; + } + if (publicCredentialId && publicCredentialId !== result.credential.credentialId) { + return res.status(403).json({ + error: { code: "CREDENTIAL_MISMATCH", message: "Public and secret credentials do not match", status: 403 } + }); + } + + req.credential = result.credential; + // 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) { - const partnerIdOrName = req.body.partnerId; - - // Detect if partnerId is a UUID or a name - const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(partnerIdOrName); - - let requestedPartnerName: string; - - if (isUUID) { - // Look up the partner by UUID - const requestedPartner = await Partner.findByPk(partnerIdOrName); - - if (!requestedPartner) { - recordAuthFailure(req, 404, "auth_partner_not_found", getSafeApiKeyPrefix(apiKey, ["sk_"]), partner ?? undefined); - return res.status(404).json({ - error: { - code: "PARTNER_NOT_FOUND", - message: "The requested partner was not found", - status: 404 - } - }); - } - - requestedPartnerName = requestedPartner.name; - } else { - // Treat as partner name - requestedPartnerName = partnerIdOrName; + const requestedPartner = await resolvePartner(req.body.partnerId); + if (!requestedPartner) { + recordAuthFailure(req, 404, "auth_partner_not_found", getSafeApiKeyPrefix(apiKey, ["sk_"]), partner ?? undefined); + return res.status(404).json({ + error: { + code: "PARTNER_NOT_FOUND", + message: "The requested partner was not found", + status: 404 + } + }); } - // Compare partner names since one API key works for all partners with same name - if (!partner || requestedPartnerName !== partner.name) { + if (requestedPartner.id !== req.credential.partnerId) { recordAuthFailure(req, 403, "auth_partner_mismatch", getSafeApiKeyPrefix(apiKey, ["sk_"]), partner ?? undefined); return res.status(403).json({ error: { code: "PARTNER_MISMATCH", - details: { - authenticatedPartnerName: partner?.name ?? null, - requestedPartnerName: requestedPartnerName - }, - message: "The authenticated partner name does not match the requested partner's name", + message: "The authenticated partner does not match the requested partner", status: 403 } }); @@ -158,7 +159,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { /** * Middleware to enforce partner authentication when partnerId is in payload * This ensures that if a partnerId is specified, the request must be authenticated - * and the authenticated partner name must match the requested partner's name. + * and resolve to the credential's canonical partner ID. * * Supports both UUID (partner ID) and string (partner name) formats. */ @@ -166,8 +167,7 @@ export function enforcePartnerAuth() { return async (req: Request, res: Response, next: NextFunction) => { // If partnerId is in the payload if (req.body?.partnerId) { - // Partner must be authenticated - if (!req.authenticatedPartner) { + if (!req.credential?.partnerId) { recordAuthFailure(req, 403, "auth_missing_api_key"); return res.status(403).json({ error: { @@ -178,45 +178,24 @@ export function enforcePartnerAuth() { }); } - const partnerIdOrName = req.body.partnerId; - - // Detect if partnerId is a UUID or a name - const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(partnerIdOrName); - - let requestedPartnerName: string; - - if (isUUID) { - // Look up the partner by UUID - const requestedPartner = await Partner.findByPk(partnerIdOrName); - - if (!requestedPartner) { - recordAuthFailure(req, 404, "auth_partner_not_found", null, req.authenticatedPartner); - return res.status(404).json({ - error: { - code: "PARTNER_NOT_FOUND", - message: "The requested partner was not found", - status: 404 - } - }); - } - - requestedPartnerName = requestedPartner.name; - } else { - // Treat as partner name - requestedPartnerName = partnerIdOrName; + const requestedPartner = await resolvePartner(req.body.partnerId); + if (!requestedPartner) { + recordAuthFailure(req, 404, "auth_partner_not_found", null); + return res.status(404).json({ + error: { + code: "PARTNER_NOT_FOUND", + message: "The requested partner was not found", + status: 404 + } + }); } - // Compare partner names (not IDs) since one API key works for all partners with same name - if (requestedPartnerName !== req.authenticatedPartner.name) { + if (requestedPartner.id !== req.credential.partnerId) { recordAuthFailure(req, 403, "auth_partner_mismatch", null, req.authenticatedPartner); return res.status(403).json({ error: { code: "PARTNER_MISMATCH", - details: { - authenticatedPartnerName: req.authenticatedPartner.name, - requestedPartnerName: requestedPartnerName - }, - message: "The authenticated partner name does not match the requested partner's name", + message: "The authenticated partner does not match the requested partner", status: 403 } }); @@ -227,6 +206,11 @@ export function enforcePartnerAuth() { }; } +async function resolvePartner(partnerIdOrName: string): Promise { + const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(partnerIdOrName); + return isUUID ? Partner.findByPk(partnerIdOrName) : Partner.findOne({ where: { name: partnerIdOrName } }); +} + function recordAuthFailure( req: Request, httpStatus: number, @@ -248,10 +232,10 @@ function recordAuthFailure( httpStatus, metadata: buildApiClientRequestMetadata(req, { bodyKeys: ["partnerId"] }), operation: "auth_api_key", - partnerId: partner?.id || req.authenticatedPartner?.id || null, + partnerId: partner?.id || req.credential?.partnerId || null, partnerName: partner?.name || req.authenticatedPartner?.name || null, requestId: req.requestId, status: "failure", - userId: req.userId || req.apiKeyUserId || null + userId: req.userId || req.credential?.profileId || null }); } diff --git a/apps/api/src/api/middlewares/apiKeyFormat.ts b/apps/api/src/api/middlewares/apiKeyFormat.ts new file mode 100644 index 000000000..2a5c34881 --- /dev/null +++ b/apps/api/src/api/middlewares/apiKeyFormat.ts @@ -0,0 +1,40 @@ +import crypto from "crypto"; + +export function isValidApiKeyFormat(key: string): boolean { + return /^(pk|sk)_(live|test)_[a-zA-Z0-9]{32}$/.test(key); +} + +export function isValidSecretKeyFormat(key: string): boolean { + return /^sk_(live|test)_[a-zA-Z0-9]{32}$/.test(key); +} + +export function getKeyType(key: string): "public" | "secret" | null { + if (key.startsWith("pk_")) return "public"; + if (key.startsWith("sk_")) return "secret"; + return null; +} + +export function generateApiKey(keyType: "public" | "secret", environment: "live" | "test" = "live"): string { + const randomPart = crypto + .randomBytes(32) + .toString("base64") + .replace(/\+/g, "") + .replace(/\//g, "") + .replace(/=/g, "") + .substring(0, 32); + return `${keyType === "public" ? "pk" : "sk"}_${environment}_${randomPart}`; +} + +export function digestApiKey(key: string): string { + return crypto.createHash("sha256").update(key).digest("hex"); +} + +export function getKeyPrefix(key: string): string { + return key.substring(0, 8); +} + +export const SECRET_KEY_LOOKUP_PREFIX_LENGTH = 16; + +export function getSecretKeyLookupPrefix(key: string): string { + return key.substring(0, SECRET_KEY_LOOKUP_PREFIX_LENGTH); +} diff --git a/apps/api/src/api/middlewares/auth.ts b/apps/api/src/api/middlewares/auth.ts deleted file mode 100644 index 3eef63062..000000000 --- a/apps/api/src/api/middlewares/auth.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import httpStatus from "http-status"; -import logger from "../../config/logger"; -import { validateSignatureAndGetMemo } from "../services/siwe.service"; - -declare global { - // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. - namespace Express { - interface Request { - derivedMemo?: string | null; - } - } -} - -async function getMemoFromCookiesMiddleware(req: Request, res: Response, next: NextFunction): Promise { - // If the client didn't specify, we don't want to pass a derived memo even if a cookie was sent. - - req.derivedMemo = null; // Explicit overwrite to avoid tampering, defensive. - - if (!req.body.usesMemo) { - next(); - return; - } - - try { - const { - cookies, - body: { address } - } = req; - - const cookieKey = `authToken_${address}`; - const authToken = cookies[cookieKey]; - - // Check if matches the address requested by client, otherwise ignore cookie - if (!authToken?.signature || !authToken?.nonce) { - res.status(httpStatus.UNAUTHORIZED).json({ - error: "Missing or invalid authentication token" - }); - return; - } - - const memo = await validateSignatureAndGetMemo(authToken.nonce, authToken.signature); - - // Client declared usage of memo, but it could not be derived from provided signatures - if (!memo) { - res.status(httpStatus.UNAUTHORIZED).json({ - error: "Missing or invalid authentication token" - }); - return; - } - - req.derivedMemo = memo; - next(); - } catch (error) { - const err = error as Error; - // Distinguish between failed signature check and other errors - if (err.message.includes("Could not verify signature")) { - res.status(httpStatus.UNAUTHORIZED).json({ - details: err.message, - error: "Signature validation failed." - }); - return; - } - - logger.error(`Error in getMemoFromCookiesMiddleware: ${err.message}`); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - details: err.message, - error: "Error while verifying signature" - }); - } -} - -export { getMemoFromCookiesMiddleware }; diff --git a/apps/api/src/api/middlewares/dualAuth.ts b/apps/api/src/api/middlewares/dualAuth.ts index aafb78e3e..11aa3e8f4 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -7,15 +7,15 @@ import { } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; import { SupabaseAuthService } from "../services/auth"; -import { getKeyType, isValidSecretKeyFormat, validateSecretApiKey } from "./apiKeyAuth.helpers"; -import { setApiKeyUserId } from "./effectiveUser"; +import { getKeyType, isValidSecretKeyFormat, validatePublicApiKey, validateSecretApiKey } from "./apiKeyAuth.helpers"; export { assertQuoteOwnership, assertRampOwnership } from "./ownershipAuth"; /** * Dual-track authentication: accepts either a partner secret API key * (X-API-Key: sk_*) or a Supabase user Bearer token (Authorization: Bearer ...). - * Exactly one of req.authenticatedPartner or req.userId is populated on success. + * Canonical API credential identity is populated on `req.credential`; Supabase + * identity remains on `req.userId`. */ export function requirePartnerOrUserAuth() { return dualAuthHandler({ requireCredentials: true }); @@ -64,10 +64,25 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } }); } + const publicKey = req.headers["x-public-key"] as string | undefined; + if (publicKey) { + const publicResult = await validatePublicApiKey(publicKey); + if (!publicResult) { + return res.status(401).json({ + error: { code: "INVALID_PUBLIC_KEY", message: "The provided public API key is invalid or expired.", status: 401 } + }); + } + if (publicResult.credential.credentialId !== result.credential.credentialId) { + return res.status(403).json({ + error: { code: "CREDENTIAL_MISMATCH", message: "Public and secret credentials do not match", status: 403 } + }); + } + } + if (result.partner) { req.authenticatedPartner = result.partner; } - setApiKeyUserId(req, result.apiKeyUserId); + req.credential = result.credential; return next(); } @@ -122,10 +137,10 @@ function recordDualAuthFailure( httpStatus, metadata: buildApiClientRequestMetadata(req, { bodyKeys: ["partnerId"] }), operation: "auth_dual", - partnerId: req.authenticatedPartner?.id || null, + partnerId: req.credential?.partnerId || null, partnerName: req.authenticatedPartner?.name || null, requestId: req.requestId, status: "failure", - userId: req.userId || req.apiKeyUserId || null + userId: req.userId || req.credential?.profileId || null }); } diff --git a/apps/api/src/api/middlewares/effectiveUser.test.ts b/apps/api/src/api/middlewares/effectiveUser.test.ts index fc666eb86..3f47a95ef 100644 --- a/apps/api/src/api/middlewares/effectiveUser.test.ts +++ b/apps/api/src/api/middlewares/effectiveUser.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from "bun:test"; -import {getEffectiveUserId, setApiKeyUserId} from "./effectiveUser"; +import {getEffectiveUserId} from "./effectiveUser"; function fakeReq({userId, apiKeyUserId}: {userId?: string; apiKeyUserId?: string} = {}): { userId?: string; @@ -16,37 +16,28 @@ function fakeReq({userId, apiKeyUserId}: {userId?: string; apiKeyUserId?: string } describe("getEffectiveUserId", () => { - it("prefers req.userId (Supabase) over req.apiKeyUserId", () => { - expect(getEffectiveUserId(fakeReq({userId: "supabase-user", apiKeyUserId: "key-user"}))).toBe( - "supabase-user" - ); + it("prefers req.userId (Supabase) over the credential subject", () => { + expect( + getEffectiveUserId({ + credential: { credentialId: "credential-1", environment: "test", partnerId: null, profileId: "key-user", strength: "secret" }, + userId: "supabase-user" + } as never) + ).toBe("supabase-user"); }); - it("falls back to req.apiKeyUserId when no Supabase user", () => { - expect(getEffectiveUserId(fakeReq({apiKeyUserId: "key-user"}))).toBe("key-user"); + it("uses the credential subject when no Supabase user is present", () => { + expect( + getEffectiveUserId({ + credential: { credentialId: "credential-1", environment: "test", partnerId: null, profileId: "key-user", strength: "secret" } + } as never) + ).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(); + it("ignores the legacy API key user field", () => { + expect(getEffectiveUserId(fakeReq({ apiKeyUserId: "legacy-user" }) as never)).toBeUndefined(); }); }); diff --git a/apps/api/src/api/middlewares/effectiveUser.ts b/apps/api/src/api/middlewares/effectiveUser.ts index eaab99101..c609ddd51 100644 --- a/apps/api/src/api/middlewares/effectiveUser.ts +++ b/apps/api/src/api/middlewares/effectiveUser.ts @@ -1,46 +1,17 @@ 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; +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. + * canonical profile resolved during API credential validation. 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; - } + return req.userId ?? req.credential?.profileId; } export type EffectiveUserRequest = Request; diff --git a/apps/api/src/api/middlewares/maintenanceGuard.ts b/apps/api/src/api/middlewares/maintenanceGuard.ts index 3c1d8422d..8ffbb85da 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.ts @@ -6,6 +6,7 @@ import { classifyApiClientError, getErrorMessage } from "../observability/errorC import { getRequestDurationMs } from "../observability/requestContext"; import type { ApiClientOperation } from "../observability/types"; import { MaintenanceService } from "../services/maintenance.service"; +import { getEffectiveUserId } from "./effectiveUser"; const MAINTENANCE_PROBLEM_TYPE = "https://api.vortexfinance.co/problems/maintenance-window"; const BLOCKED_OPERATIONS = [ @@ -71,7 +72,7 @@ function observeMaintenanceDenial( } ): void { const body = getRequestBody(req); - const publicApiKey = getString(body.apiKey) || req.validatedPublicKey?.apiKey; + const publicApiKey = getString(body.apiKey); const secretApiKey = getHeaderValue(req.headers?.["x-api-key"]); observeApiClientEvent({ @@ -86,15 +87,15 @@ function observeMaintenanceDenial( maintenance_title: maintenanceDetails.title }, operation, - partnerId: req.authenticatedPartner?.id || getString(body.partnerId), - partnerName: req.authenticatedPartner?.name || req.validatedPublicKey?.partnerName || null, + partnerId: req.credential?.partnerId || null, + partnerName: req.authenticatedPartner?.name || null, paymentMethod: getString(body.paymentMethod), quoteId: getString(body.quoteId), rampId: getString(body.rampId), rampType: getString(body.rampType), requestId: req.requestId, status: "failure", - userId: req.userId || null + userId: getEffectiveUserId(req) || null }); } diff --git a/apps/api/src/api/middlewares/ownershipAuth.test.ts b/apps/api/src/api/middlewares/ownershipAuth.test.ts index 24f109265..43910d46f 100644 --- a/apps/api/src/api/middlewares/ownershipAuth.test.ts +++ b/apps/api/src/api/middlewares/ownershipAuth.test.ts @@ -1,16 +1,13 @@ import {afterEach, describe, expect, it, mock} from "bun:test"; -import Partner from "../../models/partner.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import {assertQuoteOwnership, assertRampOwnership} from "./ownershipAuth"; describe("assertQuoteOwnership", () => { const originalFindByPk = QuoteTicket.findByPk; - const originalPartnerFindByPk = Partner.findByPk; afterEach(() => { QuoteTicket.findByPk = originalFindByPk; - Partner.findByPk = originalPartnerFindByPk; }); it("rejects a Supabase user registering another user's quote", async () => { @@ -53,22 +50,85 @@ describe("assertQuoteOwnership", () => { await expect(assertQuoteOwnership({ userId: "user-1" }, "quote-1")).resolves.toBeUndefined(); }); - it("allows partner API keys to access quotes owned by another active partner row with the same name", async () => { + it("rejects a credential whose canonical partner ID does not own the 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({ authenticatedPartner: { id: "api-key-partner-id", name: "Partner" } }, "quote-1") + assertQuoteOwnership( + { + credential: { + credentialId: "credential-1", + environment: "test", + partnerId: "api-key-partner-id", + profileId: "profile-1", + strength: "secret" + }, + authenticatedPartner: { id: "api-key-partner-id", name: "Partner" } + } as never, + "quote-1" + ) + ).rejects.toThrow("Authenticated partner does not own this quote"); + }); + + it("rejects secret credential B from registering a quote created with public credential A", async () => { + QuoteTicket.findByPk = mock(async () => ({ + apiCredentialId: "credential-a", + partnerId: "partner-id", + userId: "profile-id" + })) as typeof QuoteTicket.findByPk; + + await expect( + assertQuoteOwnership( + { + credential: { + credentialId: "credential-b", + environment: "test", + partnerId: "partner-id", + profileId: "profile-id", + strength: "secret" + } + }, + "quote-1" + ) + ).rejects.toThrow("Secret credential does not match the credential used to create this quote"); + }); + + it("allows secret credential A to register a quote created with public credential A", async () => { + QuoteTicket.findByPk = mock(async () => ({ + apiCredentialId: "credential-a", + partnerId: "partner-id", + userId: "profile-id" + })) as typeof QuoteTicket.findByPk; + + await expect( + assertQuoteOwnership( + { + credential: { + credentialId: "credential-a", + environment: "test", + partnerId: "partner-id", + profileId: "profile-id", + strength: "secret" + } + }, + "quote-1" + ) ).resolves.toBeUndefined(); }); + it("allows the owning Supabase profile regardless of the quote credential ID", async () => { + QuoteTicket.findByPk = mock(async () => ({ + apiCredentialId: "credential-a", + partnerId: null, + userId: "profile-id" + })) as typeof QuoteTicket.findByPk; + + await expect(assertQuoteOwnership({ userId: "profile-id" }, "quote-1")).resolves.toBeUndefined(); + }); + it("allows an anonymous caller to register a fully-anonymous quote", async () => { QuoteTicket.findByPk = mock(async () => ({ partnerId: null, @@ -101,21 +161,23 @@ describe("assertQuoteOwnership", () => { 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( { + credential: { + credentialId: "credential-1", + environment: "test", + partnerId: "quote-partner-id", + profileId: "attacker-user", + strength: "secret" + }, apiKeyUserId: "attacker-user", authenticatedPartner: {id: "api-key-partner-id", name: "Partner"} - }, + } as never, "quote-1" ) - ).rejects.toThrow("Authenticated API key user does not own this quote"); + ).rejects.toThrow("Authenticated profile does not own this quote"); }); it("allows a linked API key to operate on its own user's provider-bound quote", async () => { @@ -123,39 +185,64 @@ describe("assertQuoteOwnership", () => { 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", + credential: { + credentialId: "credential-1", + environment: "test", + partnerId: "quote-partner-id", + profileId: "user-1", + strength: "secret" + }, + apiKeyUserId: "stale-user", authenticatedPartner: {id: "api-key-partner-id", name: "Partner"} - }, + } as never, "quote-1" ) ).resolves.toBeUndefined(); }); - it("allows an unlinked partner key to operate on a partner-owned anonymous-user quote", async () => { + it("allows a canonical partner credential 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, + credential: { + credentialId: "credential-1", + environment: "test", + partnerId: "quote-partner-id", + profileId: "profile-1", + strength: "secret" + }, authenticatedPartner: {id: "api-key-partner-id", name: "Partner"} + } as never, + "quote-1" + ) + ).resolves.toBeUndefined(); + }); + + it("prefers the Supabase profile over the credential profile for linked quote ownership", async () => { + QuoteTicket.findByPk = mock(async () => ({ + partnerId: "quote-partner-id", + userId: "supabase-user" + })) as typeof QuoteTicket.findByPk; + + await expect( + assertQuoteOwnership( + { + credential: { + credentialId: "credential-1", + environment: "test", + partnerId: "quote-partner-id", + profileId: "credential-profile", + strength: "secret" + }, + userId: "supabase-user" }, "quote-1" ) diff --git a/apps/api/src/api/middlewares/ownershipAuth.ts b/apps/api/src/api/middlewares/ownershipAuth.ts index 826ede689..ec38fb144 100644 --- a/apps/api/src/api/middlewares/ownershipAuth.ts +++ b/apps/api/src/api/middlewares/ownershipAuth.ts @@ -1,16 +1,14 @@ import httpStatus from "http-status"; -import Partner from "../../models/partner.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; 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 type { CredentialContext } from "../services/apiCredential.service"; import { getEffectiveUserId } from "./effectiveUser"; interface OwnershipRequest { - authenticatedPartner?: AuthenticatedPartner; - apiKeyUserId?: string; + credential?: CredentialContext; body?: unknown; method?: string; params?: unknown; @@ -21,18 +19,6 @@ interface OwnershipRequest { userId?: string; } -async function ownsPartnerRecord(authenticatedPartner: AuthenticatedPartner, partnerId: string | null): Promise { - if (!partnerId) { - return false; - } - - const quotePartner = await Partner.findByPk(partnerId); - if (!quotePartner?.isActive) { - return false; - } - return partnerId === authenticatedPartner.id || quotePartner.name === authenticatedPartner.name; -} - /** * Verify the authenticated principal owns the ramp identified by req.params.id * or req.body.rampId. Partner principals must match the quote's partnerId; @@ -45,13 +31,13 @@ export async function assertRampOwnership(req: OwnershipRequest, rampId: string) throw new APIError({ message: "Ramp not found", status: httpStatus.NOT_FOUND }); } - if (req.authenticatedPartner) { + if (req.credential?.partnerId) { const quote = await QuoteTicket.findByPk(ramp.quoteId); if (!quote) { recordOwnershipFailure(req, httpStatus.NOT_FOUND, "quote_not_found", { quoteId: ramp.quoteId, rampId }); throw new APIError({ message: "Associated quote not found", status: httpStatus.NOT_FOUND }); } - if (!(await ownsPartnerRecord(req.authenticatedPartner, quote.partnerId))) { + if (quote.partnerId !== req.credential.partnerId) { recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId: ramp.quoteId, rampId }); throw new APIError({ message: "Authenticated partner does not own this ramp", @@ -61,10 +47,11 @@ export async function assertRampOwnership(req: OwnershipRequest, rampId: string) // 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) { + const profileId = getEffectiveUserId(req); + if (profileId && quote.userId && quote.userId !== profileId) { recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId: ramp.quoteId, rampId }); throw new APIError({ - message: "Authenticated API key user does not own this ramp", + message: "Authenticated profile does not own this ramp", status: httpStatus.FORBIDDEN }); } @@ -114,8 +101,16 @@ export async function assertQuoteOwnership(req: OwnershipRequest, quoteId: strin throw new APIError({ message: "Quote not found", status: httpStatus.NOT_FOUND }); } - if (req.authenticatedPartner) { - if (!(await ownsPartnerRecord(req.authenticatedPartner, quote.partnerId))) { + if (quote.apiCredentialId && req.credential?.strength === "secret" && quote.apiCredentialId !== req.credential.credentialId) { + recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId }); + throw new APIError({ + message: "Secret credential does not match the credential used to create this quote", + status: httpStatus.FORBIDDEN + }); + } + + if (req.credential?.partnerId) { + if (quote.partnerId !== req.credential.partnerId) { recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId }); throw new APIError({ message: "Authenticated partner does not own this quote", @@ -125,10 +120,11 @@ export async function assertQuoteOwnership(req: OwnershipRequest, quoteId: strin // 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) { + const profileId = getEffectiveUserId(req); + if (profileId && quote.userId && quote.userId !== profileId) { recordOwnershipFailure(req, httpStatus.FORBIDDEN, "ownership_denied", { quoteId }); throw new APIError({ - message: "Authenticated API key user does not own this quote", + message: "Authenticated profile does not own this quote", status: httpStatus.FORBIDDEN }); } @@ -178,8 +174,7 @@ function recordOwnershipFailure( httpStatus: status, metadata: buildApiClientRequestMetadata(req, { bodyKeys: ["quoteId", "rampId"], paramKeys: ["id"] }), operation: "auth_ownership", - partnerId: req.authenticatedPartner?.id || null, - partnerName: req.authenticatedPartner?.name || null, + partnerId: req.credential?.partnerId || null, requestId: req.requestId, status: "failure", userId: getEffectiveUserId(req) || null diff --git a/apps/api/src/api/middlewares/publicKeyAuth.test.ts b/apps/api/src/api/middlewares/publicKeyAuth.test.ts new file mode 100644 index 000000000..6a0906765 --- /dev/null +++ b/apps/api/src/api/middlewares/publicKeyAuth.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import ApiCredential from "../../models/apiCredential.model"; +import Partner from "../../models/partner.model"; +import { digestApiKey, generateApiKey, getSecretKeyLookupPrefix } from "./apiKeyFormat"; +import { apiKeyAuth, enforcePartnerAuth } from "./apiKeyAuth"; +import { optionalPartnerOrUserAuth } from "./dualAuth"; +import { validatePublicKey } from "./publicKeyAuth"; + +const originalFindAll = ApiCredential.findAll; +const originalFindOne = ApiCredential.findOne; +const originalPartnerFindOne = Partner.findOne; + +afterEach(() => { + ApiCredential.findAll = originalFindAll; + ApiCredential.findOne = originalFindOne; + Partner.findOne = originalPartnerFindOne; +}); + +function responseDouble() { + const response = { + body: undefined as unknown, + json: mock((body: unknown) => { + response.body = body; + return response; + }), + status: mock(() => response) + }; + return response; +} + +describe("validatePublicKey", () => { + it("rejects a body/header public key mismatch before continuing", async () => { + const next = mock(() => undefined); + const response = responseDouble(); + await validatePublicKey()( + { + body: { apiKey: "pk_test_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }, + headers: { "x-public-key": "pk_test_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" }, + query: {} + } as never, + response as never, + next + ); + + expect(response.status).toHaveBeenCalledWith(403); + expect((response.body as { error: { code: string } }).error.code).toBe("CREDENTIAL_MISMATCH"); + expect(next).not.toHaveBeenCalled(); + }); + + it("rejects public and secret values from different credentials", async () => { + const publicKey = generateApiKey("public", "test"); + const secretKey = generateApiKey("secret", "test"); + const publicCredential = Object.assign(new ApiCredential(), { + environment: "test", + id: "public-credential", + partnerId: null, + profileId: "profile-1", + publicKeyValue: publicKey, + update: mock(async () => publicCredential) + }); + const secretCredential = Object.assign(new ApiCredential(), { + environment: "test", + id: "secret-credential", + partnerId: null, + profileId: "profile-1", + secretKeyDigest: digestApiKey(secretKey), + secretKeyPrefix: getSecretKeyLookupPrefix(secretKey), + update: mock(async () => secretCredential) + }); + ApiCredential.findOne = mock(async () => publicCredential) as never; + ApiCredential.findAll = mock(async () => [secretCredential]) as never; + + const request = { body: {}, headers: { "x-api-key": secretKey, "x-public-key": publicKey }, query: {} }; + const publicResponse = responseDouble(); + await validatePublicKey()(request as never, publicResponse as never, mock(() => undefined)); + + const secretResponse = responseDouble(); + const next = mock(() => undefined); + await apiKeyAuth()(request as never, secretResponse as never, next); + + expect(secretResponse.status).toHaveBeenCalledWith(403); + expect((secretResponse.body as { error: { code: string } }).error.code).toBe("CREDENTIAL_MISMATCH"); + expect(next).not.toHaveBeenCalled(); + + const dualResponse = responseDouble(); + const dualNext = mock(() => undefined); + await optionalPartnerOrUserAuth()( + { body: {}, headers: { "x-api-key": secretKey, "x-public-key": publicKey } } as never, + dualResponse as never, + dualNext + ); + + expect(dualResponse.status).toHaveBeenCalledWith(403); + expect((dualResponse.body as { error: { code: string } }).error.code).toBe("CREDENTIAL_MISMATCH"); + expect(dualNext).not.toHaveBeenCalled(); + }); +}); + +describe("enforcePartnerAuth", () => { + it("rejects a same-name partner whose canonical ID differs from the credential", async () => { + Partner.findOne = mock(async () => ({ id: "requested-partner-id", name: "Partner" }) as Partner) as typeof Partner.findOne; + const response = responseDouble(); + const next = mock(() => undefined); + + await enforcePartnerAuth()( + { + authenticatedPartner: { id: "credential-partner-id", name: "Partner" }, + body: { partnerId: "Partner" }, + credential: { + credentialId: "credential-1", + environment: "test", + partnerId: "credential-partner-id", + profileId: "profile-1", + strength: "secret" + } + } as never, + response as never, + next + ); + + expect(response.status).toHaveBeenCalledWith(403); + expect((response.body as { error: { code: string } }).error.code).toBe("PARTNER_MISMATCH"); + expect(next).not.toHaveBeenCalled(); + }); + + it("does not authorize from the compatibility partner field", async () => { + const response = responseDouble(); + const next = mock(() => undefined); + + await enforcePartnerAuth()( + { authenticatedPartner: { id: "partner-1", name: "Partner" }, body: { partnerId: "Partner" } } as never, + response as never, + next + ); + + expect(response.status).toHaveBeenCalledWith(403); + expect((response.body as { error: { code: string } }).error.code).toBe("AUTHENTICATION_REQUIRED"); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/middlewares/publicKeyAuth.ts b/apps/api/src/api/middlewares/publicKeyAuth.ts index 4dbae016e..d5b149d8b 100644 --- a/apps/api/src/api/middlewares/publicKeyAuth.ts +++ b/apps/api/src/api/middlewares/publicKeyAuth.ts @@ -6,6 +6,7 @@ import { observeApiClientEvent } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; +import { CredentialContext } from "../services/apiCredential.service"; import { getKeyType, isValidApiKeyFormat, validatePublicApiKey } from "./apiKeyAuth.helpers"; // Extend Express Request type to include validated public key @@ -13,9 +14,9 @@ declare global { // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. namespace Express { interface Request { + credential?: CredentialContext; validatedPublicKey?: { apiKey: string; - partnerName: string | null; }; } } @@ -30,8 +31,14 @@ declare global { export function validatePublicKey() { return async (req: Request, res: Response, next: NextFunction) => { try { - // Check for apiKey in query params or body - const apiKey = (req.query.apiKey as string) || req.body?.apiKey; + const headerKey = req.headers["x-public-key"] as string | undefined; + const legacyKey = (req.query.apiKey as string | undefined) || req.body?.apiKey; + if (headerKey && legacyKey && headerKey !== legacyKey) { + return res.status(403).json({ + error: { code: "CREDENTIAL_MISMATCH", message: "Public credential values do not match", status: 403 } + }); + } + const apiKey = headerKey || legacyKey; // If no API key provided, continue without validation if (!apiKey) { @@ -79,9 +86,9 @@ export function validatePublicKey() { // Attach validated public key info to request req.validatedPublicKey = { - apiKey, - partnerName: result.partnerName + apiKey }; + req.credential = result.credential; next(); } catch (error) { @@ -101,6 +108,6 @@ function recordPublicKeyFailure(req: Request, httpStatus: number, apiKeyPrefix: operation: "auth_public_key", requestId: req.requestId, status: "failure", - userId: req.userId || req.apiKeyUserId || null + userId: req.userId || req.credential?.profileId || null }); } diff --git a/apps/api/src/api/middlewares/supabaseAuth.test.ts b/apps/api/src/api/middlewares/supabaseAuth.test.ts new file mode 100644 index 000000000..4a349d330 --- /dev/null +++ b/apps/api/src/api/middlewares/supabaseAuth.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import type { NextFunction, Request, Response } from "express"; +import { AccessTokenVerificationError, SupabaseAuthService } from "../services/auth"; +import { optionalAuth, requireAuth } from "./supabaseAuth"; + +function request(authorization?: string): Request { + return { + headers: authorization === undefined ? {} : { authorization }, + path: "/v1/quote" + } as Request; +} + +function response(): Response & { json: ReturnType; status: ReturnType } { + const res = {} as Response & { json: ReturnType; status: ReturnType }; + res.json = mock(() => res); + res.status = mock(() => res); + return res; +} + +afterEach(() => { + mock.restore(); +}); + +describe("Supabase authentication middleware", () => { + it("continues anonymously only when optional auth receives no credential", async () => { + const verify = spyOn(SupabaseAuthService, "verifyToken"); + const req = request(); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await optionalAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(verify).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects a present malformed or invalid optional credential with 401", async () => { + const verify = spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ valid: false }); + + for (const authorization of ["Basic value", "Bearer ", "Bearer invalid"]) { + const res = response(); + const next = mock(() => undefined) as NextFunction; + await optionalAuth(request(authorization), res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + } + expect(verify).toHaveBeenCalledTimes(1); + }); + + it("returns 503 without anonymous fallback when verification is indeterminate", async () => { + spyOn(SupabaseAuthService, "verifyToken").mockRejectedValue( + new AccessTokenVerificationError("provider unavailable", true) + ); + const res = response(); + const next = mock(() => undefined) as NextFunction; + + await optionalAuth(request("Bearer valid-looking"), res, next); + + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + }); + + it("attaches a valid optional identity and applies the same outage distinction to required auth", async () => { + const verify = spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ + email: "user@example.com", + user_id: "user-1", + valid: true + }); + const optionalRequest = request("Bearer valid"); + const optionalResponse = response(); + const optionalNext = mock(() => undefined) as NextFunction; + + await optionalAuth(optionalRequest, optionalResponse, optionalNext); + + expect(optionalRequest.userId).toBe("user-1"); + expect(optionalRequest.userEmail).toBe("user@example.com"); + expect(optionalNext).toHaveBeenCalledTimes(1); + + verify.mockRejectedValueOnce(new AccessTokenVerificationError("provider unavailable", true)); + const requiredResponse = response(); + await requireAuth(request("Bearer valid"), requiredResponse, mock(() => undefined) as NextFunction); + expect(requiredResponse.status).toHaveBeenCalledWith(503); + }); +}); diff --git a/apps/api/src/api/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts index c98c48175..5b629546f 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; -import { SupabaseAuthService } from "../services/auth"; +import { AccessTokenVerificationError, SupabaseAuthService } from "../services/auth"; declare global { // biome-ignore lint/style/noNamespace: Express request augmentation follows the existing backend pattern. @@ -38,9 +38,10 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio req.userEmail = result.email; next(); } catch (error) { - logger.error("Auth middleware error:", error); - return res.status(401).json({ - error: "Authentication failed" + const unavailable = error instanceof AccessTokenVerificationError && error.transient; + logVerificationFailure(req, unavailable ? "provider_unavailable" : "verification_error", error); + return res.status(unavailable ? 503 : 401).json({ + error: unavailable ? "Authentication service unavailable" : "Authentication failed" }); } } @@ -49,31 +50,37 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio * Optional auth - attaches userId if token present */ export async function optionalAuth(req: Request, res: Response, next: NextFunction) { - try { - const authHeader = req.headers.authorization; - - if (authHeader?.startsWith("Bearer ")) { - const token = authHeader.substring(7); - const result = await SupabaseAuthService.verifyToken(token); + const authHeader = req.headers.authorization; + if (authHeader === undefined) { + next(); + return; + } + if (!authHeader.startsWith("Bearer ") || authHeader.length <= 7) { + return res.status(401).json({ error: "Missing or invalid authorization header" }); + } - if (result.valid) { - req.userId = result.user_id; - } + try { + const result = await SupabaseAuthService.verifyToken(authHeader.substring(7)); + if (!result.valid) { + return res.status(401).json({ error: "Invalid or expired token" }); } - + req.userId = result.user_id; + req.userEmail = result.email; next(); } catch (error) { - // Log truncated token for security - only show first/last few characters - const authHeader = req.headers.authorization; - const truncatedAuth = authHeader - ? `${authHeader.substring(0, 15)}...${authHeader.substring(authHeader.length - 4)}` - : undefined; - - logger.warn("optionalAuth middleware: authentication error", { - authorization: truncatedAuth, - error, - path: req.path + const unavailable = error instanceof AccessTokenVerificationError && error.transient; + logVerificationFailure(req, unavailable ? "provider_unavailable" : "verification_error", error); + return res.status(unavailable ? 503 : 401).json({ + error: unavailable ? "Authentication service unavailable" : "Authentication failed" }); - next(); } } + +function logVerificationFailure(req: Request, category: string, error: unknown): void { + logger.warn("Supabase access-token verification failed", { + category, + error: error instanceof Error ? error.message : String(error), + path: req.path, + requestId: req.headers["x-request-id"] + }); +} diff --git a/apps/api/src/api/routes/v1/admin/managed-profiles.route.ts b/apps/api/src/api/routes/v1/admin/managed-profiles.route.ts new file mode 100644 index 000000000..42c0301f9 --- /dev/null +++ b/apps/api/src/api/routes/v1/admin/managed-profiles.route.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { postManagedProfile } from "../../../controllers/admin/managedProfiles.controller"; +import { adminAuth } from "../../../middlewares/adminAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(adminAuth); +router.post("/", postManagedProfile); + +export default router; diff --git a/apps/api/src/api/routes/v1/admin/partner-api-keys.route.ts b/apps/api/src/api/routes/v1/admin/partner-api-keys.route.ts index 74bcccd5d..47b94d7a2 100644 --- a/apps/api/src/api/routes/v1/admin/partner-api-keys.route.ts +++ b/apps/api/src/api/routes/v1/admin/partner-api-keys.route.ts @@ -8,7 +8,7 @@ const router: Router = Router({ mergeParams: true }); router.use(adminAuth); /** - * POST /v1/admin/partners/:partnerName/api-keys + * POST /v1/admin/partners/:partnerName/api-credentials * Create a new API key for a partner (by name) * * This will create a key that works for ALL partner records with the same name @@ -25,7 +25,7 @@ router.use(adminAuth); router.post("/", createApiKey); /** - * GET /v1/admin/partners/:partnerName/api-keys + * GET /v1/admin/partners/:partnerName/api-credentials * List all API keys for a partner (by name) * * Authentication: Requires Authorization: Bearer @@ -33,11 +33,11 @@ router.post("/", createApiKey); router.get("/", listApiKeys); /** - * DELETE /v1/admin/partners/:partnerName/api-keys/:keyId + * DELETE /v1/admin/partners/:partnerName/api-credentials/:credentialId * Revoke (soft delete) an API key * * Authentication: Requires Authorization: Bearer */ -router.delete("/:keyId", revokeApiKey); +router.delete("/:credentialId", revokeApiKey); export default router; diff --git a/apps/api/src/api/routes/v1/api-credentials.route.ts b/apps/api/src/api/routes/v1/api-credentials.route.ts new file mode 100644 index 000000000..280d36e04 --- /dev/null +++ b/apps/api/src/api/routes/v1/api-credentials.route.ts @@ -0,0 +1,11 @@ +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); +router.post("/", createUserApiKey as unknown as (req: Request, res: Response) => void); +router.get("/", listUserApiKeys as unknown as (req: Request, res: Response) => void); +router.delete("/:credentialId", revokeUserApiKey as unknown as (req: Request<{ credentialId: string }>, res: Response) => void); + +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 deleted file mode 100644 index 7cc2458e1..000000000 --- a/apps/api/src/api/routes/v1/api-keys.route.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/index.ts b/apps/api/src/api/routes/v1/index.ts index 781feeeba..edeba260c 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -2,12 +2,13 @@ import { Request, Response, Router } from "express"; import { sendStatusWithPk as sendMoonbeamStatusWithPk } from "../../controllers/moonbeam.controller"; import { sendStatusWithPk as sendPendulumStatusWithPk } from "../../controllers/pendulum.controller"; import apiClientEventsRoutes from "./admin/api-client-events.route"; +import managedProfilesRoutes from "./admin/managed-profiles.route"; import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; import partnerPricingConfigsRoutes from "./admin/partner-pricing-configs.route"; import profilePartnerAssignmentsRoutes from "./admin/profile-partner-assignments.route"; import profileRolesRoutes from "./admin/profile-roles.route"; import alfredpayRoutes from "./alfredpay.route"; -import apiKeysRoutes from "./api-keys.route"; +import apiCredentialsRoutes from "./api-credentials.route"; import authRoutes from "./auth.route"; import brlaRoutes from "./brla.route"; import contactRoutes from "./contact.route"; @@ -15,6 +16,7 @@ import countriesRoutes from "./countries.route"; import cryptocurrenciesRoutes from "./cryptocurrencies.route"; import emailRoutes from "./email.route"; import fiatRoutes from "./fiat.route"; +import limitsRoutes from "./limits.route"; import maintenanceRoutes from "./maintenance.route"; import metricsRoutes from "./metrics.route"; import moneriumRoutes from "./monerium.route"; @@ -26,6 +28,7 @@ import priceRoutes from "./price.route"; import publicKeyRoutes from "./public-key.route"; import quoteRoutes from "./quote.route"; import rampRoutes from "./ramp.route"; +import rampInfoRoutes from "./ramp-info.route"; import ratingRoutes from "./rating.route"; import recipientsRoutes from "./recipients.route"; import sessionRoutes from "./session.route"; @@ -110,6 +113,12 @@ router.use("/brla", brlaRoutes); * GET/POST v1/ramp */ router.use("/ramp", rampRoutes); +router.use("/ramp-info", rampInfoRoutes); + +/** + * POST v1/limits + */ +router.use("/limits", limitsRoutes); /** * GET v1/supported-payment-methods @@ -204,24 +213,17 @@ router.use("/notifications", notificationsRoutes); */ router.use("/onboarding", onboardingRoutes); -/** - * 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); +/** One-record API credential management for authenticated Supabase users. */ +router.use("/api-credentials", apiCredentialsRoutes); /** * Admin routes for partner API key management * Uses partner name (not ID) to manage keys for all partner configurations - * POST /v1/admin/partners/:partnerName/api-keys - * GET /v1/admin/partners/:partnerName/api-keys - * DELETE /v1/admin/partners/:partnerName/api-keys/:keyId + * POST /v1/admin/partners/:partnerName/api-credentials + * GET /v1/admin/partners/:partnerName/api-credentials + * DELETE /v1/admin/partners/:partnerName/api-credentials/:credentialId */ -router.use("/admin/partners/:partnerName/api-keys", partnerApiKeysRoutes); +router.use("/admin/partners/:partnerName/api-credentials", partnerApiKeysRoutes); /** * Admin routes for profile partner pricing assignments @@ -245,6 +247,7 @@ router.use("/admin/partner-pricing-configs", partnerPricingConfigsRoutes); * DELETE /v1/admin/profile-roles/:userIdOrEmail/:role */ router.use("/admin/profile-roles", profileRolesRoutes); +router.use("/admin/managed-profiles", managedProfilesRoutes); /** * Admin routes for API client observability dashboards diff --git a/apps/api/src/api/routes/v1/limits.route.ts b/apps/api/src/api/routes/v1/limits.route.ts new file mode 100644 index 000000000..f4d0251cb --- /dev/null +++ b/apps/api/src/api/routes/v1/limits.route.ts @@ -0,0 +1,9 @@ +import { RequestHandler, Router } from "express"; +import { getLimits } from "../../controllers/limits.controller"; +import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.post("/", requirePartnerOrUserAuth(), getLimits as unknown as RequestHandler); + +export default router; diff --git a/apps/api/src/api/routes/v1/quote.route.ts b/apps/api/src/api/routes/v1/quote.route.ts index 9dc6d724a..0165343c0 100644 --- a/apps/api/src/api/routes/v1/quote.route.ts +++ b/apps/api/src/api/routes/v1/quote.route.ts @@ -46,10 +46,10 @@ router .route("/") .post( rejectDuringActiveMaintenance("quote_create"), - validateCreateQuoteInput, optionalAuth, validatePublicKey(), apiKeyAuth({ required: false }), + validateCreateQuoteInput, enforcePartnerAuth(), createQuote ); @@ -111,10 +111,10 @@ router .route("/best") .post( rejectDuringActiveMaintenance("quote_create_best"), - validateCreateBestQuoteInput, optionalAuth, validatePublicKey(), apiKeyAuth({ required: false }), + validateCreateBestQuoteInput, enforcePartnerAuth(), createBestQuote ); diff --git a/apps/api/src/api/routes/v1/ramp-info.route.ts b/apps/api/src/api/routes/v1/ramp-info.route.ts new file mode 100644 index 000000000..cebebe250 --- /dev/null +++ b/apps/api/src/api/routes/v1/ramp-info.route.ts @@ -0,0 +1,21 @@ +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { getRampInfo } from "../../controllers/rampInfo.controller"; +import { apiKeyAuth } from "../../middlewares/apiKeyAuth"; +import { validatePublicKey } from "../../middlewares/publicKeyAuth"; + +const router = Router({ mergeParams: true }); +const windowMs = 60_000; + +const ipLimiter = rateLimit({ legacyHeaders: false, max: 60, standardHeaders: true, windowMs }); +const credentialLimiter = rateLimit({ + keyGenerator: req => req.credential?.credentialId ?? req.ip ?? "missing-credential", + legacyHeaders: false, + max: 60, + standardHeaders: true, + windowMs +}); + +router.get("/", ipLimiter, validatePublicKey(), apiKeyAuth(), credentialLimiter, getRampInfo); + +export default router; diff --git a/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts index dd7019a20..f09944d6d 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts @@ -8,7 +8,8 @@ import { import logger from "../../../config/logger"; import KycCase from "../../../models/kycCase.model"; import ProviderCustomer, { ProviderCustomerType, VerificationStatus } from "../../../models/providerCustomer.model"; -import { getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; +import User from "../../../models/user.model"; +import { findCustomerEntityIdsForProfile, getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; export function alfredpayTypeToCustomerType(type: AlfredpayCustomerType): ProviderCustomerType { return type === AlfredpayCustomerType.BUSINESS ? "business" : "individual"; @@ -181,18 +182,30 @@ function toView(record: ProviderCustomer): AlfredpayCustomerView { /** * Latest alfredpay account for (user, country[, type]) — reproduces the legacy * updatedAt-DESC tie-break across a user's individual/business rows. + * + * Typed lookups scan every entity the profile owns: migration 040 attached legacy + * business rows to the profile's individual entity, so scoping to the same-typed entity + * made every migrated business customer invisible to the KYB endpoints (and findOrCreate'd + * an empty business entity as a side effect of a read). The row's customer_type is + * authoritative; the owning entity's type is not. Type-less lookups keep resolving the + * active entity — that is the quote/ramp account context and must not widen. */ export async function findAlfredpayCustomer( userId: string, country: AlfredPayCountry, type?: AlfredpayCustomerType ): Promise { - const entity = await getOrCreateCustomerEntityForProfile(userId, type ? alfredpayTypeToCustomerType(type) : undefined); + const entityIds = type + ? await findCustomerEntityIdsForProfile(userId) + : [(await getOrCreateCustomerEntityForProfile(userId)).id]; + if (entityIds.length === 0) { + return null; + } const record = await ProviderCustomer.findOne({ order: [["updatedAt", "DESC"]], where: { country, - customerEntityId: entity.id, + customerEntityId: entityIds, provider: "alfredpay", ...(type ? { customerType: alfredpayTypeToCustomerType(type) } : {}) } @@ -300,10 +313,25 @@ export async function createAlfredpayCustomer( values: { alfredPayId: string; country: AlfredPayCountry; status: AlfredPayStatus; type: AlfredpayCustomerType } ): Promise { const customerType = alfredpayTypeToCustomerType(values.type); - const entity = await getOrCreateCustomerEntityForProfile(userId, customerType); + // Keep a profile's rows of one customer_type on a single entity, preferring the entity + // quote/ramp resolution actually reads — the active one. Legacy business rows live on the + // (active) individual entity, and a profile hit by the pre-fix duplicate bug can also + // carry a newer same-type row on a stray business entity; homing the new corridor there + // (or on the typed entity) would make it unrampable for migrated profiles. + const entityIds = await findCustomerEntityIdsForProfile(userId); + const siblings = + entityIds.length > 0 + ? await ProviderCustomer.findAll({ + order: [["updatedAt", "DESC"]], + where: { customerEntityId: entityIds, customerType, provider: "alfredpay" } + }) + : []; + const activeEntityId = siblings.length > 0 ? (await User.findByPk(userId))?.activeCustomerEntityId : null; + const sibling = siblings.find(row => row.customerEntityId === activeEntityId) ?? siblings[0]; + const customerEntityId = sibling?.customerEntityId ?? (await getOrCreateCustomerEntityForProfile(userId, customerType)).id; const record = await ProviderCustomer.create({ country: values.country, - customerEntityId: entity.id, + customerEntityId, customerType, provider: "alfredpay", providerCustomerId: values.alfredPayId, diff --git a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts index 389bc346e..46f72a4a3 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts @@ -63,4 +63,29 @@ describe("AlfredpayLimitsService.refresh", () => { const limits = service.getLimits(FiatToken.MXN, "USDC", AlfredpayCustomerType.INDIVIDUAL, RampDirection.BUY); expect(limits).toEqual({ maxRaw: "17079999", minRaw: "5000" }); }); + + test("indexes ARS rows from the provider configuration", async () => { + AlfredpayApiService.getInstance = () => + ({ + getAllConfigs: async () => ({ + supportedPairs: [ + pair({ + fromCurrency: "ARS", + maxQuantity: "250000", + minQuantity: "1000", + toCurrency: "USDT", + typeCustomer: AlfredpayCustomerType.INDIVIDUAL + }) + ] + }) + }) as unknown as AlfredpayApiService; + + const service = new (AlfredpayLimitsService as unknown as { new (): AlfredpayLimitsService })(); + await (service as unknown as { refresh(): Promise }).refresh(); + + expect(service.getLimits(FiatToken.ARS, "USDT", AlfredpayCustomerType.INDIVIDUAL, RampDirection.BUY)).toEqual({ + maxRaw: "25000000", + minRaw: "100000" + }); + }); }); diff --git a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts index e952b0ce7..8fb5e0a6b 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts @@ -17,6 +17,7 @@ const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; const CUSTOMER_TYPES: AlfredpayCustomerType[] = [AlfredpayCustomerType.INDIVIDUAL, AlfredpayCustomerType.BUSINESS]; const ALFREDPAY_FIATS: Record = { + ARS: FiatToken.ARS, COP: FiatToken.COP, MXN: FiatToken.MXN, USD: FiatToken.USD diff --git a/apps/api/src/api/services/alfredpay/alfredpay.helpers.test.ts b/apps/api/src/api/services/alfredpay/alfredpay.helpers.test.ts new file mode 100644 index 000000000..740cc7dec --- /dev/null +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { AlfredpayCustomerType, EvmToken, FiatToken, RampDirection } from "@vortexfi/shared"; +import { Op } from "sequelize"; +import RampState from "../../../models/rampState.model"; +import { + clearAlfredpayMonthlyUsageCache, + getAlfredpayMonthlyUsage, + resolveAlfredpayQuoteLimits +} from "./alfredpay.helpers"; + +describe("resolveAlfredpayQuoteLimits", () => { + it("uses the AlfredPay settlement token for a routed onramp", async () => { + const limits = await resolveAlfredpayQuoteLimits({ + inputCurrency: FiatToken.MXN, + outputCurrency: EvmToken.ETH, + rampType: RampDirection.BUY + }); + + expect(limits).toMatchObject({ + customer: AlfredpayCustomerType.INDIVIDUAL, + fiat: FiatToken.MXN, + stablecoin: EvmToken.USDT + }); + }); +}); + +describe("getAlfredpayMonthlyUsage", () => { + const originalFindAll = RampState.findAll; + + beforeEach(() => clearAlfredpayMonthlyUsageCache()); + + afterEach(() => { + RampState.findAll = originalFindAll; + clearAlfredpayMonthlyUsageCache(); + }); + + it("counts routed provider-leg amounts and caches the monthly aggregate", async () => { + let query: { where: { [Op.and]: { val: string }; createdAt?: unknown } } | undefined; + const findAll = mock(async () => [ + { + quote: { + inputAmount: "999", + inputCurrency: FiatToken.MXN, + metadata: { blocks: { alfredpayMint: { currency: FiatToken.MXN, inputAmountDecimal: "125.5" } } }, + outputCurrency: EvmToken.ETH + }, + type: RampDirection.BUY + }, + { + quote: { + inputAmount: "2", + inputCurrency: EvmToken.ETH, + metadata: { + blocks: { + alfredpayOfframp: { + currency: FiatToken.MXN, + inputAmountDecimal: "40.25", + token: EvmToken.USDT + } + } + }, + outputCurrency: FiatToken.MXN + }, + type: RampDirection.SELL + } + ]); + RampState.findAll = mock(async options => { + query = options as typeof query; + return findAll(); + }) as unknown as typeof RampState.findAll; + + expect((await getAlfredpayMonthlyUsage("user-1", RampDirection.BUY, FiatToken.MXN, "USDT")).toFixed()).toBe( + "125.5" + ); + expect((await getAlfredpayMonthlyUsage("user-1", RampDirection.SELL, FiatToken.MXN, "USDT")).toFixed()).toBe( + "40.25" + ); + expect(findAll).toHaveBeenCalledTimes(1); + expect(query?.where.createdAt).toBeUndefined(); + expect(query?.where[Op.and].val).toContain("phase_history"); + expect(query?.where[Op.and].val).toContain("entry->>'timestamp'"); + }); + + it("counts direct ramps persisted before provider block metadata", async () => { + RampState.findAll = mock(async () => [ + { + quote: { + inputAmount: "12.75", + inputCurrency: EvmToken.USDT, + metadata: { blocks: {} }, + outputCurrency: FiatToken.COP + }, + type: RampDirection.SELL + } + ]) as unknown as typeof RampState.findAll; + + const used = await getAlfredpayMonthlyUsage("user-1", RampDirection.SELL, FiatToken.COP, "USDT"); + expect(used.toFixed()).toBe("12.75"); + }); +}); diff --git a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts index a88a270b1..b4a30779f 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts @@ -1,4 +1,5 @@ import { + ALFREDPAY_EVM_TOKEN, AlfredPayCountry, AlfredpayCustomerType, AlfredpayStablecoinKey, @@ -11,6 +12,7 @@ import { } from "@vortexfi/shared"; import Big from "big.js"; import { Op } from "sequelize"; +import sequelize from "../../../config/database"; import ProviderCustomer from "../../../models/providerCustomer.model"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState from "../../../models/rampState.model"; @@ -19,11 +21,19 @@ import { multiplyByPowerOfTen } from "../pendulum/helpers"; import { AlfredpayLimitsService } from "./alfredpay-limits.service"; const FIAT_TO_COUNTRY: Partial> = { + [FiatToken.ARS]: AlfredPayCountry.AR, [FiatToken.COP]: AlfredPayCountry.CO, [FiatToken.MXN]: AlfredPayCountry.MX, [FiatToken.USD]: AlfredPayCountry.US }; +const MONTHLY_USAGE_CACHE_TTL_MS = 60_000; +const monthlyUsageCache = new Map }>(); + +function usageKey(direction: RampDirection, fiat: FiatToken, stablecoin: AlfredpayStablecoinKey): string { + return `${direction}:${fiat}:${stablecoin}`; +} + export function alfredpayCountryForFiat(fiat: FiatToken): AlfredPayCountry | undefined { return FIAT_TO_COUNTRY[fiat]; } @@ -67,7 +77,6 @@ export interface ResolvedAlfredpayLimits extends AmountLimits { /** * Resolves AlfredPay limits for a quote request, returning null when the quote isn't an AlfredPay quote. - * Throws when the on-chain side isn't a recognized AlfredPay stablecoin. * * Returned limits are in human units of `inputCurrency` (the side the validator checks). */ @@ -80,12 +89,12 @@ export async function resolveAlfredpayQuoteLimits(args: { const { rampType, inputCurrency, outputCurrency, userId } = args; const isOnramp = rampType === RampDirection.BUY; const fiatCandidate = isOnramp ? inputCurrency : outputCurrency; - const onchainCurrency = isOnramp ? outputCurrency : inputCurrency; if (!isAlfredpayToken(fiatCandidate)) return null; - const stablecoin = stablecoinFromCurrency(onchainCurrency); + // Routed quotes may end in another asset; AlfredPay always settles the anchor leg in this token. + const stablecoin = stablecoinFromCurrency(ALFREDPAY_EVM_TOKEN); if (!stablecoin) { - throw new Error(`Unsupported AlfredPay stablecoin: ${onchainCurrency}`); + throw new Error(`Unsupported AlfredPay stablecoin: ${ALFREDPAY_EVM_TOKEN}`); } const customer = await lookupAlfredpayCustomerType(userId, fiatCandidate); @@ -102,35 +111,97 @@ export async function resolveAlfredpayQuoteLimits(args: { }; } -function startOfCurrentUtcMonth(): Date { - const now = new Date(); - return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); +export function getCurrentUtcMonthPeriod(now = new Date()): { startsAt: Date; endsAt: Date } { + return { + endsAt: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)), + startsAt: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) + }; } -/** Returned in input-currency human units: fiat on onramp, stablecoin on offramp. */ -export async function getAlfredpayMonthlyUsage( - userId: string, - direction: RampDirection, - fiat: FiatToken, - stablecoin: AlfredpayStablecoinKey -): Promise { - const isOnramp = direction === RampDirection.BUY; - const fiatSide = isOnramp ? { inputCurrency: fiat } : { outputCurrency: fiat }; - const stablecoinSide = isOnramp ? { outputCurrency: stablecoin } : { inputCurrency: stablecoin }; +export function clearAlfredpayMonthlyUsageCache(): void { + monthlyUsageCache.clear(); +} + +async function getAlfredpayMonthlyUsageByFiat(userId: string): Promise> { + const { startsAt, endsAt } = getCurrentUtcMonthPeriod(); + const cacheKey = `${userId}:${startsAt.toISOString()}`; + const cached = monthlyUsageCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.usage; + + const completionPeriod = sequelize.literal(`EXISTS ( + SELECT 1 FROM jsonb_array_elements("RampState"."phase_history") AS entry + WHERE entry->>'phase' = 'complete' + AND (entry->>'timestamp')::timestamptz >= ${sequelize.escape(startsAt)} + AND (entry->>'timestamp')::timestamptz < ${sequelize.escape(endsAt)} + )`); const completedRamps = (await RampState.findAll({ - include: [{ as: "quote", model: QuoteTicket, required: true, where: { ...fiatSide, ...stablecoinSide } }], + include: [{ as: "quote", model: QuoteTicket, required: true, where: { status: "consumed" } }], where: { - createdAt: { [Op.gte]: startOfCurrentUtcMonth() }, + [Op.and]: completionPeriod, currentPhase: "complete", - type: direction, userId } })) as Array; - let total = new Big(0); + const usage = new Map(); for (const ramp of completedRamps) { - total = total.plus(ramp.quote.inputAmount); + const quote = ramp.quote; + const blocks = (quote.metadata as { blocks?: Record } | null)?.blocks; + let fiat: FiatToken | undefined; + let stablecoin: AlfredpayStablecoinKey | null = null; + let amount: unknown; + + if (ramp.type === RampDirection.BUY) { + const block = blocks?.alfredpayMint as { currency?: FiatToken; inputAmountDecimal?: unknown } | undefined; + fiat = block?.currency; + stablecoin = stablecoinFromCurrency(ALFREDPAY_EVM_TOKEN); + amount = block?.inputAmountDecimal; + + // Quotes persisted before block metadata was introduced can only be identified by their direct pair. + if (!fiat && isAlfredpayToken(quote.inputCurrency)) { + const legacyStablecoin = stablecoinFromCurrency(quote.outputCurrency); + if (legacyStablecoin) { + fiat = quote.inputCurrency; + stablecoin = legacyStablecoin; + amount = quote.inputAmount; + } + } + } else { + const block = blocks?.alfredpayOfframp as + | { currency?: FiatToken; inputAmountDecimal?: unknown; token?: RampCurrency } + | undefined; + fiat = block?.currency; + stablecoin = block?.token ? stablecoinFromCurrency(block.token) : null; + amount = block?.inputAmountDecimal; + + if (!fiat && isAlfredpayToken(quote.outputCurrency)) { + const legacyStablecoin = stablecoinFromCurrency(quote.inputCurrency); + if (legacyStablecoin) { + fiat = quote.outputCurrency; + stablecoin = legacyStablecoin; + amount = quote.inputAmount; + } + } + } + + if (!fiat || !stablecoin || amount === undefined) continue; + const key = usageKey(ramp.type, fiat, stablecoin); + usage.set(key, new Big(usage.get(key) ?? 0).plus(String(amount)).toFixed()); } - return total; + + if (monthlyUsageCache.size > 10_000) monthlyUsageCache.clear(); + monthlyUsageCache.set(cacheKey, { expiresAt: Date.now() + MONTHLY_USAGE_CACHE_TTL_MS, usage }); + return usage; +} + +/** Returned in input-currency human units: fiat on onramp, stablecoin on offramp. */ +export async function getAlfredpayMonthlyUsage( + userId: string, + direction: RampDirection, + fiat: FiatToken, + stablecoin: AlfredpayStablecoinKey +): Promise { + const usage = await getAlfredpayMonthlyUsageByFiat(userId); + return new Big(usage.get(usageKey(direction, fiat, stablecoin)) ?? 0); } diff --git a/apps/api/src/api/services/api-credential-migration.test.ts b/apps/api/src/api/services/api-credential-migration.test.ts new file mode 100644 index 000000000..37783828b --- /dev/null +++ b/apps/api/src/api/services/api-credential-migration.test.ts @@ -0,0 +1,96 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { + type ApiCredentialMigrationEntry, + migrateApiCredentials, + preflightApiCredentialMigration +} from "../../../scripts/api-credential-migration"; +import ApiCredential from "../../models/apiCredential.model"; +import ApiKey from "../../models/apiKey.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestPartner, createTestUser } from "../../test-utils/factories"; + +describe("legacy API credential migration", () => { + beforeAll(setupTestDatabase); + beforeEach(resetTestDatabase); + + async function legacyPair(): Promise { + const profile = await createTestUser(); + const partner = await createTestPartner(); + const publicKey = "pk_test_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"; + const secretKey = "sk_test_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"; + const common = { + expiresAt: new Date(Date.now() + 86_400_000), + isActive: true, + lastUsedAt: null, + name: "legacy", + partnerId: partner.id, + partnerName: partner.name, + revokedAt: null, + scopes: null, + userId: profile.id + }; + const publicRow = await ApiKey.create({ + ...common, + keyHash: null, + keyPrefix: publicKey.slice(0, 8), + keyType: "public", + keyValue: publicKey + }); + const secretRow = await ApiKey.create({ + ...common, + keyHash: createHash("sha256").update(secretKey).digest("hex"), + keyPrefix: secretKey.slice(0, 16), + keyType: "secret", + keyValue: null + }); + return { + expiresAt: common.expiresAt.toISOString(), + name: "migrated credential", + partnerId: partner.id, + profileId: profile.id, + publicKeyId: publicRow.id, + secretKeyId: secretRow.id + }; + } + + it("preflights without writing, then inserts and revokes the exact mapped pair transactionally", async () => { + const entry = await legacyPair(); + expect(await preflightApiCredentialMigration([entry])).toBe(1); + expect(await ApiCredential.count()).toBe(0); + + expect(await migrateApiCredentials([entry])).toBe(1); + const credential = await ApiCredential.findOne(); + expect(credential).toMatchObject({ + environment: "test", + name: entry.name, + partnerId: entry.partnerId, + profileId: entry.profileId + }); + expect(await ApiKey.count({ where: { isActive: true } })).toBe(0); + }); + + it("fails preflight if any active row is unmapped or the secret is not a SHA-256 digest", async () => { + const entry = await legacyPair(); + await ApiKey.create({ + expiresAt: null, + isActive: true, + keyHash: null, + keyPrefix: "pk_test_", + keyType: "public", + keyValue: "pk_test_12345678901234567890123456789012", + lastUsedAt: null, + name: "unmapped", + partnerId: null, + partnerName: null, + revokedAt: null, + scopes: null, + userId: null + }); + await expect(preflightApiCredentialMigration([entry])).rejects.toThrow("not explicitly mapped or revoked"); + + await ApiKey.destroy({ where: { name: "unmapped" } }); + await ApiKey.update({ keyHash: "$2b$10$legacy" }, { where: { id: entry.secretKeyId } }); + await expect(preflightApiCredentialMigration([entry])).rejects.toThrow("does not contain a SHA-256 digest"); + }); +}); diff --git a/apps/api/src/api/services/apiCredential.service.test.ts b/apps/api/src/api/services/apiCredential.service.test.ts new file mode 100644 index 000000000..3099598c2 --- /dev/null +++ b/apps/api/src/api/services/apiCredential.service.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { Op } from "sequelize"; +import sequelize from "../../config/database"; +import ApiCredential from "../../models/apiCredential.model"; +import ApiKey from "../../models/apiKey.model"; +import User from "../../models/user.model"; +import { digestApiKey, generateApiKey, getSecretKeyLookupPrefix } from "../middlewares/apiKeyFormat"; +import { + createCredential, + assertApiCredentialSchemaReady, + MAX_ACTIVE_CREDENTIALS_PER_PROFILE, + revokeCredential, + validatePublicKey, + validateSecretKey +} from "./apiCredential.service"; + +const originals = { + count: ApiCredential.count, + legacyCount: ApiKey.count, + create: ApiCredential.create, + findAll: ApiCredential.findAll, + findByPk: User.findByPk, + findOne: ApiCredential.findOne, + transaction: sequelize.transaction, + query: sequelize.query, + update: ApiCredential.update +}; + +afterEach(() => { + ApiCredential.count = originals.count; + ApiKey.count = originals.legacyCount; + ApiCredential.create = originals.create; + ApiCredential.findAll = originals.findAll; + ApiCredential.findOne = originals.findOne; + ApiCredential.update = originals.update; + User.findByPk = originals.findByPk; + sequelize.transaction = originals.transaction; + sequelize.query = originals.query; +}); + +describe("api credential service", () => { + it("locks the profile and excludes expired credentials from the cap query", async () => { + const transaction = { LOCK: { UPDATE: "UPDATE" } }; + let countWhere: Record = {}; + User.findByPk = mock(async () => ({ id: "profile-1" })) as never; + ApiCredential.count = mock(async options => { + countWhere = options?.where as Record; + return MAX_ACTIVE_CREDENTIALS_PER_PROFILE; + }) as never; + sequelize.transaction = mock(async callback => callback(transaction as never)) as never; + + await expect(createCredential({ environment: "test", profileId: "profile-1" })).rejects.toMatchObject({ + code: "CREDENTIAL_LIMIT_REACHED" + }); + expect(User.findByPk).toHaveBeenCalledWith("profile-1", expect.objectContaining({ lock: "UPDATE", transaction })); + expect(countWhere.revokedAt).toBeNull(); + expect(countWhere.expiresAt).toEqual({ [Op.gt]: expect.any(Date) }); + }); + + it("revokes both key values atomically with one credential update", async () => { + const update = mock(async () => [1]); + ApiCredential.update = update as never; + + await revokeCredential("credential-1", { partnerId: null, profileId: "profile-1" }); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + { revokedAt: expect.any(Date) }, + { where: { id: "credential-1", partnerId: null, profileId: "profile-1", revokedAt: null } } + ); + }); + + it("validates public and secret values to the same credential", async () => { + const secret = generateApiKey("secret", "test"); + const credential = Object.assign(new ApiCredential(), { + environment: "test", + expiresAt: new Date(Date.now() + 60_000), + id: "credential-1", + partnerId: null, + profileId: "profile-1", + publicKeyValue: generateApiKey("public", "test"), + revokedAt: null, + secretKeyDigest: digestApiKey(secret), + secretKeyPrefix: getSecretKeyLookupPrefix(secret), + update: mock(async () => credential) + }); + ApiCredential.findOne = mock(async () => credential) as never; + ApiCredential.findAll = mock(async () => [credential]) as never; + + const publicContext = await validatePublicKey(credential.publicKeyValue); + const secretContext = await validateSecretKey(secret); + + expect(publicContext).toMatchObject({ credentialId: credential.id, profileId: "profile-1", strength: "public" }); + expect(secretContext).toMatchObject({ credentialId: credential.id, profileId: "profile-1", strength: "secret" }); + }); + + it("refuses startup while active legacy api_keys rows remain", async () => { + const columns = [ + "created_at", + "environment", + "expires_at", + "id", + "name", + "partner_id", + "profile_id", + "public_key_value", + "public_last_used_at", + "revoked_at", + "secret_key_digest", + "secret_key_prefix", + "secret_last_used_at", + "updated_at" + ]; + sequelize.query = mock(async (sql: string) => { + if (sql.includes("information_schema.columns")) { + return columns.map(column_name => ({ + column_name, + is_nullable: ["partner_id", "public_last_used_at", "revoked_at", "secret_last_used_at"].includes(column_name) + ? "YES" + : "NO" + })); + } + if (sql.includes("pg_indexes")) { + return [ + "api_credentials_pkey", + "idx_api_credentials_partner_id", + "idx_api_credentials_profile_id", + "idx_api_credentials_secret_key_prefix", + "uq_api_credentials_public_key_value", + "uq_api_credentials_secret_key_digest" + ].map(indexname => ({ indexname })); + } + return [ + "api_credentials_partner_id_fkey", + "api_credentials_profile_id_fkey", + "chk_api_credentials_secret_digest", + "chk_api_credentials_secret_prefix_length" + ].map(conname => ({ conname })); + }) as never; + ApiKey.count = mock(async () => 1) as never; + + await expect(assertApiCredentialSchemaReady()).rejects.toThrow("active api_keys"); + }); +}); diff --git a/apps/api/src/api/services/apiCredential.service.ts b/apps/api/src/api/services/apiCredential.service.ts new file mode 100644 index 000000000..6e95c76d6 --- /dev/null +++ b/apps/api/src/api/services/apiCredential.service.ts @@ -0,0 +1,291 @@ +import crypto from "crypto"; +import { Op, QueryTypes } from "sequelize"; +import sequelize from "../../config/database"; +import logger from "../../config/logger"; +import ApiCredential, { ApiCredentialEnvironment } from "../../models/apiCredential.model"; +import ApiKey from "../../models/apiKey.model"; +import User from "../../models/user.model"; +import { digestApiKey, generateApiKey, getSecretKeyLookupPrefix } from "../middlewares/apiKeyFormat"; + +export const MAX_ACTIVE_CREDENTIALS_PER_PROFILE = 5; +const DEFAULT_EXPIRY_MS = 365 * 24 * 60 * 60 * 1000; +const MAX_EXPIRY_MS = 2 * DEFAULT_EXPIRY_MS; + +export interface CredentialContext { + credentialId: string; + environment: ApiCredentialEnvironment; + profileId: string; + partnerId: string | null; + strength: "public" | "secret"; +} + +export interface ApiCredentialDto { + id: string; + name: string; + profileId: string; + partnerId: string | null; + environment: ApiCredentialEnvironment; + publicKey: string; + secretKeyPrefix: string; + publicLastUsedAt: Date | null; + secretLastUsedAt: Date | null; + expiresAt: Date; + revokedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export class ApiCredentialServiceError extends Error { + constructor( + public readonly code: + | "CREDENTIAL_LIMIT_REACHED" + | "CREDENTIAL_NOT_FOUND" + | "CREDENTIAL_SUBJECT_REQUIRED" + | "INVALID_CREDENTIAL_EXPIRY" + | "INVALID_CREDENTIAL_NAME", + message: string + ) { + super(message); + } +} + +function toDto(credential: ApiCredential): ApiCredentialDto { + return { + createdAt: credential.createdAt, + environment: credential.environment, + expiresAt: credential.expiresAt, + id: credential.id, + name: credential.name, + partnerId: credential.partnerId, + profileId: credential.profileId, + publicKey: credential.publicKeyValue, + publicLastUsedAt: credential.publicLastUsedAt, + revokedAt: credential.revokedAt, + secretKeyPrefix: credential.secretKeyPrefix, + secretLastUsedAt: credential.secretLastUsedAt, + updatedAt: credential.updatedAt + }; +} + +function validateInput(name: unknown, expiresAt: unknown): { expiresAt: Date; name: string } { + if (name !== undefined && typeof name !== "string") { + throw new ApiCredentialServiceError("INVALID_CREDENTIAL_NAME", "name must be a string"); + } + const normalizedName = typeof name === "string" ? name.trim() || "API Credential" : "API Credential"; + if (normalizedName.length > 100) { + throw new ApiCredentialServiceError("INVALID_CREDENTIAL_NAME", "name must be at most 100 characters"); + } + + if (expiresAt !== undefined && typeof expiresAt !== "string") { + throw new ApiCredentialServiceError("INVALID_CREDENTIAL_EXPIRY", "expiresAt must be a valid ISO-8601 date"); + } + const now = Date.now(); + const expirationDate = expiresAt === undefined ? new Date(now + DEFAULT_EXPIRY_MS) : new Date(expiresAt); + if ( + Number.isNaN(expirationDate.getTime()) || + expirationDate.getTime() <= now || + expirationDate.getTime() > now + MAX_EXPIRY_MS + ) { + throw new ApiCredentialServiceError( + "INVALID_CREDENTIAL_EXPIRY", + "expiresAt must be in the future and at most 2 years from now" + ); + } + return { expiresAt: expirationDate, name: normalizedName }; +} + +export async function createCredential(input: { + environment: ApiCredentialEnvironment; + expiresAt?: unknown; + name?: unknown; + partnerId?: string | null; + profileId: string; +}): Promise { + if (!input.profileId) { + throw new ApiCredentialServiceError("CREDENTIAL_SUBJECT_REQUIRED", "A profile subject is required"); + } + const validated = validateInput(input.name, input.expiresAt); + const publicKey = generateApiKey("public", input.environment); + const secretKey = generateApiKey("secret", input.environment); + + const credential = await sequelize.transaction(async transaction => { + const profile = await User.findByPk(input.profileId, { + attributes: ["id"], + lock: transaction.LOCK.UPDATE, + transaction + }); + if (!profile) { + throw new ApiCredentialServiceError("CREDENTIAL_SUBJECT_REQUIRED", "Profile was not found"); + } + + const activeCount = await ApiCredential.count({ + transaction, + where: { expiresAt: { [Op.gt]: new Date() }, profileId: input.profileId, revokedAt: null } + }); + if (activeCount >= MAX_ACTIVE_CREDENTIALS_PER_PROFILE) { + throw new ApiCredentialServiceError( + "CREDENTIAL_LIMIT_REACHED", + `Active API credential limit reached (${MAX_ACTIVE_CREDENTIALS_PER_PROFILE})` + ); + } + + return ApiCredential.create( + { + environment: input.environment, + expiresAt: validated.expiresAt, + name: validated.name, + partnerId: input.partnerId ?? null, + profileId: input.profileId, + publicKeyValue: publicKey, + secretKeyDigest: digestApiKey(secretKey), + secretKeyPrefix: getSecretKeyLookupPrefix(secretKey) + }, + { transaction } + ); + }); + + return { ...toDto(credential), secretKey }; +} + +export async function listCredentials(filter: { partnerId?: string | null; profileId: string }): Promise { + return ( + await ApiCredential.findAll({ + order: [["createdAt", "DESC"]], + where: { ...(filter.partnerId !== undefined ? { partnerId: filter.partnerId } : {}), profileId: filter.profileId } + }) + ).map(toDto); +} + +export async function revokeCredential(id: string, filter: { partnerId?: string | null; profileId: string }): Promise { + const [updated] = await ApiCredential.update( + { revokedAt: new Date() }, + { + where: { + ...(filter.partnerId !== undefined ? { partnerId: filter.partnerId } : {}), + id, + profileId: filter.profileId, + revokedAt: null + } + } + ); + if (updated === 0) throw new ApiCredentialServiceError("CREDENTIAL_NOT_FOUND", "API credential not found"); +} + +function context(credential: ApiCredential, strength: "public" | "secret"): CredentialContext { + return { + credentialId: credential.id, + environment: credential.environment, + partnerId: credential.partnerId, + profileId: credential.profileId, + strength + }; +} + +export async function validatePublicKey(publicKey: string): Promise { + const credential = await ApiCredential.findOne({ + where: { expiresAt: { [Op.gt]: new Date() }, publicKeyValue: publicKey, revokedAt: null } + }); + if (!credential) return null; + credential + .update({ publicLastUsedAt: new Date() }) + .catch(error => logger.error("Failed to update public credential usage", error)); + return context(credential, "public"); +} + +export async function validateSecretKey(secretKey: string): Promise { + const candidates = await ApiCredential.findAll({ + where: { expiresAt: { [Op.gt]: new Date() }, revokedAt: null, secretKeyPrefix: getSecretKeyLookupPrefix(secretKey) } + }); + const presented = Buffer.from(digestApiKey(secretKey), "hex"); + const credential = candidates.find(candidate => { + if (!/^[0-9a-f]{64}$/.test(candidate.secretKeyDigest)) return false; + const stored = Buffer.from(candidate.secretKeyDigest, "hex"); + return stored.length === presented.length && crypto.timingSafeEqual(stored, presented); + }); + if (!credential) return null; + credential + .update({ secretLastUsedAt: new Date() }) + .catch(error => logger.error("Failed to update secret credential usage", error)); + return context(credential, "secret"); +} + +export async function assertApiCredentialSchemaReady(): Promise { + const expectedColumns = [ + "created_at", + "environment", + "expires_at", + "id", + "name", + "partner_id", + "profile_id", + "public_key_value", + "public_last_used_at", + "revoked_at", + "secret_key_digest", + "secret_key_prefix", + "secret_last_used_at", + "updated_at" + ]; + const columns = await sequelize.query<{ column_name: string; is_nullable: "NO" | "YES" }>( + `SELECT column_name, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'api_credentials'`, + { type: QueryTypes.SELECT } + ); + const present = new Set(columns.map(column => column.column_name)); + const missing = expectedColumns.filter(column => !present.has(column)); + if (missing.length > 0) throw new Error(`api_credentials schema is incomplete; missing: ${missing.join(", ")}`); + const nullableColumns = ["partner_id", "public_last_used_at", "revoked_at", "secret_last_used_at"]; + const nullableRequiredColumns = columns + .filter(column => !nullableColumns.includes(column.column_name)) + .filter(column => column.is_nullable !== "NO") + .map(column => column.column_name); + if (nullableRequiredColumns.length > 0) { + throw new Error(`api_credentials schema is incomplete; nullable required columns: ${nullableRequiredColumns.join(", ")}`); + } + const nonNullableOptionalColumns = columns + .filter(column => nullableColumns.includes(column.column_name) && column.is_nullable !== "YES") + .map(column => column.column_name); + if (nonNullableOptionalColumns.length > 0) { + throw new Error( + `api_credentials schema is incomplete; non-null optional columns: ${nonNullableOptionalColumns.join(", ")}` + ); + } + + const indexes = await sequelize.query<{ indexname: string }>( + `SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'api_credentials'`, + { type: QueryTypes.SELECT } + ); + const presentIndexes = new Set(indexes.map(index => index.indexname)); + const expectedIndexes = [ + "api_credentials_pkey", + "idx_api_credentials_partner_id", + "idx_api_credentials_profile_id", + "idx_api_credentials_secret_key_prefix", + "uq_api_credentials_public_key_value", + "uq_api_credentials_secret_key_digest" + ]; + const missingIndexes = expectedIndexes.filter(index => !presentIndexes.has(index)); + if (missingIndexes.length > 0) { + throw new Error(`api_credentials schema is incomplete; missing indexes: ${missingIndexes.join(", ")}`); + } + + const constraints = await sequelize.query<{ conname: string }>( + `SELECT conname FROM pg_constraint WHERE conrelid = 'api_credentials'::regclass`, + { type: QueryTypes.SELECT } + ); + const presentConstraints = new Set(constraints.map(constraint => constraint.conname)); + const expectedConstraints = [ + "api_credentials_partner_id_fkey", + "api_credentials_profile_id_fkey", + "chk_api_credentials_secret_digest", + "chk_api_credentials_secret_prefix_length" + ]; + const missingConstraints = expectedConstraints.filter(constraint => !presentConstraints.has(constraint)); + if (missingConstraints.length > 0) { + throw new Error(`api_credentials schema is incomplete; missing constraints: ${missingConstraints.join(", ")}`); + } + + const activeLegacyCount = await ApiKey.count({ where: { isActive: true } }); + if (activeLegacyCount > 0) { + throw new Error(`${activeLegacyCount} active api_keys row(s) remain; migrate or revoke them before startup`); + } +} diff --git a/apps/api/src/api/services/auth/index.ts b/apps/api/src/api/services/auth/index.ts index 91db21e5e..5b15bfc7e 100644 --- a/apps/api/src/api/services/auth/index.ts +++ b/apps/api/src/api/services/auth/index.ts @@ -1 +1 @@ -export { RefreshTokenError, SupabaseAuthService } from "./supabase.service"; +export { AccessTokenVerificationError, 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 747806ce1..82056be07 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -2,6 +2,16 @@ import { isAuthRetryableFetchError, type User } from "@supabase/supabase-js"; import logger from "../../../config/logger"; import { supabase, supabaseAdmin } from "../../../config/supabase"; +export class AccessTokenVerificationError extends Error { + readonly transient: boolean; + + constructor(message: string, transient: boolean) { + super(message); + this.name = "AccessTokenVerificationError"; + this.transient = transient; + } +} + /** * 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 @@ -161,11 +171,20 @@ export class SupabaseAuthService { user_id?: string; email?: string; }> { - const { data, error } = await supabaseAdmin.auth.getUser(accessToken); + // Access-token verification is an Auth operation and does not require broad + // service-role privileges. The project anon key identifies the trusted Supabase + // project; the bearer token remains the credential being verified. + const { data, error } = await supabase.auth.getUser(accessToken); - if (error || !data.user) { + if (error) { + const status = error.status ?? 0; + const transient = isAuthRetryableFetchError(error) || status === 0 || status >= 500; + if (transient) { + throw new AccessTokenVerificationError("Supabase access-token verification unavailable", true); + } return { valid: false }; } + if (!data.user) return { valid: false }; return { email: data.user.email, diff --git a/apps/api/src/api/services/auth/supabase.verify-token.test.ts b/apps/api/src/api/services/auth/supabase.verify-token.test.ts new file mode 100644 index 000000000..2a984c444 --- /dev/null +++ b/apps/api/src/api/services/auth/supabase.verify-token.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { supabase, supabaseAdmin } from "../../../config/supabase"; +import { AccessTokenVerificationError, SupabaseAuthService } from "./supabase.service"; + +afterEach(() => { + mock.restore(); +}); + +describe("SupabaseAuthService.verifyToken", () => { + it("uses the least-privileged Auth client and returns the authoritative user", async () => { + const getUser = spyOn(supabase.auth, "getUser").mockResolvedValue({ + data: { user: { email: "user@example.com", id: "user-1" } }, + error: null + } as never); + const adminGetUser = spyOn(supabaseAdmin.auth, "getUser"); + + await expect(SupabaseAuthService.verifyToken("access-token")).resolves.toEqual({ + email: "user@example.com", + user_id: "user-1", + valid: true + }); + expect(getUser).toHaveBeenCalledWith("access-token"); + expect(adminGetUser).not.toHaveBeenCalled(); + }); + + it("distinguishes a definitive invalid token from an indeterminate provider failure", async () => { + const getUser = spyOn(supabase.auth, "getUser"); + getUser.mockResolvedValueOnce({ data: { user: null }, error: { message: "invalid JWT", status: 401 } } as never); + await expect(SupabaseAuthService.verifyToken("invalid")).resolves.toEqual({ valid: false }); + + getUser.mockResolvedValueOnce({ data: { user: null }, error: { message: "upstream unavailable", status: 503 } } as never); + await expect(SupabaseAuthService.verifyToken("indeterminate")).rejects.toBeInstanceOf(AccessTokenVerificationError); + }); +}); diff --git a/apps/api/src/api/services/customer-entity.service.ts b/apps/api/src/api/services/customer-entity.service.ts index 328ee8ce3..1b5b295f8 100644 --- a/apps/api/src/api/services/customer-entity.service.ts +++ b/apps/api/src/api/services/customer-entity.service.ts @@ -60,8 +60,32 @@ export async function getOrCreateCustomerEntityForProfile( return entity; } -export async function selectActiveCustomerEntity(profileId: string, type: CustomerEntityType): Promise { - return sequelize.transaction(async transaction => { +/** + * All customer entity ids owned by a profile, oldest first. Read-only counterpart to + * getOrCreateCustomerEntityForProfile for lookup/ownership paths: migration 040 attached + * legacy business provider rows to the profile's individual entity, so a row's owning + * entity does not reliably carry the row's customer_type — callers that filter provider + * rows by customer_type must scope by every entity the profile owns, and a pure lookup + * must not leave an empty entity behind. + */ +export async function findCustomerEntityIdsForProfile(profileId: string): Promise { + const entities = await CustomerEntity.findAll({ + attributes: ["id"], + order: [ + ["createdAt", "ASC"], + ["id", "ASC"] + ], + where: { profileId } + }); + return entities.map(entity => entity.id); +} + +export async function selectActiveCustomerEntity( + profileId: string, + type: CustomerEntityType, + existingTransaction?: Transaction +): Promise { + const select = async (transaction: Transaction): Promise => { const profile = await User.findByPk(profileId, { lock: Transaction.LOCK.UPDATE, transaction }); if (!profile) { throw new APIError({ isPublic: true, message: "Profile not found", status: httpStatus.NOT_FOUND }); @@ -113,5 +137,7 @@ export async function selectActiveCustomerEntity(profileId: string, type: Custom )); await profile.update({ activeCustomerEntityId: selected.id }, { transaction }); return selected; - }); + }; + + return existingTransaction ? select(existingTransaction) : sequelize.transaction(select); } diff --git a/apps/api/src/api/services/limits.service.test.ts b/apps/api/src/api/services/limits.service.test.ts new file mode 100644 index 000000000..63cf2d5e3 --- /dev/null +++ b/apps/api/src/api/services/limits.service.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { AlfredPayCountry, BrlaApiService, EvmToken, FiatToken, RampDirection } from "@vortexfi/shared"; +import CustomerEntity from "../../models/customerEntity.model"; +import ProviderCustomer from "../../models/providerCustomer.model"; +import RampState from "../../models/rampState.model"; +import User from "../../models/user.model"; +import { clearAlfredpayMonthlyUsageCache } from "./alfredpay/alfredpay.helpers"; +import { getUserLimits } from "./limits.service"; + +const originals = { + brlaGetInstance: BrlaApiService.getInstance, + customerEntityFindOne: CustomerEntity.findOne, + providerCustomerFindAll: ProviderCustomer.findAll, + providerCustomerFindOne: ProviderCustomer.findOne, + rampFindAll: RampState.findAll, + userFindByPk: User.findByPk +}; + +beforeEach(() => { + clearAlfredpayMonthlyUsageCache(); + User.findByPk = mock(async () => null) as unknown as typeof User.findByPk; + CustomerEntity.findOne = mock(async () => ({ id: "entity-1" })) as unknown as typeof CustomerEntity.findOne; +}); + +afterEach(() => { + BrlaApiService.getInstance = originals.brlaGetInstance; + CustomerEntity.findOne = originals.customerEntityFindOne; + ProviderCustomer.findAll = originals.providerCustomerFindAll; + ProviderCustomer.findOne = originals.providerCustomerFindOne; + RampState.findAll = originals.rampFindAll; + User.findByPk = originals.userFindByPk; + clearAlfredpayMonthlyUsageCache(); +}); + +describe("getUserLimits", () => { + it("returns both AlfredPay directions using routed monthly usage", async () => { + ProviderCustomer.findOne = mock(async () => ({ customerType: "individual" })) as unknown as typeof ProviderCustomer.findOne; + RampState.findAll = mock(async () => [ + { + quote: { + inputAmount: "100", + inputCurrency: FiatToken.USD, + metadata: { blocks: { alfredpayMint: { currency: FiatToken.USD, inputAmountDecimal: "100" } } }, + outputCurrency: EvmToken.ETH + }, + type: RampDirection.BUY + }, + { + quote: { + inputAmount: "1", + inputCurrency: EvmToken.ETH, + metadata: { + blocks: { + alfredpayOfframp: { + currency: FiatToken.USD, + inputAmountDecimal: "25.5", + token: EvmToken.USDT + } + } + }, + outputCurrency: FiatToken.USD + }, + type: RampDirection.SELL + } + ]) as unknown as typeof RampState.findAll; + + const response = await getUserLimits("user-1", ["US"]); + + expect(response.limits).toHaveLength(2); + expect(response.limits[0]).toMatchObject({ + corridor: "US", + currency: FiatToken.USD, + direction: RampDirection.BUY, + max: "100000", + used: "100" + }); + expect(response.limits[1]).toMatchObject({ + corridor: "US", + currency: EvmToken.USDT, + direction: RampDirection.SELL, + max: "100000", + used: "25.5" + }); + expect(response.limits[0].period.type).toBe("calendar_month"); + }); + + it("passes through Avenia BRL max, used, and reported month", async () => { + ProviderCustomer.findAll = mock(async () => [ + { + country: AlfredPayCountry.BR, + customerType: "individual", + providerSubaccountId: "subaccount-1", + taxReference: "12345678901" + } + ]) as unknown as typeof ProviderCustomer.findAll; + const getSubaccountUsedLimit = mock(async () => ({ + limitInfo: { + blocked: false, + createdAt: "2026-07-01T00:00:00.000Z", + limits: [ + { + currency: "BRL", + maxChainIn: "0", + maxChainOut: "0", + maxFiatIn: "10000", + maxFiatOut: "9000", + usedLimit: { + month: 7, + usedChainIn: "0", + usedChainOut: "0", + usedFiatIn: "150.5", + usedFiatOut: "25", + year: 2026 + } + } + ] + } + })); + BrlaApiService.getInstance = () => ({ getSubaccountUsedLimit }) as unknown as BrlaApiService; + + const response = await getUserLimits("user-1", ["BR"]); + + expect(getSubaccountUsedLimit).toHaveBeenCalledWith("subaccount-1"); + expect(response.limits).toEqual([ + { + corridor: "BR", + currency: FiatToken.BRL, + direction: RampDirection.BUY, + max: "10000", + period: { + endsAt: "2026-08-01T00:00:00.000Z", + startsAt: "2026-07-01T00:00:00.000Z", + type: "calendar_month" + }, + used: "150.5" + }, + { + corridor: "BR", + currency: FiatToken.BRL, + direction: RampDirection.SELL, + max: "9000", + period: { + endsAt: "2026-08-01T00:00:00.000Z", + startsAt: "2026-07-01T00:00:00.000Z", + type: "calendar_month" + }, + used: "25" + } + ]); + }); +}); diff --git a/apps/api/src/api/services/limits.service.ts b/apps/api/src/api/services/limits.service.ts new file mode 100644 index 000000000..e75421257 --- /dev/null +++ b/apps/api/src/api/services/limits.service.ts @@ -0,0 +1,112 @@ +import { + type AccountLimitsResponse, + ALFREDPAY_EVM_TOKEN, + BrlaApiError, + BrlaApiService, + BrlaCurrency, + FiatToken, + GetUserLimitsResponse, + LimitsCorridor, + RampDirection, + UserLimit, + UserLimitPeriod +} from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { APIError } from "../errors/api-error"; +import { getAlfredpayMonthlyUsage, getCurrentUtcMonthPeriod, resolveAlfredpayQuoteLimits } from "./alfredpay/alfredpay.helpers"; +import { resolveAveniaAccountForUser } from "./avenia-account"; + +const CORRIDOR_FIAT: Record, FiatToken> = { + AR: FiatToken.ARS, + CO: FiatToken.COP, + MX: FiatToken.MXN, + US: FiatToken.USD +}; + +function calendarMonthPeriod(year: number, month: number): UserLimitPeriod { + return { + endsAt: new Date(Date.UTC(year, month, 1)).toISOString(), + startsAt: new Date(Date.UTC(year, month - 1, 1)).toISOString(), + type: "calendar_month" + }; +} + +async function getAlfredpayLimits(userId: string, corridor: Exclude): Promise { + const fiat = CORRIDOR_FIAT[corridor]; + const now = new Date(); + const { startsAt } = getCurrentUtcMonthPeriod(now); + const period = calendarMonthPeriod(startsAt.getUTCFullYear(), startsAt.getUTCMonth() + 1); + + const limits: UserLimit[] = []; + for (const direction of [RampDirection.BUY, RampDirection.SELL]) { + const resolved = await resolveAlfredpayQuoteLimits({ + inputCurrency: direction === RampDirection.BUY ? fiat : ALFREDPAY_EVM_TOKEN, + outputCurrency: direction === RampDirection.BUY ? ALFREDPAY_EVM_TOKEN : fiat, + rampType: direction, + userId + }); + if (!resolved) { + throw new APIError({ message: `Limits unavailable for ${corridor}`, status: httpStatus.BAD_REQUEST }); + } + + const used = await getAlfredpayMonthlyUsage(userId, direction, fiat, resolved.stablecoin); + limits.push({ + corridor, + currency: direction === RampDirection.BUY ? fiat : ALFREDPAY_EVM_TOKEN, + direction, + max: resolved.max, + period, + used: used.toFixed() + }); + } + return limits; +} + +async function getAveniaLimits(userId: string): Promise { + const account = await resolveAveniaAccountForUser(userId); + let response: AccountLimitsResponse | undefined; + try { + response = await BrlaApiService.getInstance().getSubaccountUsedLimit(account.subAccountId); + } catch (error) { + if (error instanceof BrlaApiError) { + throw new APIError({ message: "Avenia limits are unavailable", status: httpStatus.BAD_GATEWAY }); + } + throw error; + } + const brl = response?.limitInfo?.limits.find(limit => limit.currency === BrlaCurrency.BRL); + if (!brl) { + throw new APIError({ message: "BRL limits not found", status: httpStatus.BAD_GATEWAY }); + } + + const { year, month } = brl.usedLimit; + if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) { + throw new APIError({ message: "Avenia returned invalid limit period", status: httpStatus.BAD_GATEWAY }); + } + const period = calendarMonthPeriod(year, month); + + return [ + { + corridor: "BR", + currency: FiatToken.BRL, + direction: RampDirection.BUY, + max: brl.maxFiatIn, + period, + used: brl.usedLimit.usedFiatIn + }, + { + corridor: "BR", + currency: FiatToken.BRL, + direction: RampDirection.SELL, + max: brl.maxFiatOut, + period, + used: brl.usedLimit.usedFiatOut + } + ]; +} + +export async function getUserLimits(userId: string, corridors: LimitsCorridor[]): Promise { + const limitsByCorridor = await Promise.all( + corridors.map(corridor => (corridor === "BR" ? getAveniaLimits(userId) : getAlfredpayLimits(userId, corridor))) + ); + return { limits: limitsByCorridor.flat() }; +} diff --git a/apps/api/src/api/services/managed-profile.service.test.ts b/apps/api/src/api/services/managed-profile.service.test.ts new file mode 100644 index 000000000..b60ca9be9 --- /dev/null +++ b/apps/api/src/api/services/managed-profile.service.test.ts @@ -0,0 +1,161 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import type { User as SupabaseUser } from "@supabase/supabase-js"; +import express from "express"; +import { supabaseAdmin } from "../../config/supabase"; +import CustomerEntity from "../../models/customerEntity.model"; +import PartnerManagedProfile from "../../models/partnerManagedProfile.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestPartner } from "../../test-utils/factories"; +import managedProfilesRoutes from "../routes/v1/admin/managed-profiles.route"; +import authRoutes from "../routes/v1/auth.route"; +import { SupabaseAuthService } from "./auth"; + +const BASE_PATH = "/v1/admin/managed-profiles"; +const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type": "application/json" }; + +describe("managed profile creation", () => { + let server: ReturnType; + let baseUrl: string; + const authUsers = new Map(); + const originalCreateUser = supabaseAdmin.auth.admin.createUser; + const originalListUsers = supabaseAdmin.auth.admin.listUsers; + const originalVerifyOtp = SupabaseAuthService.verifyOTP; + const createUserMock = mock(async (attributes: { app_metadata?: Record; email?: string }) => { + const email = attributes.email!; + if (authUsers.has(email)) { + return { data: { user: null }, error: { message: "User already registered" } } as never; + } + const user = { + app_metadata: attributes.app_metadata ?? {}, + aud: "authenticated", + created_at: new Date().toISOString(), + email, + id: crypto.randomUUID(), + user_metadata: {} + } as SupabaseUser; + authUsers.set(email, user); + return { data: { user }, error: null } as never; + }); + const listUsersMock = mock(async () => ({ data: { users: [...authUsers.values()] }, error: null }) as never); + + beforeAll(async () => { + await setupTestDatabase(); + supabaseAdmin.auth.admin.createUser = createUserMock as typeof supabaseAdmin.auth.admin.createUser; + supabaseAdmin.auth.admin.listUsers = listUsersMock as typeof supabaseAdmin.auth.admin.listUsers; + + const app = express(); + app.use(express.json()); + app.use(BASE_PATH, managedProfilesRoutes); + app.use("/v1/auth", authRoutes); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not bind test server"); + baseUrl = `http://127.0.0.1:${address.port}${BASE_PATH}`; + }); + + afterAll(() => { + supabaseAdmin.auth.admin.createUser = originalCreateUser; + supabaseAdmin.auth.admin.listUsers = originalListUsers; + SupabaseAuthService.verifyOTP = originalVerifyOtp; + server?.close(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + authUsers.clear(); + createUserMock.mockClear(); + listUsersMock.mockClear(); + SupabaseAuthService.verifyOTP = originalVerifyOtp; + }); + + function post(body: unknown, headers: Record = ADMIN_HEADERS) { + return fetch(baseUrl, { body: JSON.stringify(body), headers, method: "POST" }); + } + + it("requires admin auth and creates an idempotent normalized individual profile", async () => { + const partner = await createTestPartner(); + const input = { + email: " Managed.User@Example.COM ", + externalUserId: "customer-1", + partnerId: partner.id, + subjectType: "individual" + }; + + expect((await post(input, { "Content-Type": "application/json" })).status).toBe(401); + const created = await post(input); + expect(created.status).toBe(201); + const body = (await created.json()) as { managedProfile: { email: string; profileId: string } }; + expect(body.managedProfile.email).toBe("managed.user@example.com"); + expect(await CustomerEntity.count({ where: { profileId: body.managedProfile.profileId, type: "individual" } })).toBe(1); + + const retried = await post({ ...input, email: "MANAGED.USER@example.com" }); + expect(retried.status).toBe(200); + expect(createUserMock).toHaveBeenCalledTimes(1); + expect(await PartnerManagedProfile.count()).toBe(1); + }); + + it("conflicts for a changed email or for the same email under another external ID", async () => { + const partner = await createTestPartner(); + const base = { email: "owner@example.com", externalUserId: "customer-1", partnerId: partner.id, subjectType: "business" }; + expect((await post(base)).status).toBe(201); + + expect((await post({ ...base, email: "other@example.com" })).status).toBe(409); + expect((await post({ ...base, externalUserId: "customer-2" })).status).toBe(409); + expect(await PartnerManagedProfile.count()).toBe(1); + }); + + it("reconciles only an Auth identity carrying the exact workflow metadata", async () => { + const partner = await createTestPartner(); + const email = "retry@example.com"; + const exactUser = { + app_metadata: { + vortex_managed_profile_external_user_id: "retry-1", + vortex_managed_profile_partner_id: partner.id + }, + aud: "authenticated", + created_at: new Date().toISOString(), + email, + id: crypto.randomUUID(), + user_metadata: {} + } as SupabaseUser; + authUsers.set(email, exactUser); + + const recovered = await post({ email, externalUserId: "retry-1", partnerId: partner.id, subjectType: "individual" }); + expect(recovered.status).toBe(201); + expect((await PartnerManagedProfile.findOne({ where: { externalUserId: "retry-1" } }))?.profileId).toBe(exactUser.id); + + const foreignEmail = "foreign@example.com"; + authUsers.set(foreignEmail, { ...exactUser, email: foreignEmail, id: crypto.randomUUID(), app_metadata: {} }); + const rejected = await post({ email: foreignEmail, externalUserId: "retry-2", partnerId: partner.id, subjectType: "individual" }); + expect(rejected.status).toBe(409); + }); + + it("creates no entity for technical profiles and marks claims after OTP verification by profile UUID", async () => { + const partner = await createTestPartner(); + const response = await post({ + email: "technical@example.com", + externalUserId: "machine-1", + partnerId: partner.id, + subjectType: "technical" + }); + expect(response.status).toBe(201); + const body = (await response.json()) as { managedProfile: { profileId: string } }; + expect(await CustomerEntity.count({ where: { profileId: body.managedProfile.profileId } })).toBe(0); + + SupabaseAuthService.verifyOTP = mock(async () => ({ + access_token: "access", + refresh_token: "refresh", + user_id: body.managedProfile.profileId + })); + const verified = await fetch(`${baseUrl.replace(BASE_PATH, "")}/v1/auth/verify-otp`, { + body: JSON.stringify({ email: "technical@example.com", token: "123456" }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(verified.status).toBe(200); + expect((await PartnerManagedProfile.findOne({ where: { profileId: body.managedProfile.profileId } }))?.claimedAt).toBeInstanceOf( + Date + ); + expect(await CustomerEntity.count({ where: { profileId: body.managedProfile.profileId } })).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/managed-profile.service.ts b/apps/api/src/api/services/managed-profile.service.ts new file mode 100644 index 000000000..10aec8cb2 --- /dev/null +++ b/apps/api/src/api/services/managed-profile.service.ts @@ -0,0 +1,205 @@ +import type { User as SupabaseUser } from "@supabase/supabase-js"; +import { col, fn, Transaction, UniqueConstraintError, where } from "sequelize"; +import sequelize from "../../config/database"; +import { supabaseAdmin } from "../../config/supabase"; +import Partner from "../../models/partner.model"; +import PartnerManagedProfile, { type ManagedProfileSubjectType } from "../../models/partnerManagedProfile.model"; +import User from "../../models/user.model"; +import { selectActiveCustomerEntity } from "./customer-entity.service"; + +const PARTNER_METADATA_KEY = "vortex_managed_profile_partner_id"; +const EXTERNAL_USER_METADATA_KEY = "vortex_managed_profile_external_user_id"; + +export class ManagedProfileServiceError extends Error { + constructor( + readonly code: + | "MANAGED_PROFILE_CONFLICT" + | "MANAGED_PROFILE_INVALID_INPUT" + | "MANAGED_PROFILE_PARTNER_NOT_FOUND" + | "MANAGED_PROFILE_UPSTREAM_ERROR", + message: string + ) { + super(message); + this.name = this.constructor.name; + } +} + +export interface CreateManagedProfileInput { + email: string; + externalUserId: string; + partnerId: string; + subjectType: ManagedProfileSubjectType; +} + +export interface ManagedProfileResult { + claimedAt: Date | null; + created: boolean; + email: string; + externalUserId: string; + id: string; + partnerId: string; + profileId: string; + subjectType: ManagedProfileSubjectType; +} + +export function normalizeManagedProfileEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function hasAssociationMetadata(user: SupabaseUser, partnerId: string, externalUserId: string): boolean { + return ( + user.app_metadata?.[PARTNER_METADATA_KEY] === partnerId && + user.app_metadata?.[EXTERNAL_USER_METADATA_KEY] === externalUserId + ); +} + +async function findSupabaseUserByEmail(email: string): Promise { + const perPage = 1000; + for (let page = 1; ; page += 1) { + const { data, error } = await supabaseAdmin.auth.admin.listUsers({ page, perPage }); + if (error) { + throw new ManagedProfileServiceError("MANAGED_PROFILE_UPSTREAM_ERROR", "Could not reconcile the Auth identity"); + } + const match = data.users.find(user => normalizeManagedProfileEmail(user.email ?? "") === email); + if (match) return match; + if (data.users.length < perPage) return null; + } +} + +async function createOrReconcileAuthUser(input: CreateManagedProfileInput, email: string): Promise { + const { data, error } = await supabaseAdmin.auth.admin.createUser({ + app_metadata: { + [EXTERNAL_USER_METADATA_KEY]: input.externalUserId, + [PARTNER_METADATA_KEY]: input.partnerId + }, + email, + email_confirm: false + }); + + if (!error && data.user) return data.user; + + const existing = await findSupabaseUserByEmail(email); + if (!existing || !hasAssociationMetadata(existing, input.partnerId, input.externalUserId)) { + throw new ManagedProfileServiceError( + "MANAGED_PROFILE_CONFLICT", + "The email belongs to a different Auth identity or managed-profile association" + ); + } + return existing; +} + +function result(association: PartnerManagedProfile, email: string, created: boolean): ManagedProfileResult { + return { + claimedAt: association.claimedAt, + created, + email, + externalUserId: association.externalUserId, + id: association.id, + partnerId: association.partnerId, + profileId: association.profileId, + subjectType: association.subjectType + }; +} + +async function existingAssociationResult( + association: PartnerManagedProfile, + email: string, + subjectType: ManagedProfileSubjectType +): Promise { + const profile = await User.findByPk(association.profileId, { attributes: ["email"] }); + if (!profile || normalizeManagedProfileEmail(profile.email) !== email || association.subjectType !== subjectType) { + throw new ManagedProfileServiceError( + "MANAGED_PROFILE_CONFLICT", + "The external user ID is already associated with different profile data" + ); + } + return result(association, email, false); +} + +export async function createManagedProfile(input: CreateManagedProfileInput): Promise { + const email = normalizeManagedProfileEmail(input.email); + if (!email || !input.externalUserId.trim()) { + throw new ManagedProfileServiceError("MANAGED_PROFILE_INVALID_INPUT", "email and externalUserId must be non-empty strings"); + } + + const existing = await PartnerManagedProfile.findOne({ + where: { externalUserId: input.externalUserId, partnerId: input.partnerId } + }); + if (existing) return existingAssociationResult(existing, email, input.subjectType); + + if (!(await Partner.findByPk(input.partnerId, { attributes: ["id"] }))) { + throw new ManagedProfileServiceError("MANAGED_PROFILE_PARTNER_NOT_FOUND", "Partner not found"); + } + + const authUser = await createOrReconcileAuthUser(input, email); + if (normalizeManagedProfileEmail(authUser.email ?? "") !== email) { + throw new ManagedProfileServiceError("MANAGED_PROFILE_CONFLICT", "The Auth identity email does not match the request"); + } + + try { + return await sequelize.transaction(async transaction => { + const association = await PartnerManagedProfile.findOne({ + lock: Transaction.LOCK.UPDATE, + transaction, + where: { externalUserId: input.externalUserId, partnerId: input.partnerId } + }); + if (association) return existingAssociationResult(association, email, input.subjectType); + + const profileForEmail = await User.findOne({ transaction, where: where(fn("lower", col("email")), email) }); + if (profileForEmail && profileForEmail.id !== authUser.id) { + throw new ManagedProfileServiceError("MANAGED_PROFILE_CONFLICT", "The email is already linked to a different profile"); + } + + const profile = await User.findByPk(authUser.id, { lock: Transaction.LOCK.UPDATE, transaction }); + if (profile && normalizeManagedProfileEmail(profile.email) !== email) { + throw new ManagedProfileServiceError( + "MANAGED_PROFILE_CONFLICT", + "The Auth identity is already linked to a profile with a different email" + ); + } + if (!profile) await User.create({ email, id: authUser.id }, { transaction }); + + const profileAssociation = await PartnerManagedProfile.findOne({ transaction, where: { profileId: authUser.id } }); + if (profileAssociation) { + throw new ManagedProfileServiceError( + "MANAGED_PROFILE_CONFLICT", + "The profile is already used by another partner association" + ); + } + + if (input.subjectType !== "technical") { + await selectActiveCustomerEntity(authUser.id, input.subjectType, transaction); + } + + const created = await PartnerManagedProfile.create( + { + externalUserId: input.externalUserId, + partnerId: input.partnerId, + profileId: authUser.id, + subjectType: input.subjectType + }, + { transaction } + ); + return result(created, email, true); + }); + } catch (error) { + if (error instanceof UniqueConstraintError) { + const association = await PartnerManagedProfile.findOne({ + where: { externalUserId: input.externalUserId, partnerId: input.partnerId } + }); + if (association) return existingAssociationResult(association, email, input.subjectType); + throw new ManagedProfileServiceError( + "MANAGED_PROFILE_CONFLICT", + "The profile is already used by another partner association" + ); + } + throw error; + } +} + +export async function markManagedProfileClaimed(profileId: string): Promise { + const association = await PartnerManagedProfile.findOne({ where: { profileId } }); + if (!association) return null; + if (!association.claimedAt) await association.update({ claimedAt: new Date() }); + return association.subjectType; +} diff --git a/apps/api/src/api/services/partners/partner-pricing.service.test.ts b/apps/api/src/api/services/partners/partner-pricing.service.test.ts index 3d66b712c..88a1c07ff 100644 --- a/apps/api/src/api/services/partners/partner-pricing.service.test.ts +++ b/apps/api/src/api/services/partners/partner-pricing.service.test.ts @@ -3,7 +3,7 @@ import { EvmToken, FiatToken, RampDirection } from "@vortexfi/shared"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; import { createTestPartner, updatePartnerPricing } from "../../../test-utils/factories"; import { QuoteContext } from "../quote/core/types"; -import { resolveDiscountPartner } from "../quote/engines/discount/helpers"; +import { resolveDiscountPartner } from "../phases/blocks/core/discount"; import { findPartnerWithPricing } from "./partner-pricing.service"; describe("findPartnerWithPricing fiat-currency scoping", () => { diff --git a/apps/api/src/api/services/phases/base-phase-handler.test.ts b/apps/api/src/api/services/phases/base-phase-handler.test.ts new file mode 100644 index 000000000..763e576a5 --- /dev/null +++ b/apps/api/src/api/services/phases/base-phase-handler.test.ts @@ -0,0 +1,78 @@ +import { beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import type { RampPhase } from "@vortexfi/shared"; +import FinancialOperation from "../../../models/financialOperation.model"; +import RampState from "../../../models/rampState.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { ReconciliationRequiredPhaseError } from "../../errors/phase-error"; +import type { FlowIdentity } from "./blocks/core/identity"; +import { BasePhaseHandler } from "./base-phase-handler"; + +const flow: FlowIdentity = { + blockSchemaVersions: { payout: 1 }, + catalogVersion: 1, + id: "test-flow", + metadataSchemaVersion: 1, + registrationFactsSchemaVersion: 1, + stateSchemaVersion: 1, + topologyHash: "test-topology", + transactionPlanSchemaVersion: 1, + version: 2 +}; + +const state = { + id: "ramp-1", + state: { flow } +} as RampState; + +class TestFinancialPhaseHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "finalSettlementSubsidy"; + } + + public run(perform: (idempotencyKey: string) => Promise): Promise { + return this.runFinancialOperation(state, { + attemptClass: "test-operation", + perform, + provider: "test-provider", + request: { amount: "10" } + }); + } + + protected async executePhase(rampState: RampState): Promise { + return rampState; + } +} + +beforeAll(async () => { + await setupTestDatabase(); +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +describe("BasePhaseHandler financial operations", () => { + it("derives the ramp and phase identity", async () => { + await new TestFinancialPhaseHandler().run(async () => ({ id: "external-1" })); + + expect(await FinancialOperation.findOne()).toMatchObject({ + flowId: flow.id, + flowVersion: flow.version, + phase: "finalSettlementSubsidy", + scopeId: state.id, + scopeType: "ramp", + status: "confirmed" + }); + }); + + it("translates an ambiguous retry into a reconciliation phase error", async () => { + const perform = mock(async () => { + throw new Error("connection reset after submission"); + }); + const handler = new TestFinancialPhaseHandler(); + + await expect(handler.run(perform)).rejects.toThrow("connection reset after submission"); + await expect(handler.run(perform)).rejects.toBeInstanceOf(ReconciliationRequiredPhaseError); + expect(perform).toHaveBeenCalledTimes(1); + }); +}); 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 49e1ad2e2..b533f872a 100644 --- a/apps/api/src/api/services/phases/base-phase-handler.ts +++ b/apps/api/src/api/services/phases/base-phase-handler.ts @@ -5,10 +5,22 @@ import logger from "../../../config/logger"; import RampState from "../../../models/rampState.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"; +import { + PhaseError, + ReconciliationRequiredPhaseError, + RecoverablePhaseError, + requiresManualReconciliation, + UnrecoverablePhaseError +} from "../../errors/phase-error"; +import { + runFinancialOperation as executeFinancialOperation, + type RunFinancialOperationArgs, + requireFinancialFlowIdentity +} from "./blocks/core/financial-operation"; import { StateMetadata } from "./meta-state-types"; +type RampFinancialOperationArgs = Omit, "scopeType" | "scopeId" | "flow" | "phase">; + /** * Base interface for phase handlers */ @@ -63,16 +75,17 @@ export abstract class BasePhaseHandler implements PhaseHandler { return updatedState; } catch (error) { - logger.error(`Error executing phase ${this.getPhaseName()} for ramp ${state.id}:`, error); + const phaseError = requiresManualReconciliation(error) ? this.createReconciliationRequiredError(error.message) : error; + logger.error(`Error executing phase ${this.getPhaseName()} for ramp ${state.id}:`, phaseError); // Add error to the state - await this.logError(state, error); + await this.logError(state, phaseError); - if (error instanceof PhaseError) { - throw error; + if (phaseError instanceof PhaseError) { + throw phaseError; } - throw new UnrecoverablePhaseError(error instanceof Error ? error.message : "Unknown error in phase execution"); + throw new UnrecoverablePhaseError(phaseError instanceof Error ? phaseError.message : "Unknown error in phase execution"); } } @@ -85,10 +98,31 @@ export abstract class BasePhaseHandler implements PhaseHandler { return new RecoverablePhaseError(message); } + protected createReconciliationRequiredError(message: string): ReconciliationRequiredPhaseError { + return new ReconciliationRequiredPhaseError(message); + } + protected createUnrecoverableError(message: string): UnrecoverablePhaseError { return new UnrecoverablePhaseError(message); } + protected async runFinancialOperation(state: RampState, args: RampFinancialOperationArgs): Promise { + try { + return await executeFinancialOperation({ + ...args, + flow: requireFinancialFlowIdentity(state.state), + phase: this.getPhaseName(), + scopeId: state.id, + scopeType: "ramp" + }); + } catch (error) { + if (requiresManualReconciliation(error)) { + throw this.createReconciliationRequiredError(error.message); + } + throw error; + } + } + /** * Execute the phase implementation * @param state The current ramp state @@ -208,6 +242,6 @@ export abstract class BasePhaseHandler implements PhaseHandler { timestamp: new Date().toISOString() }; - await rampService.appendErrorLog(state.id, errorLog); + await state.update({ errorLogs: [...(state.errorLogs || []), errorLog].slice(-100) }); } } diff --git a/apps/api/src/api/services/phases/blocks/INHERITED-ISSUES.md b/apps/api/src/api/services/phases/blocks/INHERITED-ISSUES.md new file mode 100644 index 000000000..0adffde0c --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/INHERITED-ISSUES.md @@ -0,0 +1,86 @@ +# Inherited Issues + +> This is an implementation-detail supplement, not an exception authority. Active product or +> architecture exceptions are indexed in +> `docs/security-spec/RISK-REGISTER.md`; that register controls status. + +These are active block behaviors inherited during the block migration. The former +quote engines, transaction builders, and concrete phase handlers have been deleted; +references below point only to the current block implementation. "Inherited" records +the behavior's historical origin, not an active legacy code path. + +## Resolved: external side effects and idempotency + +Provider order/ticket creation, Avenia and Mykobo payout broadcasts, Nabla approve/swap +broadcasts, Squid approve/swap broadcasts, Axelar gas payments, pre/post subsidies, and +final-settlement funding operations now claim a durable `financial_operations` row +before the external call. Confirmed results are replayed locally. Ambiguous outcomes +stop for reconciliation instead of repeating the call. Wallet-signed funding operations +pin the nonce across internal RPC retries, and a persisted failed fixed-nonce payout is +unrecoverable rather than rebroadcast. + +The normative protocol, including the fallback for providers without an upstream +idempotency-key facility, is defined in +`docs/security-spec/03-ramp-engine/block-flow-architecture.md`. + +## Validation and completion evidence + +### Destination transaction validation is incomplete + +- **References:** The destination executor validates only the recipient encoded in the presigned native/ERC-20 transfer and skips even that when `destinationAddress` is absent (`phases/destination-transfer/execution.ts:21-55`, `77-86`). +- **Impact:** Execution does not comprehensively bind sender, chain, token contract, amount/value, nonce, and recipient to the registered intent before broadcast. Recipient-only validation cannot establish that the complete transaction matches the quote. +- **Release relevance:** Pre-existing transaction-integrity gap on destination-transfer flows; security-relevant for release review but not introduced by blocks. + +### AssetHub source hash is not proven on-chain + +- **References:** The funding executor requires `assethubToPendulumHash` and checks the stored blueprint's network and signer, but never fetches the hash or proves inclusion, success, or transaction equivalence (`phases/fund-ephemeral/execution.ts:133-145`). +- **Impact:** A reported arbitrary/non-final/failed hash can satisfy the source-hash gate; later balance checks may limit progress, but the hash itself is not evidence of the intended AssetHub transfer. +- **Release relevance:** Inherited trust-boundary weakness. The block adds useful static checks, so this is not a regression, but on-chain provenance remains release-relevant for AssetHub offramps. + +### Resolved: Moonbeam-to-Pendulum positive-balance heuristic + +The executor now waits for Moonbeam source finalization, persists the finalized block +hash, and requires the Pendulum balance to reach the phase-owned `outputAmountRaw`. +Positive dust can no longer suppress submission or satisfy completion by itself. + +### Pendulum-to-AssetHub accepts a persisted hash without arrival proof + +- **References:** A new submission waits for source finalization and the expected + `xTokens.TransferredMultiAssets` event, but the executor returns immediately when + `pendulumToAssethubXcmHash` already exists and does not check AssetHub arrival + (`phases/pendulum-to-assethub-xcm/execution.ts`). +- **Impact:** A persisted finalized source block can advance recovery even if destination + XCM execution fails or AssetHub assets never arrive. +- **Release relevance:** Accepted only for the quote-disabled BRL→AssetHub recovery flow. + It is a blocker before that corridor is re-enabled and is indexed in the normative risk + register. + +## Estimation and recovery heuristics + +### Avenia Base simulation uses a Moonbeam transfer quote + +- **References:** `simulateAveniaMint` requests `AveniaPaymentMethod.MOONBEAM` and returns Base output (`phases/avenia-mint/simulation.ts:35-69`, `82-108`); direct Base mint delegates to it (`phases/avenia-direct-mint/simulation.ts:9-20`). +- **Impact:** Base quote output and fees can be based on the wrong Avenia rail, affecting quoted output, fee accounting, and recovery thresholds when Base and Moonbeam pricing differ. +- **Release relevance:** Pre-existing quote-accuracy issue on Avenia Base routes; visible to users and therefore release-relevant, but not a block adaptation. + +### Recovery uses 95% balance shortcuts + +- **References:** Avenia mint skips at 95% of precomputed output (`phases/avenia-mint/execution.ts:39-42`, `92-103`) and Mykobo deposit does the same (`phases/mykobo-mint/execution.ts:24-49`). +- **Impact:** A partial or unrelated balance at the ephemeral can be treated as completed provider settlement, allowing downstream execution with up to a 5% shortfall before later subsidy/balance logic intervenes. +- **Release relevance:** Pre-existing settlement-evidence and subsidy-exposure heuristic on BRL/EUR onramps; release-relevant under partial-delivery recovery. + +### Pendulum XCM recovery infers submission from balance depletion + +- **References:** Avenia offramp treats a Pendulum balance below the planned transfer amount as evidence tokens already left and suppresses submission (`phases/avenia-pendulum-offramp/execution.ts:45-58`). +- **Impact:** Fees, prior spending, partial balances, or unrelated transfers can be mistaken for an already-submitted XCM, causing the phase to wait for a Moonbeam arrival that will not occur instead of broadcasting the intended transfer. +- **Release relevance:** Pre-existing recovery/liveness risk for Pendulum-to-Moonbeam transfer flows; not introduced by the block implementation. + +## Resolved: cancellation and liveness + +Every catalog-registered block executor accepts the processor's `AbortSignal`. Shared +polling helpers receive it, explicit timers use abort-aware `sleep`, provider and RPC +waits use `abortableCall`, and every durable financial operation checks the signal +before beginning its external call. Multi-call provider operations re-check between +calls. An underlying transport without native cancellation may finish the request it +already started, but the abandoned executor is detached and cannot begin subsequent +work; the financial operation remains `unknown` until reconciliation. diff --git a/apps/api/src/api/services/phases/blocks/README.md b/apps/api/src/api/services/phases/blocks/README.md new file mode 100644 index 000000000..60a9ef619 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/README.md @@ -0,0 +1,604 @@ +# Block-Based Quote Engine + +## Abstract + +A typed, composable **block** model for defining Vortex quote flows. Each +phase declares its input and output `PhaseIO` brands; a +`FlowBuilder.start(...).pipe(...).build(...)` chain enforces **at compile +time** that adjacent phases are compatible — a Base swap cannot feed a +Polygon-only transfer, a phase that bridges to Arbitrum cannot be followed +by a Base-only step. The execution `RampPhase[]` is derived from the flow +(`["initial", ...flow.phases, "complete"]`), and each phase also carries +its **executors** — the execution-side handlers for its `RampPhase`s — and +its **presigned transactions** (`prepareTxs`) — so the strategy, the +execution sequence, the brand-correctness check, the execution logic, and +the transactions the ephemerals/user must sign all live in one place. + +### Design invariants (the ethos) + +1. **Self-contained phases.** A phase owns the complete logic for its step + on **all three** sides of the system: quote simulation (`simulate`), + execution (`executors`, one per declared `RampPhase`), and transaction + preparation (`prepareTxs` — the presigned transactions its executors + expect the ephemeral/user to sign). One corridor is defined once, as + one flow instead of separate quote, phase-order, and transaction-plan + definitions. +2. **Composition only through the typed IO boundary.** A phase sees its + input `PhaseIO` — funds of token `T` on chain `C`, plus signature or + contract-execution data accumulated in `meta` — and produces an output + `PhaseIO`. It knows nothing else: not which phases surround it, not its + position in the flow, and never another phase's `meta`. Anything a + phase needs beyond its input must be derivable from the shared + read-only `PhaseCtx` (request, partner, fees, notes). This applies to + **every leg**: a phase's `simulate`, `executors`, and `prepareTxs` must + all be hermetic and self-contained — `prepareTxs` reads only the + phase's *own* simulated metadata key (e.g. `NablaSwap` reads + `quote.metadata.nablaSwapEvm`, `SquidRouterSwap` reads + `quote.metadata.evmToEvm`) plus corridor-level `PrepareCtx` data, never + another phase's output. This is what makes phases removable, + reorderable, and swappable. +3. **Adjacency mismatches fail at compile time — where the types can carry + it.** `FlowBuilder.pipe` rejects a token- or chain-brand mismatch as a + hard type error. `Phase.simulate` is declared as a *property function* + (not a method) so the check is contravariant under `strictFunctionTypes` + — a union or unbranded output cannot silently disable checking + downstream. This is a strong preference, not an absolute: where full + type-level enforcement would wreck readability, runtime checks and + focused flow tests are the accepted fallback. + +**Scope:** block flows are the production source of truth for quote simulation, +transaction preparation, phase ordering, and executor registration. The catalog + currently maps the direct, Base-destination, and cross-chain BRL/Avenia and + EUR/Mykobo onramps, their Base offramps, plus the AlfredPay flows, + expressed as flow *families* parameterized by destination chain and token +where needed. +Unmapped corridors are rejected during quote creation until their flows are +ported. `BrlOnrampAssethubUsdc` and `BrlOfframpAssethubUsdc` are cataloged for +deterministic persisted-quote preparation and recovery, but an explicit +quote-service gate keeps both BRL↔AssetHub directions disabled. Only AssetHub +USDC is represented; USDT, DOT, AlfredPay, and dormant Hydration variants are excluded. + +**Status:** the block catalog is the sole production source for quote simulation, +ramp lifecycle work, transaction preparation, phase ordering, and executor +registration. `QuoteService` persists `{ globals, blocks }` metadata. Ramp +registration resolves that persisted request, calls `Flow.register`, persists any +same-phase metadata refreshes, and passes phase-owned facts into +`Flow.prepareTxs`. Ramp start resolves the same persisted flow and calls +`Flow.start`. Startup registers only catalog-derived executors before starting +recovery workers. Flow, transaction, registration, lifecycle, executor, wiring, +and corridor scenario tests pin explicit phase arrays and durable transaction-plan +expectations for signer/network nonce lanes, calldata, precision, cleanup, and +recovery. Compatibility fields needed by active ramps are projected from +namespaced phase facts/state; new phase state and native prefunding remain in the +namespaced block shapes. + +--- + +## Detail + +### File layout + +``` +apps/api/src/api/services/phases/blocks/ + README.md # this file + TYPE-SYSTEM.md # walkthrough of the FlowBuilder brand/adjacency type system + INHERITED-ISSUES.md # active risks retained by the block implementation + core/ + types.ts # PhaseIO, Phase, Flow, PhaseCtx, PrepareCtx, TxIntent + io.ts # typed fiat/EVM/AssetHub request resolvers, evmIO + metadata.ts # simulation context descriptors and accessors + flow.ts # FlowBuilder + metadata accumulation + combinators.ts # branch(), passthrough() + fees.ts # computeFees(ctx) + phase-flow.ts # assemblePhaseFlow(flow) -> RampPhase[] + prepare.ts # nonce allocation + native prefunding aggregation + quote.ts # production simulation, validation, persistence + quote-response.ts # public response from flow metadata + register.ts # persisted-flow assertion/preparation adapter + settlement.ts # structural settlement baseline helpers + flows/catalog.ts # authoritative request -> flow mapping + register-handlers.ts # catalog-derived executor registration + phases/ # one directory per block-owned phase + alfredpay-{mint,offramp}/ # provider lifecycle and execution + avenia-{direct-mint,mint,moonbeam-mint}/ # BRL onramp variants + avenia-{offramp-fee,offramp-payout,pendulum-offramp}/ + mykobo-{mint,offramp-fee,offramp-payout}/ # EUR provider phases + {evm,assethub}-offramp-source/ # source validation and tx plans + fund-ephemeral/ # EVM/Substrate funding and source-hash checks + nabla-swap/ # EVM Nabla simulation, txs, and executors + pendulum-{nabla-swap,offramp-nabla-swap}/ # Pendulum Nabla variants + distribute-fees/ # EVM fee distribution + pendulum-distribute-fees/ # Pendulum fee distribution + {subsidize-pre,subsidize-post,final-settlement-subsidy}/ + pendulum-{subsidize-pre,subsidize-post,offramp-subsidize-pre,offramp-subsidize-post}/ + squid-router-swap/ # passthrough, same-chain, and bridge variants + {moonbeam-to-pendulum-xcm,pendulum-to-assethub-xcm}/ + destination-transfer/ # destination delivery + flows/ + alfredpay-onramp-direct.ts # Polygon passthrough/same-chain family + alfredpay-onramp-cross-chain.ts # makeAlfredpayOnrampCrossChainFlow(toChain, toToken) + alfredpay-offramp.ts # supported EVM source -> AlfredPay fiat family + brl-onramp-base-direct.ts # BRL -> BRLA on Base + brl-onramp-base-same-chain.ts # Base USDC passthrough and routed Base outputs + brl-onramp-base-cross-chain.ts # makeBrlOnrampBaseCrossChainFlow(toChain, toToken) + brl-onramp-assethub-usdc.ts # disabled public corridor, retained for recovery + brl-offramp-base.ts # supported EVM source -> BRL on Base + brl-offramp-assethub-usdc.ts # disabled public corridor, retained for recovery + eur-onramp-base-direct.ts # EUR -> EURC on Base + eur-onramp-base-same-chain.ts # Base USDC passthrough and routed Base outputs + eur-onramp-base-cross-chain.ts # makeEurOnrampBaseCrossChainFlow(toChain, toToken) + eur-offramp-base.ts # supported EVM source -> EUR on Base + __tests__/ + brl-onramp-base-same-chain.flow.test.ts # Base variant topology, executors, and simulation + brl-onramp-base-same-chain.transactions.test.ts # Base variant tx/state/nonce expectations + brl-onramp-base-cross-chain.flow.test.ts # structure, executors, adjacency, and simulation + brl-onramp-base-cross-chain.transactions.test.ts# unsignedTxs and namespaced state expectations + eur-onramp-base-direct.flow.test.ts + eur-onramp-base-direct.registration.test.ts + eur-onramp-base-direct.transactions.test.ts + eur-onramp-base-cross-chain.flow.test.ts + eur-onramp-base-cross-chain.registration.test.ts + eur-onramp-base-cross-chain.transactions.test.ts + eur-offramp-base.flow.test.ts + eur-offramp-base.transactions.test.ts + alfredpay-onramp.lifecycle.test.ts # start-time quote refresh and order creation + alfredpay-offramp.lifecycle.test.ts # registered-order start behavior + *.executor.test.ts # block-owned execution behavior + wiring.test.ts # catalog executor registry coverage +``` + +### Core types (`core/types.ts`) + +```ts +export type TokenBrand = string; +export type ChainBrand = string; + +export interface PhaseIO { + amount: Big; // human-readable decimal + amountRaw: string; // integer-string raw at token's decimals + requestInputAmountUsd?: Big; // source valuation carried across offramp phases + token: Token; + chain: Chain; +} + +export interface Phase { + readonly context: Context; + readonly name: string; + readonly phases: RampPhase[]; // declared execution expansion + readonly simulate: (input: I, ctx: PhaseCtx) => + Promise>>; + readonly executors?: PhaseHandler[]; // one per entry in `phases`, same order + readonly prepareTxs?: (ctx: PrepareCtx>) => + Promise; + readonly register?: (ctx: RegisterCtx>) => + Promise; + readonly start?: (ctx: StartCtx>) => + Promise; +} + +export interface Flow { + readonly name: string; + readonly phases: RampPhase[]; // flatMap(p => p.phases) + readonly executors: PhaseHandler[];// flatMap(p => p.executors ?? []) + simulate(ctx: PhaseCtx): Promise<{ expiresAt?: Date; metadata: FlowMetadata; output: O }>; + register(ctx: FlowRegisterCtx): Promise; + prepareTxs(ctx: FlowPrepareCtx): Promise; + start(ctx: FlowStartCtx): Promise; +} +``` + +`Token` and `Chain` are instantiated with literal types drawn from the +string enums (`EvmToken`, `FiatToken`, `AssetHubToken`) and `Networks` / +`"fiat"`. Brands are kept as `extends string` so literal narrowing flows +through generics. Note the brands are structural (string values), not +nominal: `EvmToken.USDC` and `AssetHubToken.USDC` are the same literal +type, so the **chain** brand carries the discrimination between +ecosystems. + +`simulate` is a **property function type**, not a method — under +`strictFunctionTypes` this makes `pipe`'s input check contravariant, so a +phase whose declared output degrades to unbranded `PhaseIO` (or a union) +cannot be followed by a narrower-input phase without a compile error. + +### `FlowBuilder` (`core/flow.ts`) + +Compile-time adjacency is enforced via a **builder**, not a variadic +`flow()` function. The builder's `.pipe(next)` is a single method signature +with no overload fallback to escape to, so a brand mismatch is a hard type +error. + +```ts +export class FlowBuilder { + private constructor(private readonly phaseList: AnyPhase[]) {} + + static start( + inputResolver: FlowInputResolver, + first: AnyPhase & { simulate: (input: First, ctx: PhaseCtx) => Promise> } + ): FlowBuilder; + + pipe( + next: AnyPhase & { simulate: (input: O, ctx: PhaseCtx) => Promise> } + ): FlowBuilder; + + build(name: string): Flow; +} +``` + +The builder tracks a single type: the output of the most recently composed +phase. `pipe` checks the argument's `simulate` against it contravariantly and +infers the next output from the covariant return position. Simulation +metadata, registration facts, and registration input are typed per phase and +erased at the flow level (the catalog returns bare `Flow`, so nothing +downstream consumed them). + +The internal list is stored as a type-erased `AnyPhase[]` whose simulation +input is `never`. Any concrete phase is assignable to it under +contravariance, but it cannot be called without a cast. The single +`input as never` in `build()`'s simulation loop is the handoff to the +adjacency guarantee that `pipe` already enforced. + +`build()` rejects duplicate context keys at construction time; flows are +built at module load, so a duplicate key fails startup and every test run +immediately. + +A phase may return a replacement fee snapshot when its provider quote is the +source of an anchor fee. `Flow.simulate` installs that snapshot before the next +phase and persists the final value in `globals.fees`. AlfredPay uses this +because its fiat anchor fee is only known after `AlfredpayMint.simulate` calls +the provider. + +Runtime: `build()` stores the phases; `Flow.simulate(ctx)` runs +`computeFees(ctx)`, builds the first input via the source-aware resolver, then +sequentially calls `phase.simulate(prevOutput, ctx)`. `Flow.phases` = +`flatMap(p => p.phases)`; `Flow.executors` = `flatMap(p => p.executors)`. +Fiat, EVM, and AssetHub request resolvers validate token/chain at runtime; +on-chain resolvers convert request decimals to configured integer raw units. + +### Executors (the execution side) + +Each phase directory's `execution.ts` defines block-owned executor class(es) +extending `BasePhaseHandler`, one per `RampPhase` the phase declares. +`Flow.executors` therefore lines up 1:1 with `Flow.phases`, as asserted by the +flow and wiring tests. Registration is catalog-derived: + +```ts +flow.executors.forEach(executor => phaseRegistry.registerHandler(executor)); +``` + +Startup registers these executors from the flow catalog. There is no separate +concrete-handler registry. EVM, Substrate/XCM, provider mint/payout, funding, +subsidy, swap, fee distribution, final settlement, and destination delivery +executors all live beside their phase under `phases/`. `BlockInitialExecutor` +owns the `initial` phase and advances according to the persisted flow sequence. + +Executors read their block's context from `quote.metadata.blocks`. Shared +quote facts live under `quote.metadata.globals`; untyped preparation outputs +live under the owning entry in `state.state.blockState`. Cross-block resource +needs are structural flow data rather than block dependencies: transaction +intents declare native prefunding and the flow aggregates it into +`state.state.transactionPlan`. + +### Transaction preparation (the third leg) + +Phase directories that require presigned transactions carry a `transactions.ts` file defining +the presigned transactions its executors consume, wired into the phase +factory as `prepareTxs`. No corridor-level transaction builder re-resolves the +route. + +**Requirements (same ethos as invariant 1 & 2):** + +- A phase's `prepareTxs` is **hermetic**: `Flow.prepareTxs` passes its + typed `ownMetadata`, explicit globals, and `ownRegistrationFacts`. It never + receives another phase's metadata and never knows its position in the flow. +- Registration is optional and phase-owned. `Flow.register` namespaces facts + and response artifacts by context key, and a phase may refresh only its own + metadata. Registration receives a read-only quote, authenticated user, + normalized input, signing accounts, and optional DB transaction/IP context. +- Preparation account capabilities are keyed by `EphemeralAccountType`. + EVM phases explicitly require EVM; destination address is optional in core. +- Phases whose executors sign live (funding-account subsidies, Avenia + API calls, bridge gas payment) simply omit `prepareTxs`. +- **Nonces are a flow-level resource**, so no phase picks its own nonce. + `prepareTxs` returns nonce-free `TxIntent`s; `Flow.prepareTxs` collects + them in flow order and `allocateNonces` (core/prepare.ts) assigns + nonces per `(network, signer)` in three lanes: + +- **Native prefunding is a flow-level resource.** An intent may declare + `prefundNativeValueRaw`; the flow sums those values per `(network, signer)` + after every phase has prepared. `FundEphemeral` consumes that generic plan + without knowing which phase requested the funds. + + | Lane | Meaning | Nonce position | + |------|---------|----------------| + | `main` | txs an executor broadcasts on the happy path | sequential, flow order | + | `backup` | contingency txs (bridge-failure re-swap, recovery approval) | after all main txs on that network | + | `cleanup` | post-`complete` dust sweeps | last on that network | + + An intent may set `reuseFirstMainNonce` to pin itself to the first + main-lane nonce on its network — production's `backupApprove` trick, so + a recovery tx can never be stranded behind an unreachable nonce. + `nonceSpan` reserves consecutive nonces (default `1`); it must be a positive + safe integer and cannot be combined with `reuseFirstMainNonce`. + +**Transaction ownership mapping:** + +| Presigned tx | Lane | Owning phase | Rationale | +|--------------|------|--------------|-----------| +| `nablaApprove`, `nablaSwap` | main | `NablaSwap` | its executors broadcast them; amounts from `nablaSwapEvm` | +| `distributeFees` | main | `DistributeFees` | fee amounts from `quote.metadata.fees` | +| `squidRouterApprove`, `squidRouterSwap` | main | `SquidRouterSwap` | bridge input from `evmToEvm` | +| `destinationTransfer` | main | `DestinationTransfer` | delivers `quote.outputAmount` to the user | +| `backupSquidRouterApprove`, `backupSquidRouterSwap`, `backupApprove` | backup | `SquidRouterSwap` | contingency for *its* bridge falling short | +| `baseCleanupBrla` | cleanup | `AveniaMint` | sweeps dust of the token *it* minted | +| `baseCleanupUsdc` | cleanup | `NablaSwap` | sweeps dust of *its* swap output | +| `polygonCleanup`, `alfredOnrampMintFallback` | cleanup | `AlfredpayMint` | cleanup and contingency transfer for its minted token | + +For AlfredPay, `SquidRouterSwap` owns the Polygon source approve/swap and the +destination backup lane, using the phase-owned +`squidRouterSwap.inputAmountRaw` consistently. + +`Flow.prepareTxs` assembles corridor-level fields (`destinationAddress`, +`evmEphemeralAddress`, `phaseFlow`, and static flow facts) separately from +owned `blockState`. Avenia owns its tax identifier, Nabla owns its soft +minimum, and Squid owns its route identifiers. Native call value belongs to +the flow-level transaction plan. + +### `assemblePhaseFlow` (`core/phase-flow.ts`) + +```ts +export function assemblePhaseFlow(flow: Flow): RampPhase[] { + return ["initial", ...flow.phases, "complete"]; +} +``` + +That's the whole thing. No phase-name knowledge, no route flags, no +`branch` logic. The developer pipes every step (funding, fee distribution, +subsidy, settlement, delivery) into the flow explicitly. Verbosity in flow +definitions is the deliberate tradeoff: a corridor's full execution shape +is readable top-to-bottom in one file. + +### `branch()` and `passthrough()` (`core/combinators.ts`) + +Kept as available primitives but **not relied upon**: destination variants +are expressed as a flow *family* (a factory over brands) rather than +runtime branches. Reach for `branch` only when a flow genuinely needs to +fork at simulate time; prefer separate flows otherwise. Note `branch`'s +static `phases` union is only valid when all branches expand to the same +`RampPhase` list. + +### Representative phase catalog + +Every step in a corridor — including the "bookend" steps (funding, fee +distribution, subsidy, final settlement, delivery) — is a first-class +`Phase` carrying its simulation context, `simulate`, and +executors. The flow assembles them linearly. The table shows the common EVM +building blocks; provider, offramp-source, Pendulum, and XCM blocks follow the +same contract under `phases/`. + +| Phase | Metadata key | `phases` | +|-------|--------------|----------| +| `AlfredpayMint` | `alfredpayMint` | `["alfredpayOnrampMint"]` | +| `AveniaMint` | `aveniaMint` | `["brlaOnrampMint"]` | +| `MykoboMint` | `mykoboMint` | `["mykoboOnrampDeposit"]` | +| `FundEphemeral(token, chain)` | `fundEphemeral` | `["fundEphemeral"]` | +| `SubsidizePre()` | `subsidizePreSwap` | `["subsidizePreSwap"]` | +| `NablaSwap(chain, in, out)` | `nablaSwap` | `["nablaApprove", "nablaSwap"]` | +| `DistributeFees()` | `distributeFees` | `["distributeFees"]` | +| `SubsidizePost()` | `subsidizePostSwap` | `["subsidizePostSwap"]` | +| `SquidRouterSwap(from, to, fromToken, toToken)` | `squidRouterSwap` | `["squidRouterSwap", "squidRouterPay"]` | +| `FinalSettlementSubsidy()` | `finalSettlementSubsidy` | `["finalSettlementSubsidy"]` | +| `DestinationTransfer()` | `destinationTransfer` | `["destinationTransfer"]` | + +`SquidRouterSwap` derives the bridge target from its **own** `toToken` / +`toChain` args (not from `ctx.request.outputCurrency`) — the phase carries +its complete contract in its signature. + +`MykoboMint.register` derives the approved Mykobo customer from the authenticated +user, creates the provider deposit intent against the Base EVM ephemeral, and +returns IBAN response artifacts plus facts namespaced under `mykoboMint`. +`MykoboMint.prepareTxs` receives only those own registration facts, persists them +under `blockState.mykoboMint`, and owns the Base EURC cleanup approval where the +route can leave EURC dust. The direct Base EURC route emits no cleanup intent. + +### The flow family (`flows/brl-onramp-base-cross-chain.ts`) + +The destination chain/token vary per request (`quote.to` / +`quote.outputCurrency`), so the corridor is a factory; the derived +`RampPhase[]` is identical for every destination: + +```ts +export function makeBrlOnrampBaseCrossChainFlow( + toChain: ToChain, + toToken: ToToken +): Flow { + return FlowBuilder.start(fiatRequestIO(FiatToken.BRL), AveniaMint) + .pipe(FundEphemeral(EvmToken.BRLA, Networks.Base)) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.BRLA, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(SquidRouterSwap(Networks.Base, toChain, EvmToken.USDC, toToken)) + .pipe(FinalSettlementSubsidy()) + .pipe(DestinationTransfer()) + .build("BrlOnrampBaseCrossChain"); +} +``` + +### Derived `RampPhase[]` + +`assemblePhaseFlow(flow)` deep-equals the explicit expected array in the block test for +every destination instantiation: + +``` +["initial", "brlaOnrampMint", "fundEphemeral", "subsidizePreSwap", + "nablaApprove", "nablaSwap", "distributeFees", "subsidizePostSwap", + "squidRouterSwap", "squidRouterPay", "finalSettlementSubsidy", + "destinationTransfer", "complete"] +``` + +The bookend `["initial", ..., "complete"]` is the only thing +`assemblePhaseFlow` adds. Everything else is declared by the flow itself. + +### Verification + +The `*.flow.test.ts` and `*.transactions.test.ts` suites encode the checks +every cataloged corridor must pass: + +1. **Structural** — `flow.phases` equals the expected core phases array. +2. **Phase sequence** — `assemblePhaseFlow(flow)` deep-equals an explicit + expected sequence, including other destinations of a flow family. +3. **Executor coverage** — `flow.executors.map(e => e.getPhaseName())` + deep-equals `flow.phases`: every execution phase has exactly one + executor, in order. +4. **Compile-time adjacency, build-time ownership** — `// @ts-expect-error` + blocks cover wrong token/chain adjacency; they are type-checked by tsc, + so if the brand guard were broken the directives would be unused and + `bun typecheck` would fail. Duplicate metadata keys are pinned by a + runtime test asserting `build()` throws. +5. **Simulate smoke** — with externals mocked (`BrlaApiService`, + `calculateNablaSwapOutputEvm`, `calculateEvmBridgeAndNetworkFee`, + `priceFeedService`), `simulate(ctx)` lands on the destination + token/chain with `amount > 0`. +6. **Metadata ownership** — simulation returns explicit globals and exactly + one typed context for each block in flow order. Subsidy contexts remain + distinct. +7. **Transaction plans** — `*.transactions.test.ts` files assert the full + `UnsignedTx[]` contract (phase, network, nonce, signer, and transaction + data), namespaced `blockState`, cleanup/recovery lanes, and native + prefunding without importing a second transaction assembler. +8. **Lifecycle and execution** — registration, lifecycle, and executor tests + cover provider operations and phase behavior; wiring tests assert that the + catalog supplies exactly one executor implementation per runtime phase. + +### Metadata ownership + +`PhaseIO` contains only typed monetary IO. Every block defines a local +`ContextMetadata` descriptor binding its key to its simulation type. `Phase` +carries that descriptor and returns exactly one simulation context; only +`Flow.simulate` accumulates those contexts into `{ globals, blocks }`. +Typed metadata access goes through context descriptors +(`getBlockMetadata(metadata, SomeContext)`) without a global key/type +registry. A phase cannot read previous metadata, and duplicate keys are +rejected when the flow is built at module load. Preparation and execution use +type-erased iteration internally, but the persisted envelope is bound to a +stable flow ID/version, topology hash, and per-context schema versions. +Runtime checks reject an incompatible identity, phase sequence, context set, +block-state envelope, or transaction plan before lifecycle hooks run. Tests +supplement these checks; they are not the runtime data boundary. Blocks never +declare dependencies on other block identities. Persisted decimal metadata +uses JSON-safe scalar unions, so consumers explicitly construct `Big` values +after loading JSONB. Fees, request data, and partner data are explicit globals +rather than metadata installed by the first block. +Offramp source phases carry the request's bridged USD valuation through the +typed `PhaseIO` boundary so downstream subsidy math does not read another +phase's metadata. + +The three subsidy phases independently call `computeExpectedOutput(ctx)` +and persist distinct contexts. Their values no longer overwrite one shared +`subsidy` key. + +### Conventions (non-negotiable) + +- `bun`, never npm/yarn/pnpm. Run `bun lint:fix` then `bun typecheck` from + the repo root. +- Biome: line width 128, 2-space indent, semicolons always, double quotes, + no trailing commas. +- DO NOT add comments unless this doc explicitly asks. No docstrings on + code you didn't touch. +- Keep block behavior under `blocks/`; shared ramp-state typing may be + extended only for generic flow infrastructure such as `blockState` and + `transactionPlan`. +- No over-engineering: no abstractions for single-use code, no error + handling for impossible scenarios, no input validation for typed internal + params. +- `FiatToken` has 6 values (EURC, ARS, BRL, USD, MXN, COP); any + `Record` must include all six. +- Mimic the import style of neighboring files. + +### Brand values (enum member string values — keep adjacency consistent) + +| Enum | Member | Value | +|------|--------|-------| +| `FiatToken` | `BRL` | `"BRL"` | +| `FiatToken` | `EURC` | `"EUR"` | +| `EvmToken` | `BRLA` | `"BRLA"` | +| `EvmToken` | `EURC` | `"EURC"` | +| `EvmToken` | `USDC` | `"USDC"` | +| `EvmToken` | `USDT` | `"USDT"` | +| `Networks` | `Base` | `"base"` | +| `Networks` | `Arbitrum` | `"arbitrum"` | +| `Networks` | `Polygon` | `"polygon"` | + +**Gotcha:** `FiatToken.BRL` is `"BRL"` but `EvmToken.BRLA` is `"BRLA"` +(and `FiatToken.EURC` is `"EUR"` vs `EvmToken.EURC` `"EURC"`) — different +strings, so the brands are distinct types. This is what makes the +fiat→EVM boundary in `AveniaMint` / `MykoboMint` type-check: the output +brand genuinely differs from the input brand, and only the mint phase's +declared signature bridges them. + +### Factory function call forms (TS has no generic const values) + +| Export | Form | Why | +|--------|------|-----| +| `AveniaMint` | plain `const` (no generics) | no runtime variability | +| `MykoboMint` | plain `const` (no generics) | no runtime variability | +| `FundEphemeral(token, chain)` | generic **function with runtime args** | executor needs the runtime chain | +| `NablaSwap(chain, in, out)` | generic function with runtime args | needs runtime values for `getOnChainTokenDetails` | +| `SquidRouterSwap(from, to, fromToken, toToken)` | generic function with runtime args | needs runtime values for the bridge request; target token is the phase's own arg | +| `DistributeFees()` | type-args only | reads from `ctx.fees` | +| `SubsidizePre()` | type-args only | ctx-derived | +| `SubsidizePost()` | type-args only | ctx-derived | +| `FinalSettlementSubsidy()` | type-args only | ctx-derived | +| `DestinationTransfer()` | type-args only | pure passthrough in simulation | +| `passthrough()` | type-args only | pure no-op | +| `branch(select, branches)` | generic function | runtime decision point | + +**Brands are always enum member types** (`typeof EvmToken.BRLA`, +`typeof Networks.Base`), never plain string literals — keep this consistent +so adjacency matches. + +### Current catalog boundaries + +Unmapped cases fail at quote resolution; there is no alternate engine: + +1. **Generic BRL/EUR onramp discounts.** `SubsidizePost` resolves the active + corridor pricing config, applies the dynamic partner difference, and converts the + oracle target into pre-bridge Base USDC using SquidRouter. Its typed input follows + `DistributeFees`, so the actual amount already has network, vortex, and partner + markup fees deducted without reading another block's metadata. AlfredPay retains + its specialized pre-bridge subsidy path. +2. **BRL onramp fees.** `AveniaMint` replaces the anchor fee with the + live mint/transfer fees and installs the Squid network fee before + `DistributeFees`; direct BRLA and Base USDC routes keep a zero network fee. +3. **Executors cover the cataloged corridors.** EVM, Substrate/XCM, BUY, and + SELL behavior is composed from the phase implementations selected by each + flow. +4. **Some executors keep compatibility cross-phase metadata reads** (e.g. + `subsidizePostSwap` topping up to `evmToEvm.inputAmountRaw`) where active + persisted ramp state still uses corridor-level fields. +5. **`NablaSwap` runtime is Base-only.** `calculateNablaSwapOutputEvm` + hardcodes `Networks.Base`; the `chain` arg is used for branding/IO. +6. **`PartnerInfo` import source.** Not exported from `@vortexfi/shared`; + `core/types.ts` imports it from `../../core/types` (read-only). +7. **BRL Base variants are statically selected.** Base USDC omits Squid and + uses `BRL_ONRAMP_BASE_SAME_CHAIN`; other configured Base outputs use the + one-phase `SameChainSquidRouterSwap` block and + `BRL_ONRAMP_BASE_SAME_CHAIN_SWAP`. The latter emits source approve/swap + transactions only, with destination transfer at the next nonce and no + Squid pay, backup bridge, or final-settlement work. +8. **EUR Base variants use the same static split.** Base EURC is owned only by + `EurOnrampBaseDirect`; Base USDC uses `EUR_ONRAMP_BASE_SAME_CHAIN` without + Squid; Base USDT, ETH, AXLUSDC, and BRLA use + `EUR_ONRAMP_BASE_SAME_CHAIN_SWAP` with one Base-built same-chain Squid swap + immediately before destination transfer. No same-chain variant includes + Squid pay, backups, or final settlement. + +### Runtime ownership + +There are no parallel quote engines, route strategies, corridor transaction +assemblers, static flow definitions, or concrete handler implementations. +`flows/catalog.ts` resolves the request once; `Flow.simulate`, `Flow.register`, +`Flow.prepareTxs`, and `Flow.start` iterate the same ordered phase list; and +`register-handlers.ts` derives the runtime registry from catalog executors. +`RampService` adapts namespaced registration facts and response artifacts only +where persisted-ramp or API compatibility requires top-level fields. diff --git a/apps/api/src/api/services/phases/blocks/TYPE-SYSTEM.md b/apps/api/src/api/services/phases/blocks/TYPE-SYSTEM.md new file mode 100644 index 000000000..189fbd647 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/TYPE-SYSTEM.md @@ -0,0 +1,328 @@ +# The Flow Type System + +`FlowBuilder` checks the boundaries that make blocks composable at compile +time: source resolver/first-phase adjacency and phase input/output adjacency. +Simulation metadata ownership (no duplicate context keys) is enforced at +`build()` time, which runs at module load. Metadata, registration facts, and +registration input are typed per phase but erased at the flow level — the +catalog returns bare `Flow`, so nothing downstream consumed those types. +Preparation, start lifecycle, and execution remain implementation concerns verified by tests. + +## 1. Monetary IO uses literal brands + +```ts +export interface PhaseIO { + amount: Big; + amountRaw: string; + token: Token; + chain: Chain; +} +``` + +`PhaseIO<"BRLA", "base">` and `PhaseIO<"USDC", "base">` differ because +their token properties are different string literal types. When token values +overlap across ecosystems, the chain literal provides the discrimination. + +Metadata is intentionally not carried in `PhaseIO`. It is owned by the block +that produces it and accumulated separately by the flow. + +## 2. A context types simulation metadata only + +```ts +export interface ContextMetadata { + readonly key: Key; + readonly [simulationType]: Simulation; +} + +export const SquidRouterSwapContext = + defineContext()("squidRouterSwap"); +``` + +The runtime descriptor contains the key. The symbol property carries the +simulation type for TypeScript. Contexts do not declare preparation types, +runtime types, or dependencies on other blocks. + +## 3. `Phase.simulate` is the typed boundary + +```ts +export interface Phase { + readonly context: Context; + readonly simulate: ( + input: I, + ctx: PhaseCtx + ) => Promise>>; + readonly prepareTxs?: ( + ctx: PrepareCtx> + ) => Promise; + readonly register?: ( + ctx: RegisterCtx> + ) => Promise; + readonly start?: ( + ctx: StartCtx> + ) => Promise; + readonly executors?: PhaseHandler[]; +} +``` + +`simulate` is a function-typed property rather than a method. Under +`strictFunctionTypes`, its input is checked contravariantly, so a phase cannot +silently accept the wrong token or chain through bivariant method parameters. + +Each simulation returns two independent values: + +```ts +interface PhaseResult { + output: O; + metadata: Metadata; +} +``` + +`output` feeds the next block. `metadata` is stored under the block's context +key and never enters the next block's `PhaseIO`. + +## 4. The builder tracks one thing + +```ts +FlowBuilder +``` + +`O` is the output type of the most recently composed block. It is the only +type-level state the builder carries: it is what the next `pipe` checks its +phase against. Starting with Avenia produces: + +```ts +FlowBuilder> +``` + +Simulation metadata, registration facts, and registration input are not +accumulated. Each phase types them locally (section 7), and the flow handles +them as `Record` keyed by context key. + +## 5. `start` and `pipe` check adjacency + +Both signatures state their boundary check directly — the next output is +inferred from the covariant return position of the argument's `simulate`, +and the input side is checked contravariantly in place: + +```ts +static start( + inputResolver: FlowInputResolver, + first: AnyPhase & { simulate: (input: First, ctx: PhaseCtx) => Promise> } +): FlowBuilder; + +pipe( + next: AnyPhase & { simulate: (input: O, ctx: PhaseCtx) => Promise> } +): FlowBuilder; +``` + +In `start`, `First` may be inferred from either argument; soundness does not +depend on which one wins. Whatever `First` resolves to, the call only checks +if the resolver's output is assignable to `First` (covariant) and `First` is +assignable to the phase's input (contravariant) — together forcing resolver +output ⊆ phase input. The requirement is assignability, not type equality. + +For example, `AlfredpayMint` accepts four fiat tokens: + +```ts +type AlfredpayOnrampFiat = "ARS" | "COP" | "MXN" | "USD"; +type InputOfAlfredpayMint = PhaseIO; +``` + +A resolver restricted to USD can safely feed that phase: + +```ts +const resolver = fiatRequestIO(FiatToken.USD); +type ResolverIO = PhaseIO<"USD", "fiat">; + +FlowBuilder.start(resolver, AlfredpayMint); // valid +``` + +`ResolverIO` and `InputOfAlfredpayMint` are not equal, but every value the +resolver can produce is accepted by the phase. The reverse relationship would +be unsafe: a resolver that may produce a token outside the phase's accepted +union is rejected, because under `strictFunctionTypes` the phase's `simulate` +property is checked contravariantly in its input. + +`pipe` performs the corresponding check between the previous phase's output +and the next phase's input. A successful call advances `O`. + +Metadata key ownership is no longer a compile-time constraint. `build()` +walks the phase list and throws on a duplicate context key; since every flow +is constructed at module load, a duplicate key fails the process (and any +test run) immediately. + +The skipped compile-time test pins wrong-token and wrong-chain failures with +`@ts-expect-error`. If a guard stops working, the directive becomes unused +and typecheck fails. Duplicate keys are pinned by a runtime test against +`build()`. + +## 6. Runtime storage is deliberately erased + +The builder must place heterogeneous phases in one array. `AnyPhase` therefore +uses a `never` simulation input: + +```ts +type AnyPhase = { + readonly context: AnyContextMetadata; + readonly simulate: ( + input: never, + ctx: PhaseCtx + ) => Promise>; + // execution and preparation fields omitted here +}; +``` + +Under contravariance, every concrete phase can be stored in this shape, but +the erased function cannot be called with a real value. The simulation loop +contains the single cast justified by prior composition: + +```ts +for (const phase of phaseList) { + const result = await phase.simulate(current as never, ctx); + blocks[phase.context.key] = result.metadata; + current = result.output; +} +``` + +The built `Flow` retains its final output type even though the internal +phase array and the accumulated metadata map are erased. Typed access to a +block's metadata goes through its context descriptor +(`getBlockMetadata(metadata, SomeContext)`), the same path executors use. + +## 7. Registration, preparation, and start + +Registration introduces two types distinct from simulation IO and metadata: + +```ts +interface RegisterCtx> { + authenticatedUser: Readonly<{ id: string }>; + input: Readonly; + metadata: Readonly; + quote: Readonly; + signingAccounts: readonly AccountMeta[]; + ipAddress?: string; + transaction?: Transaction; +} + +interface RegistrationResult { + facts: RegistrationFacts; + metadata?: Metadata; + responseArtifacts?: Readonly>; +} +``` + +- `RegistrationInput` is caller-supplied data required in addition to the + quote, such as an email address, wallet address, PIX destination, or tax ID. + It is potentially untrusted even when an API boundary has normalized it. A + registering phase must validate the fields it consumes before using them. +- `RegistrationFacts` is trusted data derived by the system during + registration, after validation or a provider operation. Examples include a + normalized tax ID, a validated destination, or a provider transaction ID. + Later phases do not accept these values directly from caller input. + +Each registering phase declares both types locally: + +```ts +Phase +``` + +They type the phase's own `register` and `prepareTxs` pairing and nothing +else. At the flow level both are erased: `Flow.register` accepts the shared +caller input as `Record` — consistent with it being +untrusted, since every registering phase must runtime-validate the fields it +consumes regardless — and namespaces the resulting facts by context key at +runtime: + +```ts +// Facts produced by two registering phases +{ + evmOfframpSource: { userAddress: string }; + aveniaOfframpPayout: { brlaEvmAddress: string; pixDestination: string }; +} +``` + +`Phase.register` is optional. It receives the shared potentially untrusted +input, authenticated-user context, signing accounts, the read-only quote, +optional transaction/IP data, and only its own simulation metadata. +`Flow.register` collects each phase's trusted facts under that phase's context +key, applies optional same-phase metadata refreshes, and namespaces response +artifacts. During preparation, a phase receives only its own facts as +`ownRegistrationFacts`; facts are never passed through another phase's input. + +After simulation, `Flow.prepareTxs` gives each block its own simulation +metadata, only its own registration facts, and generic account capabilities +keyed by `EphemeralAccountType`. It then collects the implementation-defined result: + +```ts +interface PreparedPhaseTxs { + intents: TxIntent[]; + state?: unknown; +} +``` + +State is stored under `blockState[context.key]`, but its shape is not inferred +by `FlowBuilder`. Executors may assert their own local state type when reading +it. There is no context-level dependency graph and no block may require +another block by identity. + +`Phase.start` is also optional and phase-owned. `Flow.start` walks the same +ordered phase list after all required transactions have been signed, passing +only the phase's simulation metadata and namespaced preparation state together +with read-only quote/ramp context. A start hook may refresh only its own +metadata, return compatibility response artifacts, and update ramp state. +AlfredPay uses this hook for start-time quote refresh and onramp order creation; +there is no provider-specific start dispatcher in `RampService`. + +## 8. Shared resources are aggregated structurally + +Nonce assignment and native prefunding are flow-level resources. A transaction +that needs native call value declares it on its intent: + +```ts +interface TxIntent { + network: Networks; + signer: string; + lane: "main" | "backup" | "cleanup"; + prefundNativeValueRaw?: string; + nonceSpan?: number; + // phase and transaction data omitted +} +``` + +After every block prepares, the flow sums `prefundNativeValueRaw` by +`(network, signer)` and persists: + +```ts +state.transactionPlan.nativePrefunding[`${network}:${signer.toLowerCase()}`] +``` + +`FundEphemeral` consumes that generic plan and funds only the difference +between the current native balance and: + +```text +fixed gas reserve + aggregated native prefunding +``` + +The funding block does not know which block requested the value. Any future +block can participate by emitting the same structural intent field. + +## 9. Concrete BRL flow + +```ts +FlowBuilder.start(fiatRequestIO(FiatToken.BRL), AveniaMint) // BRLA on Base + .pipe(FundEphemeral(EvmToken.BRLA, Networks.Base)) // unchanged + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.BRLA, EvmToken.USDC)) // USDC on Base + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(SquidRouterSwap(Networks.Base, toChain, EvmToken.USDC, toToken)) // destination IO + .pipe(FinalSettlementSubsidy()) + .pipe(DestinationTransfer()) + .build("BrlOnrampBaseCrossChain", { isDirectTransfer: false }); +``` + +The type system guarantees the simulation chain; `build()` guarantees +metadata ownership at module load. Flow and transaction tests verify phase +expansion, executors, prepared transactions, nonce lanes, namespaced +preparation state, and native prefunding aggregation. Registration and +lifecycle tests cover the optional phase-owned hooks. diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts new file mode 100644 index 000000000..80da9ae95 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import { + EPaymentMethod, + EvmToken, + FiatToken, + mapFiatToDestination, + Networks, + RampDirection, + type RampPhase +} from "@vortexfi/shared"; +const ALFREDPAY_OFFRAMP: RampPhase[] = [ + "initial", + "squidRouterPermitExecute", + "fundEphemeral", + "finalSettlementSubsidy", + "alfredpayOfframpTransfer", + "complete" +]; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { alfredpayOfframpFlow, makeAlfredpayOfframpFlow } from "../flows/alfredpay-offramp"; +import { resolveBlockFlow } from "../flows/catalog"; + +const CORE_PHASES: RampPhase[] = [ + "squidRouterPermitExecute", + "fundEphemeral", + "finalSettlementSubsidy", + "alfredpayOfframpTransfer" +]; + +describe("Alfredpay offramp flow", () => { + it("preserves phase sequence and executor coverage", () => { + expect(alfredpayOfframpFlow.phases).toEqual(CORE_PHASES); + expect(alfredpayOfframpFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + expect(assemblePhaseFlow(alfredpayOfframpFlow)).toEqual(ALFREDPAY_OFFRAMP); + }); + + it("uses one family for direct Polygon, same-chain Squid, and cross-chain sources", () => { + for (const flow of [ + makeAlfredpayOfframpFlow(EvmToken.USDT, Networks.Polygon), + makeAlfredpayOfframpFlow(EvmToken.USDC, Networks.Polygon), + makeAlfredpayOfframpFlow(EvmToken.USDC, Networks.Base) + ]) { + expect(flow.name).toBe("AlfredpayOfframp"); + expect(flow.phases).toEqual(CORE_PHASES); + } + }); + + it("maps every supported fiat payment method", () => { + for (const outputCurrency of [FiatToken.USD, FiatToken.MXN, FiatToken.COP, FiatToken.ARS]) { + const flow = resolveBlockFlow({ + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency, + rampType: RampDirection.SELL, + to: mapFiatToDestination(outputCurrency) + }); + expect(flow.name).toBe("AlfredpayOfframp"); + } + }); + + it("rejects a mismatched Alfredpay payment method", () => { + expect(() => + resolveBlockFlow({ + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: EPaymentMethod.ACH + }) + ).toThrow(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.lifecycle.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.lifecycle.test.ts new file mode 100644 index 000000000..4280c226f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.lifecycle.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test"; +import { startAlfredpayOfframp } from "../phases/alfredpay-offramp/lifecycle"; + +function context(state: Record, quoteId = "quote-1") { + return { + metadata: { quoteId }, + ownState: undefined, + quote: {} as never, + state: state as never + } as never; +} + +describe("Alfredpay offramp start lifecycle", () => { + it("is idempotent once registration has created the provider order", async () => { + expect(await startAlfredpayOfframp(context({ alfredpayTransactionId: "transaction-1" }, ""))).toEqual({}); + }); + + it("retains defensive validation when no provider transaction exists", async () => { + await expect(startAlfredpayOfframp(context({}))).rejects.toThrow("Missing Alfredpay user ID in ramp state"); + await expect(startAlfredpayOfframp(context({}, ""))).rejects.toThrow("Missing Alfredpay quote ID in metadata"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts new file mode 100644 index 000000000..04be6c2ae --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, mock } from "bun:test"; +import { type EvmNetworks, EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { registerAlfredpayOfframp } from "../phases/alfredpay-offramp/registration"; +import type { AlfredpayOfframpMetadata } from "../phases/alfredpay-offramp/simulation"; + +const metadata: AlfredpayOfframpMetadata = { + adjustedDifference: "0", + adjustedTargetDiscount: "0", + bridgeInputAmountRaw: "100000000", + bridgeOutputAmountDecimal: "99", + bridgeOutputAmountRaw: "99000000", + currency: FiatToken.MXN, + expirationDate: new Date("2026-01-01T00:00:00Z"), + fee: "1", + fromNetwork: Networks.Base as EvmNetworks, + fromToken: "0x1111111111111111111111111111111111111111" as const, + inputAmountDecimal: "99", + inputAmountRaw: "99000000", + network: Networks.Polygon, + outputAmountDecimal: "1980", + outputAmountRaw: "198000", + quoteId: "quote-old", + subsidyAmountDecimal: "0", + subsidyAmountRaw: "0", + token: EvmToken.USDT, + toToken: "0x2222222222222222222222222222222222222222" as const +}; + +function context() { + return { + authenticatedUser: { id: "user-1" }, + input: { fiatAccountId: "fiat-1", walletAddress: "0x3333333333333333333333333333333333333333" }, + metadata, + quote: { inputAmount: "100" } as never, + signingAccounts: [{ address: "0x4444444444444444444444444444444444444444", type: "EVM" }] as never + }; +} + +describe("Alfredpay offramp registration", () => { + it("refreshes exact quotes, creates the order, and updates only provider identity metadata", async () => { + const service = { + createOfframp: mock(async () => ({ + depositAddress: "0x5555555555555555555555555555555555555555", + transactionId: "transaction-1" + })), + createOfframpQuote: mock(async () => ({ + expiration: "2026-01-01T00:01:00Z", + fees: [{ amount: "1", currency: "MXN" }], + quoteId: "quote-new", + toAmount: "1980" + })) + } as never; + const result = await registerAlfredpayOfframp(context(), { + resolveCustomerId: async () => "customer-1", + service + }); + expect(result.metadata).toEqual({ + ...metadata, + expirationDate: new Date("2026-01-01T00:01:00Z"), + quoteId: "quote-new" + }); + expect(result.facts).toEqual({ + alfredpayTransactionId: "transaction-1", + alfredpayUserId: "customer-1", + depositAddress: "0x5555555555555555555555555555555555555555", + fiatAccountId: "fiat-1", + walletAddress: "0x3333333333333333333333333333333333333333" + }); + }); + + it("hard-fails on refreshed amount drift before creating an order", async () => { + const createOrder = mock(async () => ({})); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({ + expiration: "2026-01-01T00:01:00Z", + fees: [{ amount: "1", currency: "MXN" }], + quoteId: "quote-new", + toAmount: "1979" + })) + } as never; + await expect( + registerAlfredpayOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) + ).rejects.toThrow("drifted"); + expect(createOrder).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.variants.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.variants.test.ts new file mode 100644 index 000000000..8a3b64cfc --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.variants.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test"; +import { Networks } from "@vortexfi/shared"; +import { classifyAlfredpayOfframpSource } from "../phases/alfredpay-offramp/transactions"; + +describe("Alfredpay offramp source variants", () => { + it("selects every direct, same-chain Squid, and cross-chain Squid permit topology", () => { + expect(classifyAlfredpayOfframpSource(Networks.Polygon, true, true)).toBe("direct-permit"); + expect(classifyAlfredpayOfframpSource(Networks.Polygon, true, false)).toBe("direct-no-permit"); + expect(classifyAlfredpayOfframpSource(Networks.Polygon, false, true)).toBe("same-chain-squid-permit"); + expect(classifyAlfredpayOfframpSource(Networks.Polygon, false, false)).toBe("same-chain-squid-no-permit"); + expect(classifyAlfredpayOfframpSource(Networks.Base, false, true)).toBe("cross-chain-squid-permit"); + expect(classifyAlfredpayOfframpSource(Networks.Base, false, false)).toBe("cross-chain-squid-no-permit"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts new file mode 100644 index 000000000..6cf76a6f5 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts @@ -0,0 +1,171 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { + AlfredpayApiService, + EPaymentMethod, + EvmToken, + FiatToken, + Networks, + RampDirection, + RampPhase +} from "@vortexfi/shared"; +import Big from "big.js"; + +const alfredpayApiServiceGetInstanceReal = AlfredpayApiService.getInstance; + +afterAll(() => { + AlfredpayApiService.getInstance = alfredpayApiServiceGetInstanceReal; +}); + +mock.module("../core/quote-fees", () => ({ + calculateFeeComponents: async () => ({ + anchorFee: "0", + feeCurrency: FiatToken.MXN, + partnerMarkupFee: "1", + vortexFee: "1" + }) +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { + convertCurrency: async (amount: string) => amount, + getFiatToUsdExchangeRate: async () => new Big(1) + } +})); + +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async () => ({ + id: "vortex-partner", + maxDynamicDifference: 0, + maxSubsidy: 0, + minDynamicDifference: 0, + name: "vortex", + rampType: RampDirection.BUY, + targetDiscount: 0 + }) +})); + +mock.module("../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: async ({ amountRaw }: { amountRaw: string }) => ({ + finalEffectiveExchangeRate: "0.99", + finalGrossOutputAmountDecimal: new Big(amountRaw).div(1_000_000).minus(1), + networkFeeUSD: "1", + outputTokenDecimals: 6 + }), + getBridgeTargetTokenDetails: () => ({ + erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" + }) +})); + +const ALFREDPAY_ONRAMP_CROSS_CHAIN: RampPhase[] = [ + "initial", + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { getBlockMetadata } from "../core/metadata"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { PhaseCtx } from "../core/types"; +import { AlfredpayMint } from "../phases/alfredpay-mint"; +import { AlfredpayMintContext } from "../phases/alfredpay-mint/simulation"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { SquidRouterSwapContext } from "../phases/squid-router-swap/simulation"; +import { SubsidizePreContext } from "../phases/subsidize-pre/simulation"; +import { + alfredpayOnrampCrossChainFlow, + alfredpayOnrampCrossChainPhaseFlow, + makeAlfredpayOnrampCrossChainFlow +} from "../flows/alfredpay-onramp-cross-chain"; + +const CORE_PHASES: RampPhase[] = [ + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer" +]; + +function buildCtx(): PhaseCtx { + return { + addNote: () => undefined, + notes: [], + now: new Date(), + partner: { id: null }, + request: { + from: EPaymentMethod.SPEI, + inputAmount: "100", + inputCurrency: FiatToken.MXN, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + }, + targetFeeFiatCurrency: FiatToken.MXN + }; +} + +describe("Alfredpay cross-chain onramp flow", () => { + it("derives the production phase flow and executor order", () => { + expect(alfredpayOnrampCrossChainFlow.phases).toEqual(CORE_PHASES); + expect(alfredpayOnrampCrossChainPhaseFlow).toEqual(ALFREDPAY_ONRAMP_CROSS_CHAIN); + expect(assemblePhaseFlow(makeAlfredpayOnrampCrossChainFlow(Networks.Base, EvmToken.USDC))).toEqual( + ALFREDPAY_ONRAMP_CROSS_CHAIN + ); + expect(alfredpayOnrampCrossChainFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + }); + + it.skip("rejects incompatible adjacency at compile time", () => { + const wrongChain = FlowBuilder.start( + fiatRequestIO(FiatToken.ARS, FiatToken.COP, FiatToken.MXN, FiatToken.USD), + AlfredpayMint + ).pipe( + // @ts-expect-error AlfredpayMint outputs USDT on Polygon, not Base. + FundEphemeral(EvmToken.USDT, Networks.Base) + ); + void wrongChain; + }); + + it("simulates provider fees before subsidy and bridges the resulting amount", async () => { + AlfredpayApiService.getInstance = mock(() => ({ + createOnrampQuote: async () => ({ + expiration: new Date(Date.now() + 30_000).toISOString(), + fees: [{ amount: "2", currency: FiatToken.MXN }], + fromAmount: "100", + quoteId: "alfred-quote", + toAmount: "98" + }) + })) as unknown as typeof AlfredpayApiService.getInstance; + + const { metadata, output } = await alfredpayOnrampCrossChainFlow.simulate(buildCtx()); + + expect(output.token).toBe(EvmToken.USDC); + expect(output.chain).toBe(Networks.Arbitrum); + expect(output.amount.toFixed()).toBe("95"); + expect(metadata.globals.fees.usd).toEqual({ + anchor: "2", + network: "0", + partnerMarkup: "1", + total: "4.000000", + vortex: "1" + }); + expect(Object.keys(metadata.blocks)).toEqual([ + "alfredpayMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "finalSettlementSubsidy", + "destinationTransfer" + ]); + expect(getBlockMetadata(metadata, AlfredpayMintContext).outputAmountRaw).toBe("98000000"); + expect(getBlockMetadata(metadata, SubsidizePreContext).targetInputAmountRaw).toBe("96000000"); + expect(getBlockMetadata(metadata, SquidRouterSwapContext).inputAmountRaw).toBe("96000000"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.transactions.test.ts new file mode 100644 index 000000000..595e5e530 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.transactions.test.ts @@ -0,0 +1,203 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { + EphemeralAccountType, + EPaymentMethod, + EvmToken, + FiatToken, + Networks, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import { privateKeyToAccount } from "viem/accounts"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import * as evmFundingNamespace from "../core/evm-funding"; +import * as alfredpayCustomerNamespace from "../../../quote/alfredpay-customer"; +import type { FlowMetadata } from "../core/metadata"; + +const sharedReal = { ...sharedNamespace }; +const evmFundingReal = { ...evmFundingNamespace }; +const alfredpayCustomerReal = { ...alfredpayCustomerNamespace }; +const sourceAmounts: string[] = []; +const destinationAmounts: string[] = []; +const EVM_EPHEMERAL_ADDRESS = privateKeyToAccount( + "0x3434343434343434343434343434343434343434343434343434343434343434" +).address; +const DESTINATION_ADDRESS = "0x1212121212121212121212121212121212121212"; +const FUNDING_ADDRESS = "0x9999999999999999999999999999999999999999"; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + createOnrampSquidrouterTransactionsFromPolygonToEvm: async ({ rawAmount }: { rawAmount: string }) => { + sourceAmounts.push(rawAmount); + return { + approveData: { data: "0xa1", gas: "100000", to: "0x1111111111111111111111111111111111111111", value: "0" }, + squidRouterQuoteId: "squid-quote-id", + squidRouterReceiverHash: "0xreceiverhash", + squidRouterReceiverId: "receiver-id", + swapData: { data: "0xa2", gas: "500000", to: "0x1111111111111111111111111111111111111111", value: "123" } + }; + }, + createOnrampSquidrouterTransactionsOnDestinationChain: async ({ rawAmount }: { rawAmount: string }) => { + destinationAmounts.push(rawAmount); + return { + approveData: { data: "0xb1", gas: "100000", to: "0x2222222222222222222222222222222222222222", value: "0" }, + swapData: { data: "0xb2", gas: "500000", to: "0x2222222222222222222222222222222222222222", value: "0" } + }; + }, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + estimateFeesPerGas: async () => ({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000n }) + }) + }) + } +})); + +mock.module("../core/evm-funding", () => ({ + getEvmFundingAccount: () => ({ address: FUNDING_ADDRESS }) +})); + +mock.module("../../alfredpay-customer", () => ({ + resolveAlfredpayCustomerId: async () => "alfredpay-user-id" +})); + +const { makeAlfredpayOnrampCrossChainFlow } = await import("../flows/alfredpay-onramp-cross-chain"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/evm-funding", () => ({ ...evmFundingReal })); + mock.module("../../alfredpay-customer", () => ({ ...alfredpayCustomerReal })); +}); + +function buildQuote(): QuoteTicketAttributes { + return { + from: EPaymentMethod.SPEI, + id: "quote-alfredpay", + inputAmount: "100", + inputCurrency: FiatToken.MXN, + metadata: { + alfredpayMint: { outputAmountRaw: "98000000" }, + evmToEvm: { inputAmountRaw: "96000000", outputAmountRaw: "95000000" } + }, + network: Networks.Arbitrum, + outputAmount: "95", + outputCurrency: EvmToken.USDC, + partnerId: null, + pricingPartnerId: null, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + } as unknown as QuoteTicketAttributes; +} + +function buildMetadata(): FlowMetadata { + return { + blocks: { + alfredpayMint: { + currency: FiatToken.MXN, + expirationDate: new Date(), + fee: new Big(2), + inputAmountDecimal: new Big(100), + inputAmountRaw: "10000", + outputAmountDecimal: new Big(98), + outputAmountRaw: "98000000", + quoteId: "alfred-quote" + }, + destinationTransfer: { + amountDecimal: new Big(95), + amountRaw: "95000000", + network: Networks.Arbitrum, + token: EvmToken.USDC + }, + finalSettlementSubsidy: {}, + fundEphemeral: { network: Networks.Polygon, token: EvmToken.USDT }, + squidRouterSwap: { + fromNetwork: Networks.Polygon, + fromToken: sharedReal.ALFREDPAY_ERC20_TOKEN, + inputAmountDecimal: new Big(96), + inputAmountRaw: "96000000", + networkFeeUSD: "1", + outputAmountDecimal: new Big(95), + outputAmountRaw: "95000000", + toNetwork: Networks.Arbitrum, + toToken: sharedReal.evmTokenConfig.arbitrum.USDC!.erc20AddressSourceChain + }, + subsidizePreSwap: { + expectedOutputAmountDecimal: new Big(98), + expectedOutputAmountRaw: "98000000", + inputCurrency: EvmToken.USDT, + inputDecimals: 6, + network: Networks.Polygon, + targetInputAmountRaw: "96000000" + } + }, + globals: { + fees: { + usd: { anchor: "2", network: "0", partnerMarkup: "1", total: "4", vortex: "1" } + }, + partner: { id: null }, + request: { + from: EPaymentMethod.SPEI, + inputAmount: "100", + inputCurrency: FiatToken.MXN, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + } + } + }; +} + +describe("AlfredPay onramp cross-chain transactions", () => { + it("preserves source precision, recovery lanes, and route state", async () => { + sourceAmounts.length = 0; + destinationAmounts.length = 0; + const { metadata: _metadata, ...quote } = buildQuote(); + const blocks = await makeAlfredpayOnrampCrossChainFlow(Networks.Arbitrum, EvmToken.USDC).prepareTxs({ + destinationAddress: DESTINATION_ADDRESS, + accounts: { [EphemeralAccountType.EVM]: { address: EVM_EPHEMERAL_ADDRESS, type: EphemeralAccountType.EVM } }, + metadata: buildMetadata() as never, + quote, + registrationFacts: { alfredpayMint: { userId: "alfredpay-user-id" } }, + userId: "user-id" + }); + + expect(sourceAmounts).toEqual(["96000000"]); + expect(destinationAmounts).toEqual(["96000000"]); + expect(blocks.stateMeta.blockState).toEqual({ + alfredpayMint: { userId: "alfredpay-user-id" }, + squidRouterSwap: { + quoteId: "squid-quote-id", + receiverHash: "0xreceiverhash", + receiverId: "receiver-id" + } + }); + expect(blocks.stateMeta.phaseFlow).toEqual([ + "initial", + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" + ]); + expect(blocks.stateMeta.transactionPlan).toEqual({ + nativePrefunding: { [`${Networks.Polygon}:${EVM_EPHEMERAL_ADDRESS.toLowerCase()}`]: "123" } + }); + expect(blocks.unsignedTxs.map(tx => [tx.phase, tx.network, tx.signer, tx.nonce])).toEqual([ + ["squidRouterApprove", Networks.Polygon, EVM_EPHEMERAL_ADDRESS, 0], + ["squidRouterSwap", Networks.Polygon, EVM_EPHEMERAL_ADDRESS, 1], + ["destinationTransfer", Networks.Arbitrum, EVM_EPHEMERAL_ADDRESS, 0], + ["backupSquidRouterApprove", Networks.Arbitrum, EVM_EPHEMERAL_ADDRESS, 1], + ["backupSquidRouterSwap", Networks.Arbitrum, EVM_EPHEMERAL_ADDRESS, 2], + ["backupApprove", Networks.Arbitrum, EVM_EPHEMERAL_ADDRESS, 0], + ["polygonCleanup", Networks.Polygon, EVM_EPHEMERAL_ADDRESS, 2], + ["alfredOnrampMintFallback", Networks.Polygon, EVM_EPHEMERAL_ADDRESS, 3] + ]); + expect(blocks.unsignedTxs.find(tx => tx.phase === "squidRouterSwap")?.txData).toMatchObject({ data: "0xa2" }); + expect(blocks.unsignedTxs.find(tx => tx.phase === "backupSquidRouterSwap")?.txData).toMatchObject({ data: "0xb2" }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts new file mode 100644 index 000000000..b68b133ce --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts @@ -0,0 +1,157 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { + ALFREDPAY_EVM_TOKEN, + type CreateAlfredpayOnrampQuoteRequest, + AlfredpayApiService, + EPaymentMethod, + EvmToken, + FiatToken, + Networks, + RampDirection, + RampPhase +} from "@vortexfi/shared"; +import Big from "big.js"; + +const alfredpayApiServiceGetInstanceReal = AlfredpayApiService.getInstance; +let squidCalculations = 0; +const capturedProviderRequests: CreateAlfredpayOnrampQuoteRequest[] = []; + +afterAll(() => { + AlfredpayApiService.getInstance = alfredpayApiServiceGetInstanceReal; +}); + +mock.module("../core/quote-fees", () => ({ + calculateFeeComponents: async () => ({ + anchorFee: "0", + feeCurrency: FiatToken.MXN, + partnerMarkupFee: "1", + vortexFee: "1" + }) +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { + convertCurrency: async (amount: string) => amount, + getFiatToUsdExchangeRate: async () => new Big(1) + } +})); + +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async () => ({ + id: "vortex-partner", + maxDynamicDifference: 0, + maxSubsidy: 0, + minDynamicDifference: 0, + name: "vortex", + rampType: RampDirection.BUY, + targetDiscount: 0 + }) +})); + +mock.module("../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: async ({ amountRaw }: { amountRaw: string }) => { + squidCalculations++; + return { + finalEffectiveExchangeRate: "0.99", + finalGrossOutputAmountDecimal: new Big(amountRaw).div(1_000_000).minus(1), + networkFeeUSD: "1", + outputTokenDecimals: 6 + }; + }, + getBridgeTargetTokenDetails: () => ({ + erc20AddressSourceChain: "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" + }) +})); + +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { PhaseCtx } from "../core/types"; +import { + alfredpayOnrampDirectFlow, + alfredpayOnrampDirectPhaseFlow, + makeAlfredpayOnrampDirectFlow +} from "../flows/alfredpay-onramp-direct"; + +const CORE_PHASES: RampPhase[] = [ + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "finalSettlementSubsidy", + "destinationTransfer" +]; +const ALFREDPAY_ONRAMP_DIRECT: RampPhase[] = ["initial", ...CORE_PHASES, "complete"]; + +function buildCtx(outputCurrency: EvmToken): PhaseCtx { + return { + addNote: () => undefined, + notes: [], + now: new Date(), + partner: { id: null }, + request: { + from: EPaymentMethod.SPEI, + inputAmount: "100", + inputCurrency: FiatToken.MXN, + network: Networks.Polygon, + outputCurrency, + rampType: RampDirection.BUY, + to: Networks.Polygon + }, + targetFeeFiatCurrency: FiatToken.MXN + }; +} + +describe("Alfredpay direct onramp flow", () => { + it("derives the production phase flow and one executor per phase for both variants", () => { + expect(alfredpayOnrampDirectFlow.phases).toEqual(CORE_PHASES); + expect(alfredpayOnrampDirectPhaseFlow).toEqual(ALFREDPAY_ONRAMP_DIRECT); + + for (const token of [ALFREDPAY_EVM_TOKEN, EvmToken.USDC]) { + const flow = makeAlfredpayOnrampDirectFlow(token); + expect(assemblePhaseFlow(flow)).toEqual(ALFREDPAY_ONRAMP_DIRECT); + expect(flow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + } + }); + + it("simulates direct-token passthrough without a provider route", async () => { + AlfredpayApiService.getInstance = mock(() => ({ + createOnrampQuote: async (request: CreateAlfredpayOnrampQuoteRequest) => { + capturedProviderRequests.push(request); + return { + expiration: new Date(Date.now() + 30_000).toISOString(), + fees: [{ amount: "2", currency: FiatToken.MXN }], + fromAmount: "100", + quoteId: "alfred-quote", + toAmount: "98" + }; + } + })) as unknown as typeof AlfredpayApiService.getInstance; + squidCalculations = 0; + capturedProviderRequests.length = 0; + + const { metadata, output } = await makeAlfredpayOnrampDirectFlow(ALFREDPAY_EVM_TOKEN).simulate( + buildCtx(ALFREDPAY_EVM_TOKEN) + ); + + expect(squidCalculations).toBe(0); + expect(capturedProviderRequests[0]?.metadata.customerId).toBe("anonymous"); + expect(output).toMatchObject({ amountRaw: "96000000", chain: Networks.Polygon, token: ALFREDPAY_EVM_TOKEN }); + expect(metadata.blocks.squidRouterSwap).toMatchObject({ + effectiveExchangeRate: "1", + inputAmountRaw: "96000000", + networkFeeUSD: "0", + outputAmountRaw: "96000000" + }); + }); + + it("simulates a real same-chain Squid route for another Polygon token", async () => { + squidCalculations = 0; + const { metadata, output } = await makeAlfredpayOnrampDirectFlow(EvmToken.USDC).simulate(buildCtx(EvmToken.USDC)); + + expect(squidCalculations).toBe(1); + expect(output).toMatchObject({ amountRaw: "95000000", chain: Networks.Polygon, token: EvmToken.USDC }); + expect(metadata.blocks.squidRouterSwap).toMatchObject({ + inputAmountRaw: "96000000", + outputAmountRaw: "95000000" + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.transactions.test.ts new file mode 100644 index 000000000..bfa620ee2 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.transactions.test.ts @@ -0,0 +1,207 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { + ALFREDPAY_EVM_TOKEN, + EphemeralAccountType, + EPaymentMethod, + EvmToken, + EvmTokenDetails, + FiatToken, + Networks, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import { privateKeyToAccount } from "viem/accounts"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import * as evmFundingNamespace from "../core/evm-funding"; +import * as alfredpayCustomerNamespace from "../../../quote/alfredpay-customer"; +import type { FlowMetadata } from "../core/metadata"; + +const sharedReal = { ...sharedNamespace }; +const evmFundingReal = { ...evmFundingNamespace }; +const alfredpayCustomerReal = { ...alfredpayCustomerNamespace }; +const sourceAmounts: string[] = []; +const EVM_EPHEMERAL_ADDRESS = privateKeyToAccount( + "0x3434343434343434343434343434343434343434343434343434343434343434" +).address; +const DESTINATION_ADDRESS = "0x1212121212121212121212121212121212121212"; +const FUNDING_ADDRESS = "0x9999999999999999999999999999999999999999"; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + createOnrampSquidrouterTransactionsFromPolygonToEvm: async ({ rawAmount }: { rawAmount: string }) => { + sourceAmounts.push(rawAmount); + return { + approveData: { data: "0xa1", gas: "100000", to: "0x1111111111111111111111111111111111111111", value: "0" }, + squidRouterQuoteId: "squid-quote-id", + squidRouterReceiverHash: "0xreceiverhash", + squidRouterReceiverId: "receiver-id", + swapData: { data: "0xa2", gas: "500000", to: "0x1111111111111111111111111111111111111111", value: "123" } + }; + }, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + estimateFeesPerGas: async () => ({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000n }) + }) + }) + } +})); + +mock.module("../core/evm-funding", () => ({ + getEvmFundingAccount: () => ({ address: FUNDING_ADDRESS }) +})); + +mock.module("../../alfredpay-customer", () => ({ + resolveAlfredpayCustomerId: async () => "alfredpay-user-id" +})); + +const { makeAlfredpayOnrampDirectFlow } = await import("../flows/alfredpay-onramp-direct"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/evm-funding", () => ({ ...evmFundingReal })); + mock.module("../../alfredpay-customer", () => ({ ...alfredpayCustomerReal })); +}); + +function buildQuote(outputCurrency: EvmToken): QuoteTicketAttributes { + const outputAmount = outputCurrency === ALFREDPAY_EVM_TOKEN ? "96" : "95"; + return { + from: EPaymentMethod.SPEI, + id: "quote-alfredpay-direct", + inputAmount: "100", + inputCurrency: FiatToken.MXN, + metadata: { + alfredpayMint: { outputAmountRaw: "98000000" }, + evmToEvm: { inputAmountRaw: "96000000", outputAmountRaw: `${outputAmount}000000` } + }, + network: Networks.Polygon, + outputAmount, + outputCurrency, + partnerId: null, + pricingPartnerId: null, + rampType: RampDirection.BUY, + to: Networks.Polygon + } as unknown as QuoteTicketAttributes; +} + +function buildMetadata(outputCurrency: EvmToken): FlowMetadata { + const outputAmount = outputCurrency === ALFREDPAY_EVM_TOKEN ? "96" : "95"; + const tokenDetails = sharedReal.getOnChainTokenDetails(Networks.Polygon, outputCurrency) as EvmTokenDetails; + return { + blocks: { + alfredpayMint: { + currency: FiatToken.MXN, + expirationDate: new Date(), + fee: new Big(2), + inputAmountDecimal: new Big(100), + inputAmountRaw: "10000", + outputAmountDecimal: new Big(98), + outputAmountRaw: "98000000", + quoteId: "alfred-quote" + }, + destinationTransfer: { + amountDecimal: new Big(outputAmount), + amountRaw: `${outputAmount}000000`, + network: Networks.Polygon, + token: outputCurrency + }, + finalSettlementSubsidy: {}, + fundEphemeral: { network: Networks.Polygon, token: ALFREDPAY_EVM_TOKEN }, + squidRouterSwap: { + fromNetwork: Networks.Polygon, + fromToken: sharedReal.ALFREDPAY_ERC20_TOKEN, + inputAmountDecimal: new Big(96), + inputAmountRaw: "96000000", + networkFeeUSD: outputCurrency === ALFREDPAY_EVM_TOKEN ? "0" : "1", + outputAmountDecimal: new Big(outputAmount), + outputAmountRaw: `${outputAmount}000000`, + toNetwork: Networks.Polygon, + toToken: tokenDetails.erc20AddressSourceChain + }, + subsidizePreSwap: { + expectedOutputAmountDecimal: new Big(98), + expectedOutputAmountRaw: "98000000", + inputCurrency: ALFREDPAY_EVM_TOKEN, + inputDecimals: 6, + network: Networks.Polygon, + targetInputAmountRaw: "96000000" + } + }, + globals: { + fees: { usd: { anchor: "2", network: "0", partnerMarkup: "1", total: "4", vortex: "1" } }, + partner: { id: null }, + request: { + from: EPaymentMethod.SPEI, + inputAmount: "100", + inputCurrency: FiatToken.MXN, + network: Networks.Polygon, + outputCurrency, + rampType: RampDirection.BUY, + to: Networks.Polygon + } + } + }; +} + +describe("AlfredPay onramp direct transactions", () => { + for (const outputCurrency of [ALFREDPAY_EVM_TOKEN, EvmToken.USDC]) { + it(`prepares Polygon ${outputCurrency}`, async () => { + sourceAmounts.length = 0; + const { metadata: _metadata, ...quote } = buildQuote(outputCurrency); + const blocks = await makeAlfredpayOnrampDirectFlow(outputCurrency).prepareTxs({ + destinationAddress: DESTINATION_ADDRESS, + accounts: { [EphemeralAccountType.EVM]: { address: EVM_EPHEMERAL_ADDRESS, type: EphemeralAccountType.EVM } }, + metadata: buildMetadata(outputCurrency) as never, + quote, + registrationFacts: { alfredpayMint: { userId: "alfredpay-user-id" } }, + userId: "user-id" + }); + + expect(blocks.unsignedTxs.every(tx => tx.network === Networks.Polygon && tx.signer === EVM_EPHEMERAL_ADDRESS)).toBe(true); + expect(blocks.stateMeta.phaseFlow).toEqual([ + "initial", + "alfredpayOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "squidRouterSwap", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" + ]); + expect(blocks.stateMeta.isDirectTransfer).toBeUndefined(); + expect(blocks.stateMeta.blockState).toEqual( + outputCurrency === ALFREDPAY_EVM_TOKEN + ? { alfredpayMint: { userId: "alfredpay-user-id" } } + : { + alfredpayMint: { userId: "alfredpay-user-id" }, + squidRouterSwap: { + quoteId: "squid-quote-id", + receiverHash: "0xreceiverhash", + receiverId: "receiver-id" + } + } + ); + expect(blocks.stateMeta.transactionPlan).toEqual({ + nativePrefunding: + outputCurrency === ALFREDPAY_EVM_TOKEN + ? {} + : { [`${Networks.Polygon}:${EVM_EPHEMERAL_ADDRESS.toLowerCase()}`]: "123" } + }); + expect(sourceAmounts).toEqual(outputCurrency === ALFREDPAY_EVM_TOKEN ? [] : ["96000000"]); + expect(blocks.unsignedTxs.map(tx => [tx.phase, tx.nonce])).toEqual( + outputCurrency === ALFREDPAY_EVM_TOKEN + ? [ + ["destinationTransfer", 0], + ["polygonCleanup", 1] + ] + : [ + ["squidRouterApprove", 0], + ["squidRouterSwap", 1], + ["destinationTransfer", 2], + ["polygonCleanup", 3] + ] + ); + }); + } +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp.lifecycle.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp.lifecycle.test.ts new file mode 100644 index 000000000..08be37615 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp.lifecycle.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + ALFREDPAY_ONCHAIN_CURRENCY, + AlfredpayChain, + AlfredpayFiatCurrency, + AlfredpayPaymentMethodType, + FiatToken +} from "@vortexfi/shared"; +import Big from "big.js"; +import { startAlfredpayMint } from "../phases/alfredpay-mint/lifecycle"; +import type { AlfredpayMintMetadata } from "../phases/alfredpay-mint/simulation"; + +const metadata: AlfredpayMintMetadata = { + currency: FiatToken.MXN, + expirationDate: new Date("2026-01-01T00:00:00Z"), + fee: "1", + inputAmountDecimal: "100", + inputAmountRaw: "10000", + outputAmountDecimal: "99", + outputAmountRaw: "99000000", + quoteId: "quote-old" +}; + +function context(state: Record = {}) { + return { + metadata, + ownState: { userId: "provider-customer-1" }, + quote: { id: "vortex-quote-1", inputAmount: "100", inputCurrency: FiatToken.MXN } as never, + state: { + destinationAddress: "0x1111111111111111111111111111111111111111", + evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + ...state + } as never, + userId: "user-1" + }; +} + +function dependencies(toAmount = "99") { + const createOnramp = mock(async () => ({ + fiatPaymentInstructions: { clabe: "646180157000000004", paymentType: "SPEI" }, + transaction: { transactionId: "transaction-1" } + })); + const createOnrampQuote = mock(async () => ({ + expiration: "2026-01-01T00:01:00Z", + fees: [{ amount: "1", currency: "MXN" }], + quoteId: "quote-new", + toAmount + })); + return { + createOnramp, + createOnrampQuote, + dependencies: { + resolveCustomerId: mock(async () => "provider-customer-1"), + service: { createOnramp, createOnrampQuote } as never, + sumFees: () => new Big(1) + } + }; +} + +describe("Alfredpay onramp start lifecycle", () => { + it("refreshes an exact quote, creates the order, and returns persisted instructions", async () => { + const { createOnramp, dependencies: injected } = dependencies(); + const result = await startAlfredpayMint(context(), injected); + + expect(createOnramp).toHaveBeenCalledWith({ + amount: "100", + chain: AlfredpayChain.MATIC, + customerId: "provider-customer-1", + depositAddress: "0x2222222222222222222222222222222222222222", + fromCurrency: AlfredpayFiatCurrency.MXN, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + quoteId: "quote-new", + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY + }); + expect(result.metadata).toEqual({ + ...metadata, + expirationDate: new Date("2026-01-01T00:01:00Z"), + quoteId: "quote-new" + }); + expect(result.state).toEqual({ + alfredpayTransactionId: "transaction-1", + fiatPaymentInstructions: { clabe: "646180157000000004", paymentType: "SPEI" } + }); + expect(result.responseArtifacts).toEqual({ + achPaymentData: { clabe: "646180157000000004", paymentType: "SPEI" } + }); + }); + + it("falls back to the original quote when refreshed economics drift", async () => { + const { createOnramp, dependencies: injected } = dependencies("98"); + const result = await startAlfredpayMint(context(), injected); + + expect(createOnramp).toHaveBeenCalledWith(expect.objectContaining({ quoteId: "quote-old" })); + expect(result.metadata).toBeUndefined(); + expect(result.state?.alfredpayTransactionId).toBe("transaction-1"); + }); + + it("does not refresh or create another order after the transaction is persisted", async () => { + const { createOnramp, createOnrampQuote, dependencies: injected } = dependencies(); + const result = await startAlfredpayMint(context({ alfredpayTransactionId: "transaction-existing" }), injected); + + expect(result).toEqual({}); + expect(createOnrampQuote).not.toHaveBeenCalled(); + expect(createOnramp).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp.registration.test.ts new file mode 100644 index 000000000..e29abc703 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp.registration.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, mock } from "bun:test"; +import { registerAlfredpayMint } from "../phases/alfredpay-mint/registration"; + +const resolveAlfredpayCustomerId = mock(async () => "alfredpay-customer-1"); + +describe("Alfredpay onramp registration", () => { + it("resolves the authenticated provider customer without creating an order", async () => { + const result = await registerAlfredpayMint( + { + authenticatedUser: { id: "user-1" }, + input: {}, + metadata: { currency: "MXN" } as never, + quote: {} as never, + signingAccounts: [] + }, + { resolveCustomerId: resolveAlfredpayCustomerId } + ); + + expect(resolveAlfredpayCustomerId).toHaveBeenCalledWith("MXN", "user-1"); + expect(result).toEqual({ facts: { userId: "alfredpay-customer-1" } }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-quote-auth.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-quote-auth.test.ts new file mode 100644 index 000000000..5ae8f3c1b --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-quote-auth.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "bun:test"; +import { + ALFREDPAY_ANONYMOUS_CUSTOMER_ID, + resolveAlfredpayQuoteCustomerId +} from "../../../quote/alfredpay-customer"; + +describe("Alfredpay block quote auth", () => { + it("keeps quote discovery anonymous without a user", async () => { + expect(await resolveAlfredpayQuoteCustomerId("MXN", undefined)).toBe(ALFREDPAY_ANONYMOUS_CUSTOMER_ID); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/anchor-test-mode.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/anchor-test-mode.test.ts new file mode 100644 index 000000000..316a08ced --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/anchor-test-mode.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { getAnchorPayoutMaxRetries, isAnchorMockingEnabled } from "../phases/anchor-test-mode"; + +const originalNodeEnv = process.env.NODE_ENV; +const originalMockMode = process.env.MOCK_ANCHOR_OPERATIONS; + +afterEach(() => { + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + if (originalMockMode === undefined) delete process.env.MOCK_ANCHOR_OPERATIONS; + else process.env.MOCK_ANCHOR_OPERATIONS = originalMockMode; +}); + +describe("anchor operation test mode", () => { + it("enables mocked mints and disables payout retries in development", () => { + process.env.NODE_ENV = "development"; + process.env.MOCK_ANCHOR_OPERATIONS = "true"; + + expect(isAnchorMockingEnabled()).toBe(true); + expect(getAnchorPayoutMaxRetries()).toBe(0); + }); + + it("ignores mocked anchor operations outside development", () => { + process.env.NODE_ENV = "test"; + process.env.MOCK_ANCHOR_OPERATIONS = "true"; + + expect(isAnchorMockingEnabled()).toBe(false); + expect(getAnchorPayoutMaxRetries()).toBe(8); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/avenia-on-hold.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/avenia-on-hold.test.ts new file mode 100644 index 000000000..e99e0ee50 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/avenia-on-hold.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { syncAveniaOnHoldState } from "../phases/avenia-mint/on-hold"; + +const getAveniaPayinTickets = mock(async () => [{ id: "ticket-1", status: "ON-HOLD" }]); +const brlaApiService = { getAveniaPayinTickets }; + +function makeState(initialOnHold?: boolean) { + return { aveniaTicketId: "ticket-1", onHold: initialOnHold }; +} + +describe("syncAveniaOnHoldState", () => { + beforeEach(() => { + getAveniaPayinTickets.mockClear(); + getAveniaPayinTickets.mockImplementation(async () => [{ id: "ticket-1", status: "ON-HOLD" }]); + }); + + it("marks the ramp as on hold when the Avenia ticket is ON-HOLD", async () => { + const state = makeState(false); + const found = await syncAveniaOnHoldState( + state, + async nextState => Object.assign(state, nextState), + brlaApiService, + "subaccount-1" + ); + + expect(found).toBe(true); + expect(getAveniaPayinTickets).toHaveBeenCalledWith("subaccount-1"); + expect(state.onHold).toBe(true); + }); + + it("normalizes status casing and clears a stale hold", async () => { + getAveniaPayinTickets.mockImplementationOnce(async () => [{ id: "ticket-1", status: "paid" }]); + const state = makeState(true); + + await syncAveniaOnHoldState(state, async nextState => Object.assign(state, nextState), brlaApiService, "subaccount-1"); + + expect(state.onHold).toBe(false); + }); + + it("does not update state when the ticket is missing", async () => { + getAveniaPayinTickets.mockImplementationOnce(async () => []); + const updateState = mock(async () => undefined); + + expect(await syncAveniaOnHoldState(makeState(false), updateState, brlaApiService, "subaccount-1")).toBe(false); + expect(updateState).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/avenia-registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/avenia-registration.test.ts new file mode 100644 index 000000000..80c132a7f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/avenia-registration.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import { BrlaCurrency, type Limit, RampDirection } from "@vortexfi/shared"; +import { + type AveniaRegistrationDependencies, + createAveniaOnrampTicket, + validateAveniaLimits, + validateAveniaOfframpRecipient +} from "../core/avenia-registration"; + +function limit(currency: string, overrides: Partial = {}): Limit { + return { + currency, + maxChainIn: "0", + maxChainOut: "0", + maxFiatIn: "1000", + maxFiatOut: "1000", + usedLimit: { month: 7, usedChainIn: "0", usedChainOut: "0", usedFiatIn: "0", usedFiatOut: "0", year: 2026 }, + ...overrides + }; +} + +function dependencies(overrides: Partial = {}): AveniaRegistrationDependencies { + return { + aveniaApi: { + createPayInQuote: async () => ({ quoteToken: "provider-quote" }) as never, + createPixInputTicket: async () => ({ brCode: "pix-code", id: "ticket-1" }) as never, + getSubaccountUsedLimit: async () => ({ limitInfo: { limits: [limit(BrlaCurrency.BRL)] } }) as never, + subaccountInfo: async () => + ({ brCode: "trusted-code", wallets: [{ chain: "EVM", walletAddress: "0x1111111111111111111111111111111111111111" }] }) as never, + validatePixKey: async () => ({ taxId: "***456789**" }) as never + }, + convertBrlToUsd: async amount => amount, + findAveniaCustomer: async () => ({ providerSubaccountId: "subaccount-1" }), + findPendingRamps: async () => [], + ...overrides + }; +} + +describe("Avenia block registration", () => { + it("counts pending volume in BRL and rejects BRL and global limit overflow", async () => { + const deps = dependencies({ + convertBrlToUsd: async amount => String(Number(amount) / 5), + findPendingRamps: async () => [{ quote: { inputAmount: "25", outputAmount: "40" } }] + }); + + await expect( + validateAveniaLimits( + "80", + [limit(BrlaCurrency.BRL, { maxFiatIn: "100" })], + RampDirection.BUY, + "123.456.789-01", + deps + ) + ).rejects.toThrow("Amount exceeds BRL limit."); + await expect( + validateAveniaLimits( + "20", + [limit(BrlaCurrency.BRL), limit("*", { maxFiatOut: "10" })], + RampDirection.SELL, + "123.456.789-01", + deps + ) + ).rejects.toThrow("Amount exceeds global limit."); + }); + + it("creates the onramp ticket for the trusted subaccount with unchanged metadata", async () => { + const calls: unknown[][] = []; + const deps = dependencies({ + aveniaApi: { + ...dependencies().aveniaApi, + createPayInQuote: async request => { + calls.push(["quote", request]); + return { quoteToken: "provider-quote" } as never; + }, + createPixInputTicket: async (request, subAccountId) => { + calls.push(["ticket", request, subAccountId]); + return { brCode: "pix-code", id: "ticket-1" } as never; + } + } + }); + + await expect(createAveniaOnrampTicket("12345678901", { id: "abcdefgh-rest" }, "100", deps)).resolves.toEqual({ + aveniaTicketId: "ticket-1", + brCode: "pix-code" + }); + expect(calls[0]).toEqual([ + "quote", + { + inputAmount: "100", + inputCurrency: BrlaCurrency.BRL, + inputPaymentMethod: "PIX", + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: "INTERNAL", + outputThirdParty: false, + subAccountId: "subaccount-1" + } + ]); + expect(calls[1]).toEqual([ + "ticket", + { + quoteToken: "provider-quote", + ticketBlockchainOutput: { beneficiaryWalletId: "00000000-0000-0000-0000-000000000000" }, + ticketBrlPixInput: { additionalData: "abcdefgh" } + }, + "subaccount-1" + ]); + }); + + it("accepts a matching masked recipient and returns only the provider-trusted wallet and code", async () => { + const result = await validateAveniaOfframpRecipient( + "12345678901", + "client-pix-key", + "123.456.789-00", + "499.25", + dependencies() + ); + + expect(result).toEqual({ + brCode: "trusted-code", + wallets: { evm: "0x1111111111111111111111111111111111111111" } + }); + }); + + it("returns one generic error when the masked PIX owner does not match", async () => { + await expect( + validateAveniaOfframpRecipient( + "12345678901", + "client-pix-key", + "123.456.780-00", + "499.25", + dependencies() + ) + ).rejects.toThrow("Invalid pixKey or receiverTaxId."); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.executor.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.executor.test.ts new file mode 100644 index 000000000..5928951f8 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.executor.test.ts @@ -0,0 +1,73 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { AveniaTicketStatus } from "@vortexfi/shared"; +import * as customerNamespace from "../../../avenia/avenia-customer.service"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import type RampState from "../../../../../models/rampState.model"; + +const sharedReal = { ...sharedNamespace }; +const customerReal = { ...customerNamespace }; +const getTicket = mock(async () => ({ status: AveniaTicketStatus.PAID })); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + BrlaApiService: { getInstance: () => ({ getAveniaPayoutTicket: getTicket }) } +})); +mock.module("../../../avenia/avenia-customer.service", () => ({ + ...customerReal, + findAveniaCustomerByTaxId: async () => ({ providerSubaccountId: "subaccount-1" }) +})); +const { AveniaOfframpPayoutExecutor } = await import("../phases/avenia-offramp-payout/execution"); +const originalFindByPk = QuoteTicket.findByPk; + +afterAll(() => { + QuoteTicket.findByPk = originalFindByPk; + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../../avenia/avenia-customer.service", () => ({ ...customerReal })); +}); + +describe("AssetHub BRL payout recovery", () => { + it("resumes a persisted PIX ticket without requiring or rebroadcasting a Base payout transaction", async () => { + QuoteTicket.findByPk = mock(async () => ({ + metadata: { + blocks: { + aveniaPendulumOfframp: { + payoutAmountDecimal: "498", + payoutAmountRaw: "49800", + pendulumCurrencyId: { XCM: 2 }, + transferAmountDecimal: "499", + transferAmountRaw: "499000000000000000000", + transferNetwork: "moonbeam" + } + }, + globals: { fees: { usd: {} }, request: {} } + } + })) as typeof QuoteTicket.findByPk; + const state = { + quoteId: "quote-1", + state: { + blockState: { + aveniaPendulumOfframp: { + brlaEvmAddress: "0x1111111111111111111111111111111111111111", + pixDestination: "pix-key", + receiverTaxId: "12345678900", + taxId: "12345678901" + } + }, + payOutTicketId: "ticket-1" + } + } as unknown as RampState; + const executor = new AveniaOfframpPayoutExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + expect(await executor.executePhase(state)).toBe(state); + expect(getTicket).toHaveBeenCalledWith("ticket-1", "subaccount-1"); + }); + + it("classifies a missing presigned Base payout as recoverable", async () => { + const executor = Object.create(AveniaOfframpPayoutExecutor.prototype) as any; + const state = { presignedTxs: [], state: {} } as unknown as RampState; + + await expect(executor.sendPayoutTransfer(state)).rejects.toMatchObject({ isRecoverable: true }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.flow.test.ts new file mode 100644 index 000000000..d10ea1e0a --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.flow.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import { AssetHubToken, EPaymentMethod, FiatToken, Networks, RampDirection, type RampPhase } from "@vortexfi/shared"; +import { QuoteService } from "../../../quote"; +const BRL_OFFRAMP_ASSETHUB_USDC: RampPhase[] = [ + "initial", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "pendulumToMoonbeamXcm", + "brlaPayoutOnBase", + "complete" +]; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { brlOfframpAssethubUsdcFlow } from "../flows/brl-offramp-assethub-usdc"; +import { resolveBlockFlow } from "../flows/catalog"; +import { AssethubOfframpSource } from "../phases/assethub-offramp-source"; + +const REQUEST = { + from: Networks.AssetHub, + inputAmount: "100", + inputCurrency: AssetHubToken.USDC, + network: Networks.AssetHub, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX +}; + +const CORE_PHASES: RampPhase[] = [ + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "pendulumToMoonbeamXcm", + "brlaPayoutOnBase" +]; + +describe("AssetHub USDC to BRL/PIX block flow", () => { + it("preserves the production phase sequence and executor coverage", () => { + expect(brlOfframpAssethubUsdcFlow.phases).toEqual(CORE_PHASES); + expect(brlOfframpAssethubUsdcFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + expect(assemblePhaseFlow(brlOfframpAssethubUsdcFlow)).toEqual(BRL_OFFRAMP_ASSETHUB_USDC); + }); + + it("resolves only persisted AssetHub USDC to PIX requests", () => { + expect(resolveBlockFlow(REQUEST).name).toBe("BrlOfframpAssethubUsdc"); + expect(() => resolveBlockFlow({ ...REQUEST, inputCurrency: AssetHubToken.USDT })).toThrow("No block flow mapped"); + expect(() => resolveBlockFlow({ ...REQUEST, inputCurrency: AssetHubToken.DOT })).toThrow("No block flow mapped"); + expect(() => resolveBlockFlow({ ...REQUEST, outputCurrency: FiatToken.ARS, to: EPaymentMethod.CBU })).toThrow( + "No block flow mapped" + ); + }); + + it("does not bypass the public quote kill switch", async () => { + await expect(new QuoteService().createQuote(REQUEST)).rejects.toMatchObject({ status: 400 }); + }); + + it("simulates the AssetHub XCM fee and Pendulum IO exactly", async () => { + const result = await AssethubOfframpSource.simulate( + { amount: new (await import("big.js")).default(100), amountRaw: "100000000", chain: Networks.AssetHub, token: AssetHubToken.USDC }, + { + addNote() {}, + notes: [], + now: new Date(), + partner: null, + request: REQUEST + } + ); + expect(result.output).toMatchObject({ amountRaw: "99980000", chain: Networks.Pendulum, token: AssetHubToken.USDC }); + expect(result.metadata.xcmFees).toEqual({ + destination: { amount: "0.01", amountRaw: "10000", currency: "USDC" }, + origin: { amount: "0.01", amountRaw: "10000", currency: "USDC" } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.registration.test.ts new file mode 100644 index 000000000..140965999 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.registration.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; +import { createRegisterAveniaOfframpPayout } from "../phases/avenia-offramp-payout/registration"; + +const validationCalls: unknown[][] = []; + +const register = createRegisterAveniaOfframpPayout({ + resolveAccount: async () => ({ taxId: "12345678901" }) as never, + validateRecipient: async (...args) => { + validationCalls.push(args); + return { brCode: "trusted-code", wallets: { evm: "0x1111111111111111111111111111111111111111" } }; + } +}); + +describe("AssetHub BRL payout registration", () => { + it("derives identity and trusted payout wallet while validating PIX ownership and limits", async () => { + const result = await register({ + authenticatedUser: { id: "user-1" }, + input: { + brlaEvmAddress: "0x9999999999999999999999999999999999999999", + pixDestination: "pix-key", + receiverTaxId: "123.456.789-00", + taxId: "client-value" + }, + metadata: {} as never, + quote: { outputAmount: "499.25" } as never, + signingAccounts: [] + }); + expect(validationCalls.at(-1)).toEqual(["12345678901", "pix-key", "12345678900", "499.25"]); + expect(result).toEqual({ + facts: { + brlaEvmAddress: "0x1111111111111111111111111111111111111111", + pixDestination: "pix-key", + receiverTaxId: "12345678900", + taxId: "12345678901" + }, + responseArtifacts: { depositQrCode: "trusted-code" } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.transactions.test.ts new file mode 100644 index 000000000..def99b2fd --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-assethub-usdc.transactions.test.ts @@ -0,0 +1,126 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { + AssetHubToken, + EphemeralAccountType, + EPaymentMethod, + FiatToken, + Networks, + RampDirection +} from "@vortexfi/shared"; +import * as feeDistributionNamespace from "../core/fee-distribution"; +import * as pendulumCleanupNamespace from "../../../transactions/pendulum/cleanup"; + +const sharedReal = { ...sharedNamespace }; +const feeDistributionReal = { ...feeDistributionNamespace }; +const pendulumCleanupReal = { ...pendulumCleanupNamespace }; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + createAssethubToPendulumXCM: async () => ({ kind: "assethub-xcm" }), + createNablaTransactionsForOfframp: async () => ({ + approve: { extrinsicOptions: { kind: "approve-options" }, transaction: "nabla-approve" }, + swap: { extrinsicOptions: { kind: "swap-options" }, transaction: "nabla-swap" } + }), + createPendulumToMoonbeamTransfer: async () => ({ kind: "moonbeam-xcm" }), + encodeSubmittableExtrinsic: (value: { kind: string }) => `encoded:${value.kind}` +})); +mock.module("../core/fee-distribution", () => ({ + ...feeDistributionReal, + createSubstrateFeeDistributionTransaction: async () => "fee-distribution" +})); +mock.module("../../../transactions/pendulum/cleanup", () => ({ + ...pendulumCleanupReal, + preparePendulumCleanupTransaction: async () => ({ kind: "pendulum-cleanup" }) +})); + +const { brlOfframpAssethubUsdcFlow } = await import("../flows/brl-offramp-assethub-usdc"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/fee-distribution", () => ({ ...feeDistributionReal })); + mock.module("../../../transactions/pendulum/cleanup", () => ({ ...pendulumCleanupReal })); +}); + +const REQUEST = { + from: Networks.AssetHub, + inputAmount: "100", + inputCurrency: AssetHubToken.USDC, + network: Networks.AssetHub, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX +}; + +function prepare(accounts: Record) { + return brlOfframpAssethubUsdcFlow.prepareTxs({ + accounts, + metadata: { + blocks: { + assethubOfframpSource: { inputAmountRaw: "100000000" }, + aveniaOfframpFee: {}, + aveniaPendulumOfframp: { pendulumCurrencyId: { XCM: 2 }, transferAmountRaw: "499000000000000000000" }, + distributeFees: {}, + fundEphemeral: {}, + nablaSwap: { inputAmountForSwapRaw: "99000000", outputAmountRaw: "499000000000000000000" }, + subsidizePostSwap: {}, + subsidizePreSwap: {} + }, + globals: { + fees: { usd: { anchor: "1", network: "0", partnerMarkup: "0", total: "1", vortex: "0" } }, + partner: null, + request: REQUEST + } + } as never, + quote: { ...REQUEST, outputAmount: "498" } as never, + registrationFacts: { + assethubOfframpSource: { userAddress: "5user" }, + aveniaPendulumOfframp: { + brlaEvmAddress: "0x1111111111111111111111111111111111111111", + pixDestination: "pix-key", + receiverTaxId: "12345678900", + taxId: "12345678901" + } + } as never, + userId: "user-1" + }); +} + +describe("AssetHub USDC to BRL transactions", () => { + it("requires only the Substrate ephemeral capability", async () => { + await expect(prepare({})).rejects.toThrow("Substrate accounts"); + }); + + it("preserves user authority, Pendulum nonces, XCM, state, and cleanup", async () => { + const prepared = await prepare({ + [EphemeralAccountType.Substrate]: { address: "5substrate", type: EphemeralAccountType.Substrate } + }); + expect(prepared.unsignedTxs.map(tx => [tx.phase, tx.network, tx.signer, tx.nonce, tx.txData])).toEqual([ + ["assethubToPendulum", Networks.AssetHub, "5user", 0, "encoded:assethub-xcm"], + ["distributeFees", Networks.Pendulum, "5substrate", 0, "fee-distribution"], + ["nablaApprove", Networks.Pendulum, "5substrate", 1, "nabla-approve"], + ["nablaSwap", Networks.Pendulum, "5substrate", 2, "nabla-swap"], + ["pendulumToMoonbeamXcm", Networks.Pendulum, "5substrate", 3, "encoded:moonbeam-xcm"], + ["pendulumCleanup", Networks.Pendulum, "5substrate", 4, "encoded:pendulum-cleanup"] + ]); + expect(prepared.stateMeta).toMatchObject({ + blockState: { + assethubOfframpSource: { userAddress: "5user" }, + aveniaPendulumOfframp: { + brlaEvmAddress: "0x1111111111111111111111111111111111111111", + pixDestination: "pix-key", + receiverTaxId: "12345678900", + taxId: "12345678901" + }, + nablaSwap: { + approveExtrinsicOptions: { kind: "approve-options" }, + softMinimumOutputRaw: expect.any(String), + swapExtrinsicOptions: { kind: "swap-options" } + } + }, + phaseFlow: ["initial", ...brlOfframpAssethubUsdcFlow.phases, "complete"], + substrateEphemeralAddress: "5substrate" + }); + expect(prepared.stateMeta.evmEphemeralAddress).toBeUndefined(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.flow.test.ts new file mode 100644 index 000000000..b270f69e9 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.flow.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import Big from "big.js"; +const BRL_OFFRAMP_BASE: RampPhase[] = [ + "initial", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "brlaPayoutOnBase", + "complete" +]; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { brlOfframpBaseFlow, makeBrlOfframpBaseFlow } from "../flows/brl-offramp-base"; +import { resolveBlockFlow } from "../flows/catalog"; +import { simulateEvmOfframpSource } from "../phases/evm-offramp-source/simulation"; + +const CORE_PHASES: RampPhase[] = [ + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "brlaPayoutOnBase" +]; + +describe("BRL Base offramp flow", () => { + it("defines the runtime phase family and executor coverage", () => { + expect(brlOfframpBaseFlow.phases).toEqual(CORE_PHASES); + expect(brlOfframpBaseFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + expect(assemblePhaseFlow(brlOfframpBaseFlow)).toEqual(BRL_OFFRAMP_BASE); + }); + + it("uses the same runtime family for direct, same-chain swap, and cross-chain sources", () => { + for (const flow of [ + makeBrlOfframpBaseFlow(EvmToken.USDC, Networks.Base), + makeBrlOfframpBaseFlow(EvmToken.BRLA, Networks.Base), + makeBrlOfframpBaseFlow(EvmToken.USDC, Networks.Polygon) + ]) { + expect(flow.phases).toEqual(CORE_PHASES); + expect(flow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + } + }); + + it("maps all supported EVM source variants to the family", () => { + for (const [from, inputCurrency] of [ + [Networks.Base, EvmToken.USDC], + [Networks.Base, EvmToken.BRLA], + [Networks.Polygon, EvmToken.USDC] + ] as const) { + const flow = resolveBlockFlow({ + from, + inputAmount: "100", + inputCurrency, + network: from, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX + }); + expect(flow.name).toBe("BrlOfframpBase"); + expect(flow.phases).toEqual(CORE_PHASES); + } + }); + + it("passes direct Base USDC through without a Squid quote or network fee", async () => { + const result = await simulateEvmOfframpSource( + { amount: new Big(100), amountRaw: "100000000", chain: Networks.Base, token: EvmToken.USDC }, + { + addNote() {}, + fees: { + displayFiat: { anchor: "0", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0", vortex: "0" }, + usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } + }, + notes: [], + now: new Date(), + partner: null, + request: { + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX + } + } + ); + + expect(result.output).toMatchObject({ amountRaw: "100000000", chain: Networks.Base, token: EvmToken.USDC }); + expect(result.output.amount.toString()).toBe("100"); + expect(result.metadata).toMatchObject({ networkFeeUSD: "0", outputAmountRaw: "100000000" }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.simulation.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.simulation.test.ts new file mode 100644 index 000000000..bd595f1e0 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.simulation.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { BrlaApiService, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../priceFeed.service"; +import { simulateAveniaOfframpFee } from "../phases/avenia-offramp-fee/simulation"; + +const originalGetInstance = BrlaApiService.getInstance; +const originalConvertCurrency = priceFeedService.convertCurrency; + +afterEach(() => { + BrlaApiService.getInstance = originalGetInstance; + priceFeedService.convertCurrency = originalConvertCurrency; +}); + +describe("BRL offramp fee simulation", () => { + it("replaces only the anchor fee and preserves accumulated fees", async () => { + BrlaApiService.getInstance = mock( + () => + ({ + createPayOutQuote: async () => ({ inputAmount: "5.30", outputAmount: "4.55" }) + }) as unknown as BrlaApiService + ); + priceFeedService.convertCurrency = mock(async amount => String(amount)) as never; + + const result = await simulateAveniaOfframpFee( + { amount: new Big("5.303854"), amountRaw: "5303854000000000000", chain: Networks.Base, token: EvmToken.BRLA }, + { + addNote: () => {}, + fees: { + displayFiat: { + anchor: "0.70", + currency: FiatToken.BRL, + network: "0.013431", + partnerMarkup: "0.002", + total: "0.73", + vortex: "0.009753" + }, + usd: { + anchor: "0.14", + network: "0.013431", + partnerMarkup: "0.002", + total: "0.165184", + vortex: "0.009753" + } + }, + notes: [], + now: new Date(), + partner: null, + request: { + from: Networks.Arbitrum, + inputAmount: "1", + inputCurrency: EvmToken.USDC, + network: Networks.Arbitrum, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX + } + } + ); + + expect(result.metadata).toEqual({ anchorFeeBrl: "0.75", grossAmountBrl: "5.30" }); + expect(result.fees).toEqual({ + displayFiat: { + anchor: "0.75", + currency: FiatToken.BRL, + network: "0.013431", + partnerMarkup: "0.002", + total: "0.78", + vortex: "0.009753" + }, + usd: { + anchor: "0.75", + network: "0.013431", + partnerMarkup: "0.002", + total: "0.775184", + vortex: "0.009753" + } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.source-txs.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.source-txs.test.ts new file mode 100644 index 000000000..5cd01805e --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-offramp-base.source-txs.test.ts @@ -0,0 +1,86 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { EphemeralAccountType, type EvmNetworks, EvmToken, evmTokenConfig, Networks } from "@vortexfi/shared"; +import type { PrepareCtx } from "../core/types"; +import type { EvmOfframpSourceRegistrationFacts } from "../phases/evm-offramp-source/registration"; +import type { EvmOfframpSourceMetadata } from "../phases/evm-offramp-source/simulation"; + +const sharedReal = { ...sharedNamespace }; +const routeRequests: Record[] = []; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + createOfframpSquidrouterTransactionsToEvm: async (request: Record) => { + routeRequests.push(request); + return { + approveData: { data: "0xa1", gasLimit: "100", target: "0x1111111111111111111111111111111111111111", value: "0" }, + swapData: { data: "0xa2", gasLimit: "200", target: "0x2222222222222222222222222222222222222222", value: "3" } + }; + } +})); + +const { prepareEvmOfframpSourceTxs } = await import("../phases/evm-offramp-source/transactions"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); +}); + +const EPHEMERAL = "0x3333333333333333333333333333333333333333"; +const USER = "0x4444444444444444444444444444444444444444"; + +function context( + fromNetwork: EvmNetworks, + fromToken: EvmToken +): PrepareCtx { + const details = evmTokenConfig[fromNetwork][fromToken]; + if (!details) throw new Error(`Missing ${fromToken} on ${fromNetwork}`); + return { + accounts: { + [EphemeralAccountType.EVM]: { address: EPHEMERAL, type: EphemeralAccountType.EVM } + }, + globals: {} as never, + ownMetadata: { + fromNetwork, + fromToken: details.erc20AddressSourceChain, + inputAmountDecimal: "100", + inputAmountRaw: "100000000", + network: Networks.Base, + networkFeeUSD: "0", + outputAmountDecimal: "100", + outputAmountRaw: "100000000", + toNetwork: Networks.Base, + toToken: evmTokenConfig[Networks.Base][EvmToken.USDC]!.erc20AddressSourceChain, + token: EvmToken.USDC + }, + ownRegistrationFacts: { userAddress: USER }, + quote: {} as never + }; +} + +describe("EVM offramp source transaction variants", () => { + it("uses one user-wallet transfer for Base USDC", async () => { + const prepared = await prepareEvmOfframpSourceTxs(context(Networks.Base, EvmToken.USDC)); + expect(prepared.intents.map(intent => intent.phase)).toEqual(["squidRouterNoPermitTransfer"]); + expect(prepared.intents[0]?.signer).toBe(USER); + expect(prepared.intents[0]?.network).toBe(Networks.Base); + }); + + it("uses user-wallet Squid approve/swap for another Base token", async () => { + const prepared = await prepareEvmOfframpSourceTxs(context(Networks.Base, EvmToken.BRLA)); + expect(prepared.intents.map(intent => intent.phase)).toEqual(["squidRouterApprove", "squidRouterSwap"]); + expect(prepared.intents.every(intent => intent.network === Networks.Base && intent.signer === USER)).toBe(true); + expect(routeRequests.at(-1)).toMatchObject({ destinationAddress: EPHEMERAL, fromAddress: USER, fromNetwork: Networks.Base }); + }); + + it("uses user-wallet Squid approve/swap on a cross-chain source", async () => { + const prepared = await prepareEvmOfframpSourceTxs(context(Networks.Polygon, EvmToken.USDC)); + expect(prepared.intents.map(intent => intent.phase)).toEqual(["squidRouterApprove", "squidRouterSwap"]); + expect(prepared.intents.every(intent => intent.network === Networks.Polygon && intent.signer === USER)).toBe(true); + expect(routeRequests.at(-1)).toMatchObject({ + destinationAddress: EPHEMERAL, + fromAddress: USER, + fromNetwork: Networks.Polygon, + toNetwork: Networks.Base + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-assethub-usdc.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-assethub-usdc.flow.test.ts new file mode 100644 index 000000000..cc153028c --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-assethub-usdc.flow.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import { AssetHubToken, EPaymentMethod, FiatToken, Networks, RampDirection, type RampPhase } from "@vortexfi/shared"; +import { QuoteService } from "../../../quote"; +import { allocateNonces } from "../core/prepare"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { resolveBlockFlow } from "../flows/catalog"; +import { brlOnrampAssethubUsdcFlow } from "../flows/brl-onramp-assethub-usdc"; +import { PendulumToAssethubXcmExecutor } from "../phases/pendulum-to-assethub-xcm/execution"; + +const REQUEST = { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.AssetHub, + outputCurrency: AssetHubToken.USDC, + rampType: RampDirection.BUY, + to: Networks.AssetHub +}; + +const PHASES: RampPhase[] = [ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "moonbeamToPendulumXcm", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "pendulumToAssethubXcm", + "complete" +]; + +describe("BRL to AssetHub USDC onramp flow", () => { + it("derives the production phase sequence and executor coverage", () => { + expect(assemblePhaseFlow(brlOnrampAssethubUsdcFlow)).toEqual(PHASES); + expect(brlOnrampAssethubUsdcFlow.executors.map(executor => executor.getPhaseName())).toEqual(PHASES.slice(1, -1)); + }); + + it("is cataloged only for PIX BRL to AssetHub USDC", () => { + expect(resolveBlockFlow(REQUEST).name).toBe("BrlOnrampAssethubUsdc"); + expect(() => resolveBlockFlow({ ...REQUEST, outputCurrency: AssetHubToken.USDT })).toThrow("No block flow mapped"); + }); + + it("remains explicitly disabled at production quote eligibility", async () => { + await expect(new QuoteService().createQuote(REQUEST)).rejects.toMatchObject({ status: 400 }); + }); + + it("reserves the second Moonbeam XCM nonce independently from Pendulum", () => { + const txs = allocateNonces([ + { + lane: "main", + network: Networks.Moonbeam, + nonceSpan: 2, + phase: "moonbeamToPendulumXcm", + signer: "0xmoonbeam", + txData: "0x01" + }, + { + lane: "main", + network: Networks.Pendulum, + phase: "nablaApprove", + signer: "pendulum", + txData: "0x02" + }, + { + lane: "cleanup", + network: Networks.Moonbeam, + phase: "moonbeamCleanup", + signer: "0xmoonbeam", + txData: "0x03" + }, + { + lane: "cleanup", + network: Networks.Pendulum, + phase: "pendulumCleanup", + signer: "pendulum", + txData: "0x04" + } + ]); + expect(txs.map(tx => [tx.phase, tx.nonce])).toEqual([ + ["moonbeamToPendulumXcm", 0], + ["nablaApprove", 0], + ["moonbeamCleanup", 2], + ["pendulumCleanup", 1] + ]); + }); + + it("does not resubmit a persisted Pendulum to AssetHub XCM", async () => { + const state = { state: { pendulumToAssethubXcmHash: "0xsubmitted", substrateEphemeralAddress: "5substrate" } }; + const result = await (new PendulumToAssethubXcmExecutor() as never as { executePhase(state: unknown): Promise }).executePhase( + state + ); + expect(result).toBe(state); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-assethub-usdc.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-assethub-usdc.registration.test.ts new file mode 100644 index 000000000..66d81702e --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-assethub-usdc.registration.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { EphemeralAccountType } from "@vortexfi/shared"; +import { createRegisterAveniaMint } from "../phases/avenia-mint/registration"; + +describe("BRL AssetHub Avenia registration", () => { + it("owns the derived tax ID, ticket, and PIX artifact", async () => { + const register = createRegisterAveniaMint({ + createTicket: async (taxId, quote, amount) => { + expect([taxId, quote.id, amount]).toEqual(["12345678901", "quote-1", "100"]); + return { aveniaTicketId: "ticket-1", brCode: "pix-code" }; + }, + resolveAccount: async () => ({ taxId: "12345678901" }) as never + }); + const result = await register({ + authenticatedUser: { id: "user-1" }, + input: { taxId: "123.456.789-01" }, + metadata: {} as never, + quote: { id: "quote-1", inputAmount: "100" } as never, + signingAccounts: [ + { address: "0xevm", type: EphemeralAccountType.EVM }, + { address: "5substrate", type: EphemeralAccountType.Substrate } + ] + }); + expect(result).toEqual({ + facts: { aveniaTicketId: "ticket-1", taxId: "12345678901" }, + responseArtifacts: { depositQrCode: "pix-code" } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-assethub-usdc.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-assethub-usdc.transactions.test.ts new file mode 100644 index 000000000..bff1ef06f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-assethub-usdc.transactions.test.ts @@ -0,0 +1,176 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { EphemeralAccountType, EPaymentMethod, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import * as feeDistributionNamespace from "../core/fee-distribution"; +import * as moonbeamCleanupNamespace from "../../../transactions/moonbeam/cleanup"; +import * as pendulumCleanupNamespace from "../../../transactions/pendulum/cleanup"; + +const sharedReal = { ...sharedNamespace }; +const feeDistributionReal = { ...feeDistributionNamespace }; +const moonbeamCleanupReal = { ...moonbeamCleanupNamespace }; +const pendulumCleanupReal = { ...pendulumCleanupNamespace }; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + createMoonbeamToPendulumXCM: async () => ({ kind: "moonbeam-xcm" }), + createNablaTransactionsForOnramp: async () => ({ + approve: { extrinsicOptions: { kind: "approve-options" }, transaction: "nabla-approve" }, + swap: { extrinsicOptions: { kind: "swap-options" }, transaction: "nabla-swap" } + }), + createPendulumToAssethubTransfer: async () => ({ kind: "assethub-xcm" }), + encodeSubmittableExtrinsic: (value: { kind: string }) => `encoded:${value.kind}` +})); +mock.module("../core/fee-distribution", () => ({ + ...feeDistributionReal, + createSubstrateFeeDistributionTransaction: async () => "fee-distribution" +})); +mock.module("../../../transactions/moonbeam/cleanup", () => ({ + ...moonbeamCleanupReal, + prepareMoonbeamCleanupTransaction: async () => ({ kind: "moonbeam-cleanup" }) +})); +mock.module("../../../transactions/pendulum/cleanup", () => ({ + ...pendulumCleanupReal, + preparePendulumCleanupTransaction: async () => ({ kind: "pendulum-cleanup" }) +})); + +const { brlOnrampAssethubUsdcFlow } = await import("../flows/brl-onramp-assethub-usdc"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/fee-distribution", () => ({ ...feeDistributionReal })); + mock.module("../../../transactions/moonbeam/cleanup", () => ({ ...moonbeamCleanupReal })); + mock.module("../../../transactions/pendulum/cleanup", () => ({ ...pendulumCleanupReal })); +}); + +describe("BRL Avenia to AssetHub USDC transactions", () => { + it("requires both EVM and Substrate capabilities", async () => { + await expect( + brlOnrampAssethubUsdcFlow.prepareTxs({ + accounts: { [EphemeralAccountType.EVM]: { address: "0xevm", type: EphemeralAccountType.EVM } }, + destinationAddress: "5destination", + metadata: { + blocks: { + aveniaMint: {}, + distributeFees: {}, + fundEphemeral: {}, + moonbeamToPendulumXcm: { inputAmountRaw: "1" }, + nablaSwap: {}, + pendulumToAssethubXcm: {}, + subsidizePostSwap: {}, + subsidizePreSwap: {} + }, + globals: { fees: { usd: {} }, partner: null, request: {} } + } as never, + quote: {} as never, + registrationFacts: { aveniaMint: { taxId: "12345678901" } } as never + }) + ).rejects.toThrow("Substrate ephemeral account"); + }); + + it("prepares both signers, exact nonces, phase state, XCM, and cleanup", async () => { + const request = { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.AssetHub, + outputCurrency: "USDC", + rampType: RampDirection.BUY, + to: Networks.AssetHub + }; + const prepared = await brlOnrampAssethubUsdcFlow.prepareTxs({ + accounts: { + [EphemeralAccountType.EVM]: { address: "0xevm", type: EphemeralAccountType.EVM }, + [EphemeralAccountType.Substrate]: { address: "5substrate", type: EphemeralAccountType.Substrate } + }, + destinationAddress: "5destination", + metadata: { + blocks: { + aveniaMint: { + mint: {}, + network: Networks.Moonbeam, + transfer: { outputAmountRaw: "99000000000000000000" } + }, + distributeFees: { + anchorFeeUsd: "1", + network: Networks.Pendulum, + networkFeeUsd: "0.03", + outputCurrencyId: { XCM: 12 }, + outputDecimals: 6, + partnerMarkupUsd: "0", + totalFeesUsd: "0.13", + vortexFeeUsd: "0.1" + }, + fundEphemeral: { network: Networks.Moonbeam, token: "BRLA" }, + moonbeamToPendulumXcm: { + inputAmountRaw: "99000000000000000000", + outputAmountRaw: "99000000000000000000", + pendulumCurrencyId: { XCM: 2 } + }, + nablaSwap: { + inputAmountForSwapDecimal: "99", + inputAmountForSwapRaw: "99000000000000000000", + inputCurrency: "BRL", + inputDecimals: 18, + inputToken: "0xinput", + network: Networks.Pendulum, + outputAmountDecimal: new Big("18"), + outputAmountRaw: "18000000", + outputCurrency: "USDC", + outputDecimals: 6, + outputToken: "0xoutput" + }, + pendulumToAssethubXcm: { + inputAmountRaw: "17500000", + outputAmountRaw: "17472000", + outputCurrencyId: { XCM: 12 } + }, + subsidizePostSwap: {}, + subsidizePreSwap: {} + }, + globals: { + fees: { usd: { anchor: "1", network: "0.03", partnerMarkup: "0", total: "1.13", vortex: "0.1" } }, + partner: null, + request + } + } as never, + quote: { + from: "pix", + id: "quote", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.AssetHub, + outputAmount: "17.472", + outputCurrency: "USDC", + partnerId: null, + pricingPartnerId: null, + rampType: RampDirection.BUY, + to: Networks.AssetHub + } as never, + registrationFacts: { aveniaMint: { aveniaTicketId: "ticket", taxId: "12345678901" } } as never + }); + + expect(prepared.unsignedTxs.map(tx => [tx.phase, tx.network, tx.signer, tx.nonce, tx.txData])).toEqual([ + ["moonbeamToPendulumXcm", Networks.Moonbeam, "0xevm", 0, "encoded:moonbeam-xcm"], + ["nablaApprove", Networks.Pendulum, "5substrate", 0, "nabla-approve"], + ["nablaSwap", Networks.Pendulum, "5substrate", 1, "nabla-swap"], + ["distributeFees", Networks.Pendulum, "5substrate", 2, "fee-distribution"], + ["pendulumToAssethubXcm", Networks.Pendulum, "5substrate", 3, "encoded:assethub-xcm"], + ["moonbeamCleanup", Networks.Moonbeam, "0xevm", 2, "encoded:moonbeam-cleanup"], + ["pendulumCleanup", Networks.Pendulum, "5substrate", 4, "encoded:pendulum-cleanup"] + ]); + expect(prepared.stateMeta).toMatchObject({ + blockState: { + aveniaMint: { taxId: "12345678901" }, + nablaSwap: { + approveExtrinsicOptions: { kind: "approve-options" }, + softMinimumOutputRaw: expect.any(String), + swapExtrinsicOptions: { kind: "swap-options" } + } + }, + destinationAddress: "5destination", + evmEphemeralAddress: "0xevm", + substrateEphemeralAddress: "5substrate" + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts new file mode 100644 index 000000000..e630b0a3e --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts @@ -0,0 +1,277 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import Big from "big.js"; +import { BrlaApiService, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; + +const partnerPricingReal = { ...partnerPricingNamespace }; +const brlaApiServiceGetInstanceReal = BrlaApiService.getInstance; + +mock.module("../core/nabla", () => ({ + calculateNablaSwapOutput: async () => { + throw new Error("calculateNablaSwapOutput should not be called in EVM-only smoke test"); + }, + calculateNablaSwapOutputEvm: async () => ({ + effectiveExchangeRate: "0.18", + nablaOutputAmountDecimal: new Big(18), + nablaOutputAmountRaw: "18000000" + }) +})); + +mock.module("../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: async () => ({ + finalEffectiveExchangeRate: "0.99", + finalGrossOutputAmountDecimal: new Big(17.5), + networkFeeUSD: "0.1", + outputTokenDecimals: 6 + }), + getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({ + networkFeeUSD: "0.1", + outputAmountDecimal: new Big(amountDecimal) + }), + getBridgeTargetTokenDetails: () => ({ + erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" + }) +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { + convertCurrency: async (amount: string) => amount, + getFiatToUsdExchangeRate: async () => new Big(0.18) + } +})); + +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async () => null +})); + +afterAll(() => { + BrlaApiService.getInstance = brlaApiServiceGetInstanceReal; + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); +}); + +const BRL_ONRAMP_BASE_CROSS_CHAIN: RampPhase[] = [ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; +import { FlowBuilder } from "../core/flow"; +import { evmRequestIO, fiatRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { getBlockMetadata } from "../core/metadata"; +import type { PhaseCtx } from "../core/types"; +import { AveniaMint } from "../phases/avenia-mint"; +import { AveniaMintContext } from "../phases/avenia-mint/simulation"; +import { DestinationTransferContext } from "../phases/destination-transfer/simulation"; +import { DistributeFees } from "../phases/distribute-fees"; +import { DistributeFeesContext } from "../phases/distribute-fees/simulation"; +import { FinalSettlementSubsidyContext } from "../phases/final-settlement-subsidy/simulation"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { NablaSwap } from "../phases/nabla-swap"; +import { NablaSwapContext } from "../phases/nabla-swap/simulation"; +import { SquidRouterSwap } from "../phases/squid-router-swap"; +import { SquidRouterSwapContext } from "../phases/squid-router-swap/simulation"; +import { SubsidizePostContext } from "../phases/subsidize-post/simulation"; +import { SubsidizePreContext } from "../phases/subsidize-pre/simulation"; +import { + brlOnrampBaseCrossChainFlow, + brlOnrampBaseCrossChainPhaseFlow, + makeBrlOnrampBaseCrossChainFlow +} from "../flows/brl-onramp-base-cross-chain"; + +const CORE_PHASES: RampPhase[] = [ + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer" +]; + +describe("BRL cross-chain onramp flow structure", () => { + it("derives the core phases from the assembled blocks", () => { + expect(brlOnrampBaseCrossChainFlow.phases).toEqual(CORE_PHASES); + }); + + it("assembles the expected phase flow", () => { + expect(brlOnrampBaseCrossChainPhaseFlow).toEqual(BRL_ONRAMP_BASE_CROSS_CHAIN); + expect(assemblePhaseFlow(brlOnrampBaseCrossChainFlow)).toEqual(BRL_ONRAMP_BASE_CROSS_CHAIN); + }); + + it("derives the same phaseFlow for every destination in the flow family", () => { + expect(assemblePhaseFlow(makeBrlOnrampBaseCrossChainFlow(Networks.Polygon, EvmToken.USDT))).toEqual( + BRL_ONRAMP_BASE_CROSS_CHAIN + ); + expect(assemblePhaseFlow(makeBrlOnrampBaseCrossChainFlow(Networks.Ethereum, EvmToken.USDC))).toEqual( + BRL_ONRAMP_BASE_CROSS_CHAIN + ); + }); +}); + +describe("BRL cross-chain onramp flow executors", () => { + it("provides exactly one executor per phase, in flow order", () => { + expect(brlOnrampBaseCrossChainFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + }); + + it("provides executors for every destination in the flow family", () => { + const flow = makeBrlOnrampBaseCrossChainFlow(Networks.Polygon, EvmToken.USDT); + expect(flow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + }); +}); + +describe("BRL cross-chain onramp flow compile-time adjacency", () => { + it.skip("rejects brand mismatches at compile time", () => { + // @ts-expect-error entry adjacency: an EVM resolver cannot feed a fiat phase + const _wrongEntry = FlowBuilder.start(evmRequestIO(EvmToken.USDC, Networks.Base), AveniaMint); + void _wrongEntry; + + // AveniaMint outputs BRLA on Base; a EURC-input swap cannot follow. + const _wrongToken = FlowBuilder.start(fiatRequestIO(FiatToken.BRL), AveniaMint).pipe( + // @ts-expect-error adjacency: NablaSwap input brand (EURC) != AveniaMint output brand (BRLA) + NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC) + ); + void _wrongToken; + + // The bridge lands on Arbitrum; a Base-only phase cannot follow. + const bridged = FlowBuilder.start( + evmRequestIO(EvmToken.USDC, Networks.Base), + SquidRouterSwap(Networks.Base, Networks.Arbitrum, EvmToken.USDC, EvmToken.USDC) + ); + // @ts-expect-error adjacency: DistributeFees chain brand (base) != bridge output chain (arbitrum) + const _wrongChain = bridged.pipe(DistributeFees()); + void _wrongChain; + + }); + + it("rejects duplicate metadata keys when the flow is built", () => { + expect(() => + FlowBuilder.start(evmRequestIO(EvmToken.USDC, Networks.Base), FundEphemeral(EvmToken.USDC, Networks.Base)) + .pipe(FundEphemeral(EvmToken.USDC, Networks.Base)) + .build("DuplicateKey") + ).toThrow("duplicate metadata key fundEphemeral"); + }); +}); + +function buildCtx(): PhaseCtx { + const notes: string[] = []; + return { + addNote: (note: string) => { + notes.push(note); + }, + fees: { + displayFiat: { anchor: "0.1", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" }, + usd: { anchor: "0.1", network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" } + }, + notes, + now: new Date(), + partner: null, + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + } + }; +} + +async function runFlow(flow: typeof brlOnrampBaseCrossChainFlow) { + BrlaApiService.getInstance = mock(() => ({ + createPayInQuote: mock(async (request: { inputCurrency: string }) => ({ + appliedFees: [{ amount: "0.2", type: "Gas Fee" }], + outputAmount: request.inputCurrency === "BRL" ? "99" : "98.5", + quoteToken: "mock-quote-token" + })) + })) as unknown as typeof BrlaApiService.getInstance; + + return flow.simulate(buildCtx()); +} + +describe("BRL cross-chain onramp flow simulation", () => { + it("runs the flow end-to-end and lands on the destination token", async () => { + const { output } = await runFlow(brlOnrampBaseCrossChainFlow); + expect(output.amount.gt(0)).toBe(true); + expect(output.token).toBe(EvmToken.USDC); + expect(output.chain).toBe(Networks.Arbitrum); + }); +}); + +describe("BRL cross-chain onramp flow metadata ownership", () => { + it("accumulates one context per block beneath explicit globals", async () => { + const { metadata } = await runFlow(brlOnrampBaseCrossChainFlow); + const { blocks, globals } = metadata; + + expect(globals.fees.usd).toMatchObject({ anchor: "1.5", network: "0.1", total: "1.700000", vortex: "0.1" }); + expect(Object.keys(blocks)).toEqual([ + "aveniaMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "finalSettlementSubsidy", + "destinationTransfer" + ]); + + const aveniaMint = getBlockMetadata(metadata, AveniaMintContext).mint; + expect(aveniaMint).toBeDefined(); + expect(aveniaMint.currency).toBe(FiatToken.BRL); + // 100 BRL in, 99 BRLA quoted -> 1 BRL mint fee, 0.2 gas fee deducted from delivery + expect(Big(aveniaMint.fee).toFixed()).toBe("1"); + expect(Big(aveniaMint.inputAmountDecimal).toFixed()).toBe("100"); + expect(Big(aveniaMint.outputAmountDecimal).toFixed()).toBe("98.8"); + + const aveniaTransfer = getBlockMetadata(metadata, AveniaMintContext).transfer; + expect(aveniaTransfer).toBeDefined(); + expect(Big(aveniaTransfer.inputAmountDecimal).toFixed()).toBe("98.8"); + // transfer quote outputs 98.5, minus the 0.2 gas-fee buffer ((0.2 + 0.2) * 0.5) + expect(Big(aveniaTransfer.outputAmountDecimal).toFixed()).toBe("98.3"); + + const nabla = getBlockMetadata(metadata, NablaSwapContext); + expect(nabla).toBeDefined(); + expect(nabla.inputCurrency).toBe(EvmToken.BRLA); + expect(nabla.outputCurrency).toBe(EvmToken.USDC); + expect(nabla.outputAmountRaw).toBe("18000000"); + expect(nabla.effectiveExchangeRate).toBe("0.18"); + + const evmToEvm = getBlockMetadata(metadata, SquidRouterSwapContext); + expect(evmToEvm).toBeDefined(); + expect(evmToEvm.fromNetwork).toBe(Networks.Base); + expect(evmToEvm.toNetwork).toBe(Networks.Arbitrum); + expect(evmToEvm.inputAmountRaw).toBeDefined(); + expect(evmToEvm.outputAmountRaw).toBe("17500000"); + expect(evmToEvm.networkFeeUSD).toBe("0.1"); + + const distributeFees = getBlockMetadata(metadata, DistributeFeesContext); + expect(distributeFees.networkFeeUsd).toBe("0.1"); + expect(distributeFees.totalFeesUsd).toBe("0.2"); + + const subsidy = getBlockMetadata(metadata, FinalSettlementSubsidyContext); + expect(subsidy).toBeDefined(); + expect(subsidy.applied).toBe(false); + expect(Big(subsidy.actualOutputAmountDecimal).gt(0)).toBe(true); + expect(subsidy.partnerId).toBeNull(); + expect(getBlockMetadata(metadata, SubsidizePreContext).inputCurrency).toBe(EvmToken.BRLA); + const subsidizePost = getBlockMetadata(metadata, SubsidizePostContext); + expect(subsidizePost.outputCurrency).toBe(EvmToken.USDC); + expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe("17.8"); + expect(getBlockMetadata(metadata, DestinationTransferContext).amountRaw).toBe("17500000"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts new file mode 100644 index 000000000..ee69dff6d --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts @@ -0,0 +1,326 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +// 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 { + EphemeralAccountType, + EPaymentMethod, + EvmToken, + FiatToken, + Networks, + RampDirection, + signUnsignedTransactions +} from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; +import * as evmFundingNamespace from "../core/evm-funding"; +import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import Big from "big.js"; +import { decodeFunctionData, erc20Abi } from "viem"; +import type { FlowMetadata } from "../core/metadata"; +import type { SubsidyMetadata } from "../phases/subsidize-pre/simulation"; + +const sharedReal = { ...sharedNamespace }; +const evmFundingReal = { ...evmFundingNamespace }; +const partnerPricingReal = { ...partnerPricingNamespace }; + +const EVM_EPHEMERAL_PRIVATE_KEY = "0x3434343434343434343434343434343434343434343434343434343434343434"; +const EVM_EPHEMERAL_ADDRESS = privateKeyToAccount(EVM_EPHEMERAL_PRIVATE_KEY).address; +const DESTINATION_ADDRESS = "0x1212121212121212121212121212121212121212"; +const FUNDING_ADDRESS = "0x9999999999999999999999999999999999999999"; +const VORTEX_PAYOUT_ADDRESS = "0x8888888888888888888888888888888888888888"; +const REQUEST = { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum +}; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + createNablaTransactionsForOnrampOnEVM: async () => ({ + approve: { data: "0xc1", gas: "100000", to: "0x3333333333333333333333333333333333333333", value: "0" }, + swap: { data: "0xc2", gas: "500000", to: "0x3333333333333333333333333333333333333333", value: "0" } + }), + createOnrampSquidrouterTransactionsFromBaseToEvm: async () => ({ + approveData: { data: "0xa1", gas: "100000", to: "0x1111111111111111111111111111111111111111", value: "0" }, + squidRouterQuoteId: "squid-quote-id", + squidRouterReceiverHash: "0xreceiverhash", + squidRouterReceiverId: "receiver-id", + swapData: { data: "0xa2", gas: "500000", to: "0x1111111111111111111111111111111111111111", value: "123" } + }), + createOnrampSquidrouterTransactionsOnDestinationChain: async () => ({ + approveData: { data: "0xb1", gas: "100000", to: "0x2222222222222222222222222222222222222222", value: "0" }, + swapData: { data: "0xb2", gas: "500000", to: "0x2222222222222222222222222222222222222222", value: "0" } + }), + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + estimateFeesPerGas: async () => ({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000n }) + }) + }) + }, + getNablaBasePool: () => ({ router: "0x4444444444444444444444444444444444444444" }) +})); + +mock.module("../core/evm-funding", () => ({ + getEvmFundingAccount: () => ({ address: FUNDING_ADDRESS }) +})); + +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async (where: { name?: string; id?: string }) => + where.name === "vortex" ? { payoutAddressEvm: VORTEX_PAYOUT_ADDRESS } : null +})); + +const { makeBrlOnrampBaseCrossChainFlow } = await import("../flows/brl-onramp-base-cross-chain"); +const { prepareDestinationTransferTxs } = await import("../phases/destination-transfer/transactions"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/evm-funding", () => ({ ...evmFundingReal })); + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); +}); + +function buildQuote(): QuoteTicketAttributes { + return { + from: "pix", + id: "quote-1", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + metadata: { + aveniaTransfer: { outputAmountRaw: "98800000000000000000" }, + evmToEvm: { inputAmountRaw: "17600000" }, + fees: { usd: { anchor: "0.1", network: "0.1", partnerMarkup: "0", total: "0.3", vortex: "0.1" } }, + nablaSwapEvm: { inputAmountForSwapRaw: "98800000000000000000", outputAmountRaw: "18000000" } + }, + network: Networks.Arbitrum, + outputAmount: "17.5", + outputCurrency: EvmToken.USDC, + partnerId: null, + pricingPartnerId: null, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + } as unknown as QuoteTicketAttributes; +} + +type BrlBlockMetadata = Awaited< + ReturnType["simulate"]> +>["metadata"]["blocks"]; + +function buildSubsidy(): SubsidyMetadata { + return { + actualOutputAmountDecimal: new Big("17.5"), + actualOutputAmountRaw: "17500000", + adjustedDifference: new Big(0), + adjustedTargetDiscount: new Big(0), + applied: false, + expectedOutputAmountDecimal: new Big("17.5"), + expectedOutputAmountRaw: "17500000", + idealSubsidyAmountInOutputTokenDecimal: new Big(0), + idealSubsidyAmountInOutputTokenRaw: "0", + partnerId: null, + subsidyAmountInOutputTokenDecimal: new Big(0), + subsidyAmountInOutputTokenRaw: "0", + subsidyRate: new Big(0), + targetOutputAmountDecimal: new Big("17.5"), + targetOutputAmountRaw: "17500000" + }; +} + +function buildMetadata(): FlowMetadata { + const subsidy = buildSubsidy(); + return { + blocks: { + aveniaMint: { + mint: { + currency: FiatToken.BRL, + fee: new Big(1), + inputAmountDecimal: new Big(100), + inputAmountRaw: "100000000000000000000", + outputAmountDecimal: new Big("98.8"), + outputAmountRaw: "98800000000000000000" + }, + transfer: { + currency: FiatToken.BRL, + fee: new Big("0.5"), + inputAmountDecimal: new Big("98.8"), + inputAmountRaw: "98800000000000000000", + outputAmountDecimal: new Big("98.3"), + outputAmountRaw: "98300000000000000000" + } + }, + destinationTransfer: { + amountDecimal: new Big("17.5"), + amountRaw: "17500000", + network: Networks.Arbitrum, + token: EvmToken.USDC + }, + distributeFees: { + anchorFeeUsd: "0.1", + networkFeeUsd: "0.1", + partnerMarkupUsd: "0", + totalFeesUsd: "0.2", + vortexFeeUsd: "0.1" + }, + finalSettlementSubsidy: { ...subsidy, amountRaw: "17500000", network: Networks.Arbitrum, token: EvmToken.USDC }, + fundEphemeral: { network: Networks.Base, token: EvmToken.BRLA }, + nablaSwap: { + effectiveExchangeRate: "0.18", + inputAmountForSwapDecimal: "98.8", + inputAmountForSwapRaw: "98800000000000000000", + inputCurrency: EvmToken.BRLA, + inputDecimals: 18, + inputToken: "0x1111111111111111111111111111111111111111", + outputAmountDecimal: new Big(18), + outputAmountRaw: "18000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + outputToken: "0x2222222222222222222222222222222222222222" + }, + squidRouterSwap: { + effectiveExchangeRate: "0.99", + fromNetwork: Networks.Base, + fromToken: "0x2222222222222222222222222222222222222222", + inputAmountDecimal: new Big("17.6"), + inputAmountRaw: "17600000", + networkFeeUSD: "0.1", + outputAmountDecimal: new Big("17.5"), + outputAmountRaw: "17500000", + toNetwork: Networks.Arbitrum, + toToken: "0x3333333333333333333333333333333333333333" + }, + subsidizePostSwap: { ...subsidy, outputCurrency: EvmToken.USDC, outputDecimals: 6 }, + subsidizePreSwap: { + expectedOutputAmountDecimal: new Big(18), + expectedOutputAmountRaw: "18000000", + inputCurrency: EvmToken.BRLA, + inputDecimals: 18, + network: Networks.Base, + targetInputAmountRaw: "98800000000000000000" + } + }, + globals: { + fees: { usd: { anchor: "0.1", network: "0.1", partnerMarkup: "0", total: "0.3", vortex: "0.1" } }, + partner: null, + request: REQUEST + } + }; +} + +function buildPrepareCtx() { + const { metadata: _metadata, ...quote } = buildQuote(); + return { + destinationAddress: DESTINATION_ADDRESS, + accounts: { + [EphemeralAccountType.EVM]: { address: EVM_EPHEMERAL_ADDRESS, type: EphemeralAccountType.EVM } as const + }, + metadata: buildMetadata(), + quote, + registrationFacts: { aveniaMint: { aveniaTicketId: "ticket-123", taxId: "tax-123" } } + }; +} + +describe("BRL onramp Base cross-chain transactions", () => { + it("assembles block-owned state and transaction calldata", async () => { + const flow = makeBrlOnrampBaseCrossChainFlow(Networks.Arbitrum, EvmToken.USDC); + const blocks = await flow.prepareTxs(buildPrepareCtx()); + + expect(blocks.stateMeta.destinationAddress).toBe(DESTINATION_ADDRESS); + expect(blocks.stateMeta.evmEphemeralAddress).toBe(EVM_EPHEMERAL_ADDRESS); + expect(blocks.stateMeta.phaseFlow).toEqual([ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" + ]); + expect(blocks.stateMeta.blockState).toEqual({ + aveniaMint: { aveniaTicketId: "ticket-123", taxId: "tax-123" }, + nablaSwap: { softMinimumOutputRaw: expect.any(String) }, + squidRouterSwap: { + quoteId: "squid-quote-id", + receiverHash: "0xreceiverhash", + receiverId: "receiver-id" + } + }); + expect(blocks.stateMeta.transactionPlan).toEqual({ + nativePrefunding: { [`${Networks.Base}:${EVM_EPHEMERAL_ADDRESS.toLowerCase()}`]: "123" } + }); + expect(blocks.unsignedTxs.find(tx => tx.phase === "nablaApprove")?.txData).toMatchObject({ data: "0xc1" }); + expect(blocks.unsignedTxs.find(tx => tx.phase === "nablaSwap")?.txData).toMatchObject({ data: "0xc2" }); + expect(blocks.unsignedTxs.find(tx => tx.phase === "squidRouterApprove")?.txData).toMatchObject({ data: "0xa1" }); + expect(blocks.unsignedTxs.find(tx => tx.phase === "squidRouterSwap")?.txData).toMatchObject({ data: "0xa2" }); + expect(blocks.unsignedTxs.find(tx => tx.phase === "backupSquidRouterApprove")?.txData).toMatchObject({ data: "0xb1" }); + expect(blocks.unsignedTxs.find(tx => tx.phase === "backupSquidRouterSwap")?.txData).toMatchObject({ data: "0xb2" }); + }); + + it("allocates the production nonce lanes per network", async () => { + const flow = makeBrlOnrampBaseCrossChainFlow(Networks.Arbitrum, EvmToken.USDC); + const { unsignedTxs } = await flow.prepareTxs(buildPrepareCtx()); + + const tuples = unsignedTxs.map(tx => [tx.phase, tx.network, tx.nonce]); + expect(tuples).toEqual( + expect.arrayContaining([ + ["nablaApprove", Networks.Base, 0], + ["nablaSwap", Networks.Base, 1], + ["distributeFees", Networks.Base, 2], + ["squidRouterApprove", Networks.Base, 3], + ["squidRouterSwap", Networks.Base, 4], + ["baseCleanupBrla", Networks.Base, 5], + ["baseCleanupUsdc", Networks.Base, 6], + ["destinationTransfer", Networks.Arbitrum, 0], + ["backupSquidRouterApprove", Networks.Arbitrum, 1], + ["backupSquidRouterSwap", Networks.Arbitrum, 2], + ["backupApprove", Networks.Arbitrum, 0] + ]) + ); + expect(unsignedTxs).toHaveLength(11); + expect(unsignedTxs.every(tx => tx.signer === EVM_EPHEMERAL_ADDRESS)).toBe(true); + }); + + it("supports client-side signing for every prepared transaction", async () => { + const blocks = await makeBrlOnrampBaseCrossChainFlow(Networks.Arbitrum, EvmToken.USDC).prepareTxs(buildPrepareCtx()); + const evmEphemeral = { + address: EVM_EPHEMERAL_ADDRESS, + secret: EVM_EPHEMERAL_PRIVATE_KEY, + type: EphemeralAccountType.EVM + }; + + const presignedTxs = await signUnsignedTransactions(blocks.unsignedTxs, { evmEphemeral }); + expect(presignedTxs.length).toBeGreaterThanOrEqual(blocks.unsignedTxs.length); + expect(presignedTxs.every(tx => typeof tx.txData === "string" && tx.txData.startsWith("0x"))).toBe(true); + }, 60_000); + + it("preserves 18-decimal BSC USDT precision in the destination transfer", async () => { + const amountRaw = "17500000000000000000"; + const prepared = await prepareDestinationTransferTxs({ + accounts: { + [EphemeralAccountType.EVM]: { address: EVM_EPHEMERAL_ADDRESS, type: EphemeralAccountType.EVM } + }, + destinationAddress: DESTINATION_ADDRESS, + globals: {} as never, + ownMetadata: { + amountDecimal: new Big("17.5"), + amountRaw, + network: Networks.BSC, + token: EvmToken.USDT + }, + ownRegistrationFacts: undefined, + quote: {} as never + }); + const txData = prepared.intents[0].txData as { data: `0x${string}` }; + const decoded = decodeFunctionData({ abi: erc20Abi, data: txData.data }); + expect(decoded.args).toEqual([DESTINATION_ADDRESS, BigInt(amountRaw)]); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-direct.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-direct.flow.test.ts new file mode 100644 index 000000000..9839f7474 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-direct.flow.test.ts @@ -0,0 +1,87 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { BrlaApiService, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import * as feesNamespace from "../core/fees"; +const BRL_ONRAMP_BASE_DIRECT: RampPhase[] = [ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "destinationTransfer", + "complete" +]; +import { getBlockMetadata } from "../core/metadata"; +import type { PhaseCtx } from "../core/types"; +import { DestinationTransferContext } from "../phases/destination-transfer/simulation"; + +const feesReal = { ...feesNamespace }; +const brlaApiServiceGetInstanceReal = BrlaApiService.getInstance; +const FEES = { + displayFiat: { anchor: "1.5", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "1.8", vortex: "0.3" }, + usd: { anchor: "0.27", network: "0", partnerMarkup: "0", total: "0.324", vortex: "0.054" } +}; +const calculateFeesMock = mock(async () => FEES); + +mock.module("../core/fees", () => ({ + calculateFees: calculateFeesMock, + computeFees: async (ctx: PhaseCtx) => { + ctx.fees ??= FEES; + } +})); + +const { brlOnrampBaseDirectFlow, brlOnrampBaseDirectPhaseFlow } = await import("../flows/brl-onramp-base-direct"); + +afterAll(() => { + BrlaApiService.getInstance = brlaApiServiceGetInstanceReal; + mock.module("../core/fees", () => ({ ...feesReal })); +}); + +const CORE_PHASES: RampPhase[] = ["brlaOnrampMint", "fundEphemeral", "destinationTransfer"]; + +function buildCtx(): PhaseCtx { + return { + addNote: () => undefined, + now: new Date(), + notes: [], + partner: null, + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + rampType: RampDirection.BUY, + to: Networks.Base + } + }; +} + +describe("BRL direct Base onramp flow", () => { + it("matches the production phase sequence and executor coverage", () => { + expect(brlOnrampBaseDirectFlow.phases).toEqual(CORE_PHASES); + expect(brlOnrampBaseDirectPhaseFlow).toEqual(BRL_ONRAMP_BASE_DIRECT); + expect(brlOnrampBaseDirectFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + }); + + it("simulates the direct Avenia mint without swap or bridge deductions", async () => { + BrlaApiService.getInstance = mock(() => ({ + createPayInQuote: mock(async (request: { inputCurrency: string }) => ({ + appliedFees: [{ amount: "0.2", type: "Gas Fee" }], + outputAmount: request.inputCurrency === "BRL" ? "99" : "98.5", + quoteToken: "mock-quote-token" + })) + })) as unknown as typeof BrlaApiService.getInstance; + + const result = await brlOnrampBaseDirectFlow.simulate(buildCtx()); + + expect(result.output).toMatchObject({ amountRaw: "98300000000000000000", chain: Networks.Base, token: EvmToken.BRLA }); + expect(calculateFeesMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + anchor: { amount: "1.5", currency: FiatToken.BRL }, + network: { amount: "0", currency: EvmToken.USDC } + }) + ); + expect(result.metadata.globals.fees).toEqual(FEES); + expect(Object.keys(result.metadata.blocks)).toEqual(["aveniaMint", "fundEphemeral", "destinationTransfer"]); + expect(getBlockMetadata(result.metadata, DestinationTransferContext).amountRaw).toBe("98300000000000000000"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-direct.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-direct.transactions.test.ts new file mode 100644 index 000000000..a852c3fc1 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-direct.transactions.test.ts @@ -0,0 +1,119 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { EphemeralAccountType, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import type { FlowMetadata } from "../core/metadata"; + +const sharedReal = { ...sharedNamespace }; +const EVM_EPHEMERAL_ADDRESS = "0x3434343434343434343434343434343434343434"; +const DESTINATION_ADDRESS = "0x1212121212121212121212121212121212121212"; +const REQUEST = { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + rampType: RampDirection.BUY, + to: Networks.Base +}; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + estimateFeesPerGas: async () => ({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000n }) + }) + }) + } +})); + +const { brlOnrampBaseDirectFlow } = await import("../flows/brl-onramp-base-direct"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); +}); + +function buildQuote(): QuoteTicketAttributes { + return { + from: EPaymentMethod.PIX, + id: "quote-direct", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + metadata: {}, + network: Networks.Base, + outputAmount: "98.3", + outputCurrency: EvmToken.BRLA, + partnerId: null, + pricingPartnerId: null, + rampType: RampDirection.BUY, + to: Networks.Base + } as unknown as QuoteTicketAttributes; +} + +type DirectBlockMetadata = Awaited>["metadata"]["blocks"]; + +function buildMetadata(): FlowMetadata { + return { + blocks: { + aveniaMint: { + mint: { + currency: FiatToken.BRL, + fee: new Big(1), + inputAmountDecimal: new Big(100), + inputAmountRaw: "100000000000000000000", + outputAmountDecimal: new Big("98.8"), + outputAmountRaw: "98800000000000000000" + }, + transfer: { + currency: FiatToken.BRL, + fee: new Big("0.5"), + inputAmountDecimal: new Big("98.8"), + inputAmountRaw: "98800000000000000000", + outputAmountDecimal: new Big("98.3"), + outputAmountRaw: "98300000000000000000" + } + }, + destinationTransfer: { + amountDecimal: new Big("98.3"), + amountRaw: "98300000000000000000", + network: Networks.Base, + token: EvmToken.BRLA + }, + fundEphemeral: { network: Networks.Base, token: EvmToken.BRLA } + }, + globals: { + fees: { usd: { anchor: "0.27", network: "0", partnerMarkup: "0", total: "0.324", vortex: "0.054" } }, + partner: null, + request: REQUEST + } + }; +} + +describe("BRL onramp Base direct transactions", () => { + it("prepares the direct transfer and preserves phase-local state", async () => { + const quote = buildQuote(); + const blocks = await brlOnrampBaseDirectFlow.prepareTxs({ + destinationAddress: DESTINATION_ADDRESS, + accounts: { [EphemeralAccountType.EVM]: { address: EVM_EPHEMERAL_ADDRESS, type: EphemeralAccountType.EVM } }, + metadata: buildMetadata(), + quote: quote as never, + registrationFacts: { aveniaMint: { aveniaTicketId: "ticket-123", taxId: "tax-123" } } + }); + expect(blocks.unsignedTxs.map(tx => [tx.phase, tx.network, tx.signer, tx.nonce])).toEqual([ + ["destinationTransfer", Networks.Base, EVM_EPHEMERAL_ADDRESS, 0] + ]); + expect(blocks.unsignedTxs[0].txData).toMatchObject({ gas: "100000", value: "0" }); + expect(blocks.stateMeta).toEqual({ + accountAddresses: { [EphemeralAccountType.EVM]: EVM_EPHEMERAL_ADDRESS }, + blockState: { aveniaMint: { aveniaTicketId: "ticket-123", taxId: "tax-123" } }, + destinationAddress: DESTINATION_ADDRESS, + evmEphemeralAddress: EVM_EPHEMERAL_ADDRESS, + flow: brlOnrampBaseDirectFlow.identity, + isDirectTransfer: true, + phaseFlow: ["initial", "brlaOnrampMint", "fundEphemeral", "destinationTransfer", "complete"], + transactionPlan: { nativePrefunding: {} } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts new file mode 100644 index 000000000..1a5c7826f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts @@ -0,0 +1,181 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { + BrlaApiService, + EPaymentMethod, + EvmToken, + evmTokenConfig, + FiatToken, + Networks, + RampDirection, + type RampPhase +} from "@vortexfi/shared"; +import Big from "big.js"; +import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; + +const partnerPricingReal = { ...partnerPricingNamespace }; +const brlaApiServiceGetInstanceReal = BrlaApiService.getInstance; + +mock.module("../core/nabla", () => ({ + calculateNablaSwapOutput: async () => { + throw new Error("calculateNablaSwapOutput should not be called in EVM-only smoke test"); + }, + calculateNablaSwapOutputEvm: async () => ({ + effectiveExchangeRate: "0.18", + nablaOutputAmountDecimal: new Big(18), + nablaOutputAmountRaw: "18000000" + }) +})); + +mock.module("../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: async ({ toToken }: { toToken: string }) => { + const token = Object.values(evmTokenConfig[Networks.Base]).find( + candidate => candidate?.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase() + ); + return { + finalEffectiveExchangeRate: "0.99", + finalGrossOutputAmountDecimal: new Big("17.5"), + networkFeeUSD: "0.1", + outputTokenDecimals: token?.decimals ?? 6 + }; + }, + getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({ + networkFeeUSD: "0.1", + outputAmountDecimal: new Big(amountDecimal) + }), + getBridgeTargetTokenDetails: (token: EvmToken) => evmTokenConfig[Networks.Base][token] +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { + convertCurrency: async (amount: string) => amount, + getFiatToUsdExchangeRate: async () => new Big("0.18") + } +})); + +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async () => null +})); + +afterAll(() => { + BrlaApiService.getInstance = brlaApiServiceGetInstanceReal; + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); +}); + +import { getBlockMetadata } from "../core/metadata"; +import type { PhaseCtx } from "../core/types"; +import { DestinationTransferContext } from "../phases/destination-transfer/simulation"; +import { DistributeFeesContext } from "../phases/distribute-fees/simulation"; +import { SubsidizePostContext } from "../phases/subsidize-post/simulation"; +const { assemblePhaseFlow } = await import("../core/phase-flow"); +const { + brlOnrampBaseSameChainFlow, + brlOnrampBaseSameChainPhaseFlow, + brlOnrampBaseSameChainSwapPhaseFlow, + makeBrlOnrampBaseSameChainSwapFlow +} = await import("../flows/brl-onramp-base-same-chain"); + +const ROUTED_BASE_OUTPUTS = [EvmToken.USDT, EvmToken.ETH, EvmToken.AXLUSDC, EvmToken.EURC] as const; +const COMMON_PHASES: RampPhase[] = [ + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap" +]; +const BRL_ONRAMP_BASE_SAME_CHAIN: RampPhase[] = ["initial", ...COMMON_PHASES, "destinationTransfer", "complete"]; +const BRL_ONRAMP_BASE_SAME_CHAIN_SWAP: RampPhase[] = [ + "initial", + ...COMMON_PHASES, + "squidRouterSwap", + "destinationTransfer", + "complete" +]; + +function buildCtx(outputCurrency: EvmToken): PhaseCtx { + return { + addNote() {}, + fees: { + displayFiat: { anchor: "0.1", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" }, + usd: { anchor: "0.1", network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" } + }, + notes: [], + now: new Date(), + partner: null, + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency, + rampType: RampDirection.BUY, + to: Networks.Base + } + }; +} + +async function simulate(outputCurrency: EvmToken) { + BrlaApiService.getInstance = mock(() => ({ + createPayInQuote: mock(async (request: { inputCurrency: string }) => ({ + appliedFees: [{ amount: "0.2", type: "Gas Fee" }], + outputAmount: request.inputCurrency === "BRL" ? "99" : "98.5", + quoteToken: "mock-quote-token" + })) + })) as unknown as typeof BrlaApiService.getInstance; + const flow = + outputCurrency === EvmToken.USDC + ? brlOnrampBaseSameChainFlow + : makeBrlOnrampBaseSameChainSwapFlow(outputCurrency); + return flow.simulate(buildCtx(outputCurrency)); +} + +describe("BRL Base same-chain block flows", () => { + it("keeps Base USDC on the no-Squid topology", () => { + expect(brlOnrampBaseSameChainFlow.phases).toEqual([...COMMON_PHASES, "destinationTransfer"]); + expect(brlOnrampBaseSameChainPhaseFlow).toEqual(BRL_ONRAMP_BASE_SAME_CHAIN); + expect(brlOnrampBaseSameChainFlow.executors.map(executor => executor.getPhaseName())).toEqual([ + ...COMMON_PHASES, + "destinationTransfer" + ]); + }); + + it("uses the one-phase same-chain Squid topology for every routed Base output", () => { + expect(brlOnrampBaseSameChainSwapPhaseFlow).toEqual(BRL_ONRAMP_BASE_SAME_CHAIN_SWAP); + for (const outputCurrency of ROUTED_BASE_OUTPUTS) { + const flow = makeBrlOnrampBaseSameChainSwapFlow(outputCurrency); + expect(assemblePhaseFlow(flow)).toEqual(BRL_ONRAMP_BASE_SAME_CHAIN_SWAP); + expect(flow.executors.map(executor => executor.getPhaseName())).toEqual([ + ...COMMON_PHASES, + "squidRouterSwap", + "destinationTransfer" + ]); + expect(flow.phases).not.toContain("squidRouterPay"); + expect(flow.phases).not.toContain("finalSettlementSubsidy"); + } + }); + + for (const outputCurrency of [EvmToken.USDC, ...ROUTED_BASE_OUTPUTS]) { + it(`simulates BRL to Base ${outputCurrency} with phase-owned metadata`, async () => { + const { metadata, output } = await simulate(outputCurrency); + expect(output.chain).toBe(Networks.Base); + expect(output.token).toBe(outputCurrency); + expect(output.amount.gt(0)).toBe(true); + expect(Object.hasOwn(metadata.blocks, "squidRouterSwap")).toBe(outputCurrency !== EvmToken.USDC); + const destinationTransfer = getBlockMetadata(metadata, DestinationTransferContext); + expect(destinationTransfer.network).toBe(Networks.Base); + expect(destinationTransfer.token).toBe(outputCurrency); + expect(metadata.globals.fees.usd.anchor).toBe("1.5"); + expect(metadata.globals.fees.usd.network).toBe(outputCurrency === EvmToken.USDC ? "0" : "0.1"); + expect(metadata.globals.fees.usd.total).toBe(outputCurrency === EvmToken.USDC ? "1.600000" : "1.700000"); + expect(metadata.globals.fees.displayFiat?.network).toBe(outputCurrency === EvmToken.USDC ? "0" : "0.1"); + expect(metadata.globals.fees.displayFiat?.total).toBe(outputCurrency === EvmToken.USDC ? "1.60" : "1.70"); + expect(getBlockMetadata(metadata, DistributeFeesContext).networkFeeUsd).toBe( + outputCurrency === EvmToken.USDC ? "0" : "0.1" + ); + const subsidizePost = getBlockMetadata(metadata, SubsidizePostContext); + expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe(outputCurrency === EvmToken.USDC ? "17.9" : "17.8"); + expect(subsidizePost.applied).toBe(false); + }); + } +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.transactions.test.ts new file mode 100644 index 000000000..152864a9b --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.transactions.test.ts @@ -0,0 +1,364 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { + EphemeralAccountType, + EPaymentMethod, + EvmToken, + EvmTokenDetails, + FiatToken, + Networks, + RampDirection, +} from "@vortexfi/shared"; +import Big from "big.js"; +import { privateKeyToAccount } from "viem/accounts"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import * as evmFundingNamespace from "../core/evm-funding"; +import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; +import type { FlowMetadata } from "../core/metadata"; +import type { SubsidyMetadata } from "../phases/subsidize-pre/simulation"; + +const sharedReal = { ...sharedNamespace }; +const evmFundingReal = { ...evmFundingNamespace }; +const partnerPricingReal = { ...partnerPricingNamespace }; +const baseBuilderCalls: EvmToken[] = []; +const nablaHardMinimums: string[] = []; +const EVM_EPHEMERAL_ADDRESS = privateKeyToAccount( + "0x3434343434343434343434343434343434343434343434343434343434343434" +).address; +const DESTINATION_ADDRESS = "0x1212121212121212121212121212121212121212"; +const FUNDING_ADDRESS = "0x9999999999999999999999999999999999999999"; +const VORTEX_PAYOUT_ADDRESS = "0x8888888888888888888888888888888888888888"; +const BASE_OUTPUTS = [EvmToken.USDC, EvmToken.USDT, EvmToken.ETH, EvmToken.AXLUSDC, EvmToken.EURC] as const; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + createNablaTransactionsForOnrampOnEVM: async (...args: unknown[]) => { + nablaHardMinimums.push(String(args[4])); + return { + approve: { data: "0xc1", gas: "100000", to: "0x3333333333333333333333333333333333333333", value: "0" }, + swap: { data: "0xc2", gas: "500000", to: "0x3333333333333333333333333333333333333333", value: "0" } + }; + }, + createOnrampSquidrouterTransactionsFromBaseToEvm: async ({ toToken }: { toToken: string }) => { + const token = Object.entries(sharedReal.evmTokenConfig[Networks.Base]).find( + ([, details]) => details?.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase() + )?.[0] as EvmToken; + baseBuilderCalls.push(token); + return { + approveData: { data: "0xa1", gas: "100000", to: "0x1111111111111111111111111111111111111111", value: "0" }, + squidRouterQuoteId: `squid-${token}`, + squidRouterReceiverHash: `hash-${token}`, + squidRouterReceiverId: `receiver-${token}`, + swapData: { data: "0xa2", gas: "500000", to: "0x1111111111111111111111111111111111111111", value: "123" } + }; + }, + createOnrampSquidrouterTransactionsFromPolygonToEvm: async () => { + throw new Error("BRL Base same-chain preparation must not call the Polygon builder"); + }, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + estimateFeesPerGas: async () => ({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000n }) + }) + }) + }, + getNablaBasePool: () => ({ router: "0x4444444444444444444444444444444444444444" }) +})); + +mock.module("../core/evm-funding", () => ({ + getEvmFundingAccount: () => ({ address: FUNDING_ADDRESS }) +})); + +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async (where: { name?: string }) => + where.name === "vortex" ? { payoutAddressEvm: VORTEX_PAYOUT_ADDRESS } : null +})); + + +const { brlOnrampBaseSameChainFlow, makeBrlOnrampBaseSameChainSwapFlow } = await import( + "../flows/brl-onramp-base-same-chain" +); +const { prepareNablaSwapTxs } = await import("../phases/nabla-swap/transactions"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/evm-funding", () => ({ ...evmFundingReal })); + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); +}); + +function tokenDetails(outputCurrency: EvmToken): EvmTokenDetails { + return sharedReal.evmTokenConfig[Networks.Base][outputCurrency] as EvmTokenDetails; +} + +function outputAmountRaw(outputCurrency: EvmToken): string { + return new Big("17.5").mul(new Big(10).pow(tokenDetails(outputCurrency).decimals)).toFixed(0, 0); +} + +function buildRequest(outputCurrency: EvmToken) { + return { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency, + rampType: RampDirection.BUY, + to: Networks.Base + }; +} + +function buildQuote(outputCurrency: EvmToken): QuoteTicketAttributes { + return { + ...buildRequest(outputCurrency), + id: `quote-${outputCurrency}`, + metadata: { + aveniaTransfer: { outputAmountRaw: "98800000000000000000" }, + evmToEvm: { inputAmountRaw: "17600000", outputAmountRaw: outputAmountRaw(outputCurrency) }, + fees: { usd: { anchor: "0.1", network: "0.1", partnerMarkup: "0", total: "0.3", vortex: "0.1" } }, + nablaSwapEvm: { inputAmountForSwapRaw: "98800000000000000000", outputAmountRaw: "18000000" } + }, + outputAmount: "17.5", + partnerId: null, + pricingPartnerId: null + } as unknown as QuoteTicketAttributes; +} + +function subsidy(outputCurrency: EvmToken): SubsidyMetadata { + const amountRaw = outputAmountRaw(outputCurrency); + return { + actualOutputAmountDecimal: new Big("17.5"), + actualOutputAmountRaw: amountRaw, + adjustedDifference: new Big(0), + adjustedTargetDiscount: new Big(0), + applied: false, + expectedOutputAmountDecimal: new Big("17.5"), + expectedOutputAmountRaw: amountRaw, + idealSubsidyAmountInOutputTokenDecimal: new Big(0), + idealSubsidyAmountInOutputTokenRaw: "0", + partnerId: null, + subsidyAmountInOutputTokenDecimal: new Big(0), + subsidyAmountInOutputTokenRaw: "0", + subsidyRate: new Big(0), + targetOutputAmountDecimal: new Big("17.5"), + targetOutputAmountRaw: amountRaw + }; +} + +function buildMetadata(outputCurrency: EvmToken): FlowMetadata { + const postSwapSubsidy = subsidy(EvmToken.USDC); + const details = tokenDetails(outputCurrency); + const blocks: Record = { + aveniaMint: { + mint: { + currency: FiatToken.BRL, + fee: new Big(1), + inputAmountDecimal: new Big(100), + inputAmountRaw: "100000000000000000000", + outputAmountDecimal: new Big("98.8"), + outputAmountRaw: "98800000000000000000" + }, + transfer: { + currency: FiatToken.BRL, + fee: new Big("0.5"), + inputAmountDecimal: new Big("98.8"), + inputAmountRaw: "98800000000000000000", + outputAmountDecimal: new Big("98.3"), + outputAmountRaw: "98300000000000000000" + } + }, + destinationTransfer: { + amountDecimal: new Big("17.5"), + amountRaw: outputAmountRaw(outputCurrency), + network: Networks.Base, + token: outputCurrency + }, + distributeFees: { + anchorFeeUsd: "0.1", + networkFeeUsd: "0.1", + partnerMarkupUsd: "0", + totalFeesUsd: "0.2", + vortexFeeUsd: "0.1" + }, + fundEphemeral: { network: Networks.Base, token: EvmToken.BRLA }, + nablaSwap: { + effectiveExchangeRate: "0.18", + inputAmountForSwapDecimal: "98.8", + inputAmountForSwapRaw: "98800000000000000000", + inputCurrency: EvmToken.BRLA, + inputDecimals: 18, + inputToken: sharedReal.evmTokenConfig[Networks.Base][EvmToken.BRLA]!.erc20AddressSourceChain, + outputAmountDecimal: new Big(18), + outputAmountRaw: "18000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + outputToken: sharedReal.evmTokenConfig[Networks.Base][EvmToken.USDC]!.erc20AddressSourceChain + }, + subsidizePostSwap: { ...postSwapSubsidy, outputCurrency: EvmToken.USDC, outputDecimals: 6 }, + subsidizePreSwap: { + expectedOutputAmountDecimal: new Big(18), + expectedOutputAmountRaw: "18000000", + inputCurrency: EvmToken.BRLA, + inputDecimals: 18, + network: Networks.Base, + targetInputAmountRaw: "98800000000000000000" + } + }; + if (outputCurrency !== EvmToken.USDC) { + blocks.squidRouterSwap = { + effectiveExchangeRate: "0.99", + fromNetwork: Networks.Base, + fromToken: sharedReal.evmTokenConfig[Networks.Base][EvmToken.USDC]!.erc20AddressSourceChain, + inputAmountDecimal: new Big("17.6"), + inputAmountRaw: "17600000", + networkFeeUSD: "0.1", + outputAmountDecimal: new Big("17.5"), + outputAmountRaw: outputAmountRaw(outputCurrency), + toNetwork: Networks.Base, + toToken: details.erc20AddressSourceChain + }; + } + return { + blocks, + globals: { + fees: { usd: { anchor: "0.1", network: "0.1", partnerMarkup: "0", total: "0.3", vortex: "0.1" } }, + partner: null, + request: buildRequest(outputCurrency) + } + }; +} + +describe("BRL Base same-chain transactions", () => { + for (const outputCurrency of BASE_OUTPUTS) { + it(`preserves state, transactions, and nonce order for Base ${outputCurrency}`, async () => { + baseBuilderCalls.length = 0; + const quote = buildQuote(outputCurrency); + const flow = + outputCurrency === EvmToken.USDC + ? brlOnrampBaseSameChainFlow + : makeBrlOnrampBaseSameChainSwapFlow(outputCurrency); + const { metadata: _metadata, ...quoteFields } = quote; + const prepared = await flow.prepareTxs({ + accounts: { [EphemeralAccountType.EVM]: { address: EVM_EPHEMERAL_ADDRESS, type: EphemeralAccountType.EVM } }, + destinationAddress: DESTINATION_ADDRESS, + metadata: buildMetadata(outputCurrency) as never, + quote: quoteFields as never, + registrationFacts: { aveniaMint: { aveniaTicketId: "ticket-123", taxId: "tax-123" } } + }); + expect(prepared.unsignedTxs.every(tx => tx.network === Networks.Base && tx.signer === EVM_EPHEMERAL_ADDRESS)).toBe(true); + expect(prepared.stateMeta.phaseFlow).toEqual( + outputCurrency === EvmToken.USDC + ? [ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "destinationTransfer", + "complete" + ] + : [ + "initial", + "brlaOnrampMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "destinationTransfer", + "complete" + ] + ); + expect(prepared.stateMeta.blockState).toEqual({ + aveniaMint: { aveniaTicketId: "ticket-123", taxId: "tax-123" }, + nablaSwap: { softMinimumOutputRaw: expect.any(String) }, + ...(outputCurrency === EvmToken.USDC + ? {} + : { + squidRouterSwap: { + quoteId: `squid-${outputCurrency}`, + receiverHash: `hash-${outputCurrency}`, + receiverId: `receiver-${outputCurrency}` + } + }) + }); + expect(prepared.stateMeta.transactionPlan).toEqual({ + nativePrefunding: + outputCurrency === EvmToken.USDC + ? {} + : { [`${Networks.Base}:${EVM_EPHEMERAL_ADDRESS.toLowerCase()}`]: "123" } + }); + expect(prepared.unsignedTxs.map(tx => [tx.phase, tx.nonce])).toEqual( + outputCurrency === EvmToken.USDC + ? [ + ["nablaApprove", 0], + ["nablaSwap", 1], + ["distributeFees", 2], + ["destinationTransfer", 3], + ["baseCleanupBrla", 4], + ["baseCleanupUsdc", 5] + ] + : [ + ["nablaApprove", 0], + ["nablaSwap", 1], + ["distributeFees", 2], + ["squidRouterApprove", 3], + ["squidRouterSwap", 4], + ["destinationTransfer", 5], + ["baseCleanupBrla", 6], + ["baseCleanupUsdc", 7] + ] + ); + expect(prepared.unsignedTxs.some(tx => tx.phase.startsWith("backup"))).toBe(false); + expect(prepared.unsignedTxs.find(tx => tx.phase === "nablaApprove")?.txData).toMatchObject({ data: "0xc1" }); + expect(prepared.unsignedTxs.find(tx => tx.phase === "nablaSwap")?.txData).toMatchObject({ data: "0xc2" }); + if (outputCurrency !== EvmToken.USDC) { + expect(prepared.unsignedTxs.find(tx => tx.phase === "squidRouterApprove")?.txData).toMatchObject({ data: "0xa1" }); + expect(prepared.unsignedTxs.find(tx => tx.phase === "squidRouterSwap")?.txData).toMatchObject({ data: "0xa2" }); + } + expect(baseBuilderCalls).toEqual(outputCurrency === EvmToken.USDC ? [] : [outputCurrency]); + }); + } + + it("uses the AMM-only output for Nabla minimums when an offramp subsidy is present", async () => { + nablaHardMinimums.length = 0; + const prepared = await prepareNablaSwapTxs( + Networks.Base, + EvmToken.BRLA, + EvmToken.USDC, + { + accounts: { [EphemeralAccountType.EVM]: { address: EVM_EPHEMERAL_ADDRESS, type: EphemeralAccountType.EVM } }, + globals: {} as never, + ownMetadata: { + ammOutputAmountRaw: "1000000", + inputAmountForSwapDecimal: "1", + inputAmountForSwapRaw: "1000000000000000000", + inputCurrency: EvmToken.BRLA, + inputDecimals: 18, + inputToken: tokenDetails(EvmToken.BRLA).erc20AddressSourceChain, + outputAmountDecimal: new Big(2), + outputAmountRaw: "2000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + outputToken: tokenDetails(EvmToken.USDC).erc20AddressSourceChain + }, + ownRegistrationFacts: undefined, + quote: {} as never + }, + false + ); + expect(nablaHardMinimums).toEqual([ + Big(1_000_000) + .mul(1 - sharedReal.AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0) + ]); + expect(prepared.state).toEqual({ + softMinimumOutputRaw: Big(1_000_000) + .mul(1 - sharedReal.AMM_MINIMUM_OUTPUT_SOFT_MARGIN) + .toFixed(0, 0) + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base.registration.test.ts new file mode 100644 index 000000000..f852396b2 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base.registration.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "bun:test"; +import { createRegisterAveniaMint } from "../phases/avenia-mint/registration"; + +describe("BRL Base Avenia registration", () => { + it("owns customer resolution, ticket creation, and PIX artifacts", async () => { + const register = createRegisterAveniaMint({ + createTicket: async () => ({ aveniaTicketId: "ticket-base", brCode: "pix-base" }), + resolveAccount: async () => ({ taxId: "12345678901" }) as never + }); + const registered = await register({ + authenticatedUser: { id: "user-1" }, + input: { taxId: "123.456.789-01" }, + metadata: {} as never, + quote: { inputAmount: "100" } as never, + signingAccounts: [] + }); + + expect(registered.facts).toEqual({ aveniaTicketId: "ticket-base", taxId: "12345678901" }); + expect(registered.responseArtifacts).toEqual({ depositQrCode: "pix-base" }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/core.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/core.test.ts new file mode 100644 index 000000000..f9bcf5f19 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/core.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "bun:test"; +import { + AssetHubToken, + EphemeralAccountType, + EPaymentMethod, + EvmToken, + FiatToken, + Networks, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import { FlowBuilder } from "../core/flow"; +import { assetHubRequestIO, evmRequestIO, fiatRequestIO } from "../core/io"; +import { defineContext } from "../core/metadata"; +import { allocateNonces } from "../core/prepare"; +import { resolveBlockQuoteExpiry } from "../core/quote"; +import type { Phase, PhaseCtx, PhaseIO, TxIntent } from "../core/types"; + +function phaseCtx(inputCurrency: FiatToken | EvmToken | AssetHubToken, network: Networks, inputAmount = "1.25"): PhaseCtx { + return { + addNote: () => undefined, + fees: { + displayFiat: { anchor: "0", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0", vortex: "0" }, + usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } + }, + notes: [], + now: new Date("2026-07-22T12:00:00.000Z"), + partner: null, + request: { + from: network, + inputAmount, + inputCurrency, + network, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX + } + }; +} + +describe("block flow request IO", () => { + it("converts EVM and AssetHub decimal amounts to configured raw units", async () => { + expect((await evmRequestIO(EvmToken.USDC, Networks.Base)(phaseCtx(EvmToken.USDC, Networks.Base))).amountRaw).toBe( + "1250000" + ); + expect( + (await assetHubRequestIO(AssetHubToken.DOT)(phaseCtx(AssetHubToken.DOT, Networks.AssetHub))).amountRaw + ).toBe("12500000000"); + }); + + it("rejects request chain and token mismatches", async () => { + expect(() => evmRequestIO(EvmToken.USDC, Networks.Base)(phaseCtx(EvmToken.USDT, Networks.Base))).toThrow( + "Expected on-chain flow input" + ); + expect(() => assetHubRequestIO(AssetHubToken.USDC)(phaseCtx(AssetHubToken.USDC, Networks.Base))).toThrow( + "Expected on-chain flow input" + ); + }); +}); + +function intent(overrides: Partial = {}): TxIntent { + return { + lane: "main", + network: Networks.Base, + phase: "initial", + signer: "0xabc", + txData: "0x", + ...overrides + }; +} + +describe("block flow nonce allocation", () => { + it("defaults to one nonce and advances by nonceSpan", () => { + expect(allocateNonces([intent({ nonceSpan: 2 }), intent()]).map(tx => tx.nonce)).toEqual([0, 2]); + }); + + it("rejects zero and impossible pinned spans", () => { + expect(() => allocateNonces([intent({ nonceSpan: 0 })])).toThrow("Invalid nonce span 0"); + expect(() => allocateNonces([intent({ nonceSpan: Number.MAX_SAFE_INTEGER }), intent()])).toThrow("safe nonce range"); + expect(() => allocateNonces([intent({ nonceSpan: 2, reuseFirstMainNonce: true })])).toThrow( + "cannot combine reuseFirstMainNonce" + ); + }); + + it("isolates cursors by signer and network while preserving lanes", () => { + const txs = allocateNonces([ + intent({ nonceSpan: 2 }), + intent({ signer: "0xdef" }), + intent({ network: Networks.Polygon }), + intent({ lane: "backup" }), + intent({ lane: "cleanup" }) + ]); + expect(txs.map(tx => [tx.network, tx.signer, tx.nonce])).toEqual([ + [Networks.Base, "0xabc", 0], + [Networks.Base, "0xdef", 0], + [Networks.Polygon, "0xabc", 0], + [Networks.Base, "0xabc", 2], + [Networks.Base, "0xabc", 3] + ]); + }); +}); + +const RegisteredContext = defineContext<{ version: number }>()("registered"); +const PlainContext = defineContext<{ value: string }>()("plain"); +type FiatBrlIO = PhaseIO; + +const RegisteredPhase: Phase = { + context: RegisteredContext, + name: "Registered", + phases: [], + prepareTxs: async ctx => ({ intents: [], state: { providerId: ctx.ownRegistrationFacts?.providerId } }), + register: async ctx => { + if (false) { + // @ts-expect-error quote economics are read-only during registration + ctx.quote.outputAmount = "changed"; + } + return { + facts: { providerId: `provider-${ctx.input.taxId}` }, + metadata: { version: ctx.metadata.version + 1 }, + responseArtifacts: { paymentReference: "reference" } + }; + }, + simulate: async input => ({ metadata: { version: 1 }, output: input }), + start: async ctx => ({ + metadata: { version: ctx.metadata.version + 1 }, + responseArtifacts: { started: true }, + state: { aveniaTicketId: (ctx.ownState as { providerId: string }).providerId } + }) +}; + +const PlainPhase: Phase = { + context: PlainContext, + name: "Plain", + phases: [], + prepareTxs: async ctx => ({ intents: [], state: { ownFacts: ctx.ownRegistrationFacts } }), + simulate: async input => ({ metadata: { value: "plain" }, output: input }) +}; + +describe("block flow registration", () => { + it("namespaces facts, metadata refreshes, artifacts, and preparation input by phase key", async () => { + const flow = FlowBuilder.start(fiatRequestIO(FiatToken.BRL), RegisteredPhase).pipe(PlainPhase).build("Registration"); + const metadata = { + blocks: { plain: { value: "plain" }, registered: { version: 1 } }, + globals: { fees: {} as never, partner: null, request: phaseCtx(FiatToken.BRL, Networks.Base).request } + }; + const registered = await flow.register({ + authenticatedUser: { id: "user" }, + input: { taxId: "123" }, + metadata, + quote: {} as never, + signingAccounts: [], + transaction: undefined + }); + + expect(registered.registrationFacts).toEqual({ registered: { providerId: "provider-123" } }); + expect(registered.metadata.flow).toEqual(flow.identity); + expect(registered.metadata.blocks.registered).toEqual({ version: 2 }); + expect(registered.responseArtifacts).toEqual({ registered: { paymentReference: "reference" } }); + + const prepared = await flow.prepareTxs({ + accounts: { + [EphemeralAccountType.Substrate]: { address: "substrate", type: EphemeralAccountType.Substrate } + }, + metadata: registered.metadata, + quote: {} as never, + registrationFacts: registered.registrationFacts + }); + expect(prepared.stateMeta.accountAddresses).toEqual({ Substrate: "substrate" }); + expect(prepared.stateMeta.blockState).toEqual({ + plain: { ownFacts: undefined }, + registered: { providerId: "provider-123" } + }); + expect(prepared.stateMeta.flow).toEqual(flow.identity); + expect(prepared.stateMeta.phaseFlow).toEqual(["initial", "complete"]); + + const started = await flow.start({ + metadata: registered.metadata, + quote: {} as never, + state: prepared.stateMeta as never, + userId: "user" + }); + expect(started.metadata.blocks.registered).toEqual({ version: 3 }); + expect(started.responseArtifacts).toEqual({ registered: { started: true } }); + expect(started.state.aveniaTicketId).toBe("provider-123"); + }); + + it("rejects persisted identity and phase topology mismatches before lifecycle hooks", async () => { + const flow = FlowBuilder.start(fiatRequestIO(FiatToken.BRL), RegisteredPhase).build("Versioned"); + const simulated = await flow.simulate(phaseCtx(FiatToken.BRL, Networks.Base)); + const badMetadata = { + ...simulated.metadata, + flow: { ...simulated.metadata.flow, topologyHash: "tampered" } + }; + expect(() => flow.assertMetadata(badMetadata)).toThrow("topologyHash"); + + const state = { + blockState: {}, + flow: flow.identity, + phaseFlow: ["initial", "nablaSwap", "complete"] + }; + expect(() => flow.assertState(state)).toThrow("phase sequence"); + }); +}); + +const ExpiringFirst: Phase = { + context: RegisteredContext, + name: "First", + phases: [], + simulate: async input => ({ expiresAt: new Date("2026-07-22T12:02:00.000Z"), metadata: { version: 1 }, output: input }) +}; + +const ExpiringSecond: Phase = { + context: PlainContext, + name: "Second", + phases: [], + simulate: async input => ({ expiresAt: new Date("2026-07-22T12:01:00.000Z"), metadata: { value: "plain" }, output: input }) +}; + +describe("block flow expiry", () => { + it("propagates the earliest phase expiry", async () => { + const flow = FlowBuilder.start(fiatRequestIO(FiatToken.BRL), ExpiringFirst).pipe(ExpiringSecond).build("Expiry"); + const result = await flow.simulate(phaseCtx(FiatToken.BRL, Networks.Base, "100")); + expect(result.expiresAt).toEqual(new Date("2026-07-22T12:01:00.000Z")); + expect(result.output.amount).toEqual(new Big(100)); + }); + + it("uses provider expiry when present and the standard ticket TTL otherwise", () => { + const now = new Date("2026-07-22T12:00:00.000Z"); + const providerExpiry = new Date("2026-07-22T12:00:30.000Z"); + expect(resolveBlockQuoteExpiry(providerExpiry, now)).toBe(providerExpiry); + expect(resolveBlockQuoteExpiry(undefined, now)).toEqual(new Date("2026-07-22T12:10:00.000Z")); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.flow.test.ts new file mode 100644 index 000000000..f3f568849 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.flow.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, type RampPhase } from "@vortexfi/shared"; +const EUR_OFFRAMP_BASE: RampPhase[] = [ + "initial", + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "mykoboPayoutOnBase", + "complete" +]; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { eurOfframpBaseFlow, makeEurOfframpBaseFlow } from "../flows/eur-offramp-base"; +import { resolveBlockFlow } from "../flows/catalog"; + +const CORE_PHASES: RampPhase[] = [ + "fundEphemeral", + "distributeFees", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "subsidizePostSwap", + "mykoboPayoutOnBase" +]; + +describe("EUR Base offramp flow", () => { + it("preserves phase topology and executor coverage", () => { + expect(eurOfframpBaseFlow.phases).toEqual(CORE_PHASES); + expect(eurOfframpBaseFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + expect(assemblePhaseFlow(eurOfframpBaseFlow)).toEqual(EUR_OFFRAMP_BASE); + }); + + it("uses the same runtime topology for direct, same-chain swap, and cross-chain sources", () => { + for (const flow of [ + makeEurOfframpBaseFlow(EvmToken.USDC, Networks.Base), + makeEurOfframpBaseFlow(EvmToken.EURC, Networks.Base), + makeEurOfframpBaseFlow(EvmToken.USDC, Networks.Polygon) + ]) { + expect(flow.phases).toEqual(CORE_PHASES); + expect(flow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + } + }); + + it("catalogs only supported EVM-to-SEPA variants", () => { + for (const [from, inputCurrency] of [ + [Networks.Base, EvmToken.USDC], + [Networks.Base, EvmToken.EURC], + [Networks.Polygon, EvmToken.USDC] + ] as const) { + expect( + resolveBlockFlow({ + from, + inputAmount: "100", + inputCurrency, + network: from, + outputCurrency: FiatToken.EURC, + rampType: RampDirection.SELL, + to: EPaymentMethod.SEPA + }).name + ).toBe("EurOfframpBase"); + } + expect(() => + resolveBlockFlow({ + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency: FiatToken.EURC, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX + }) + ).toThrow(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.registration.test.ts new file mode 100644 index 000000000..7b0cf34df --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.registration.test.ts @@ -0,0 +1,89 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { EphemeralAccountType, MykoboCurrency, MykoboTransactionType } from "@vortexfi/shared"; +import * as customerNamespace from "../../../mykobo/mykobo-customer.service"; + +const sharedReal = { ...sharedNamespace }; +const customerReal = { ...customerNamespace }; +const resolveCustomer = mock(async (_userId: string, providedEmail?: string) => { + if (providedEmail === "wrong@example.com") throw new Error("email mismatch"); + return { email: "verified@example.com" }; +}); +const createTransactionIntent = mock(async () => ({ + instructions: { address: "0x3434343434343434343434343434343434343434" }, + transaction: { id: "withdraw-1", reference: "EUR-WITHDRAW-1" } +})); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + MykoboApiService: { getInstance: () => ({ createTransactionIntent }) } +})); +mock.module("../../../mykobo/mykobo-customer.service", () => ({ resolveMykoboCustomerForUser: resolveCustomer })); + +const { registerMykoboOfframpPayout } = await import("../phases/mykobo-offramp-payout/registration"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../../mykobo/mykobo-customer.service", () => ({ ...customerReal })); +}); + +function context(ipAddress?: string, email = "verified@example.com") { + return { + authenticatedUser: { id: "user-1" }, + input: { email }, + ipAddress, + metadata: { + payoutAmountDecimal: "98.63", + payoutAmountRaw: "9863", + transferAmountDecimal: "98.98", + transferAmountRaw: "98980000" + }, + quote: {} as never, + signingAccounts: [{ address: "0x1212121212121212121212121212121212121212", type: EphemeralAccountType.EVM }] + }; +} + +describe("EUR offramp Mykobo registration trust boundary", () => { + it("derives identity and provider receivables before exposing typed facts", async () => { + const result = await registerMykoboOfframpPayout(context("203.0.113.4")); + expect(resolveCustomer).toHaveBeenCalledWith("user-1", "verified@example.com"); + expect(createTransactionIntent).toHaveBeenCalledWith({ + currency: MykoboCurrency.EURC, + email_address: "verified@example.com", + ip_address: "203.0.113.4", + transaction_type: MykoboTransactionType.WITHDRAW, + value: "98.98", + wallet_address: "0x1212121212121212121212121212121212121212" + }); + expect(result.facts).toEqual({ + mykoboEmail: "verified@example.com", + mykoboReceivablesAddress: "0x3434343434343434343434343434343434343434", + mykoboTransactionId: "withdraw-1", + mykoboTransactionReference: "EUR-WITHDRAW-1" + }); + }); + + it("rejects missing IP before identity or provider side effects", async () => { + resolveCustomer.mockClear(); + createTransactionIntent.mockClear(); + await expect(registerMykoboOfframpPayout(context())).rejects.toThrow("IP address"); + expect(resolveCustomer).not.toHaveBeenCalled(); + expect(createTransactionIntent).not.toHaveBeenCalled(); + }); + + it("rejects a mismatched supplied email before intent creation", async () => { + createTransactionIntent.mockClear(); + await expect(registerMykoboOfframpPayout(context("203.0.113.4", "wrong@example.com"))).rejects.toThrow("email mismatch"); + expect(createTransactionIntent).not.toHaveBeenCalled(); + }); + + it("rejects provider responses that do not contain withdrawal instructions", async () => { + createTransactionIntent.mockImplementationOnce( + (async () => ({ + instructions: { bank_account_name: "Not withdrawal instructions", iban: "DE89370400440532013000" }, + transaction: { id: "withdraw-invalid", reference: "EUR-INVALID-1" } + })) as never + ); + await expect(registerMykoboOfframpPayout(context("203.0.113.4"))).rejects.toThrow("receivables instructions"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.simulation.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.simulation.test.ts new file mode 100644 index 000000000..fafe584b9 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.simulation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, mock } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../priceFeed.service"; +import { simulateMykoboOfframpPayout } from "../phases/mykobo-offramp-payout/simulation"; +import { simulateMykoboOfframpFee } from "../phases/mykobo-offramp-fee/simulation"; + +function phaseContext() { + return { + addNote: () => {}, + fees: { + displayFiat: { anchor: "0", currency: FiatToken.EURC, network: "1.25", partnerMarkup: "0.2", total: "1.55", vortex: "0.1" }, + usd: { anchor: "0", network: "1.25", partnerMarkup: "0.2", total: "1.55", vortex: "0.1" } + }, + notes: [], + now: new Date(), + partner: null, + request: { + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency: FiatToken.EURC, + rampType: RampDirection.SELL, + to: EPaymentMethod.SEPA + }, + targetFeeFiatCurrency: FiatToken.EURC + }; +} + +describe("EUR offramp fee and payout simulation", () => { + it("replaces the anchor fee without discarding the source provider fee", async () => { + const originalConvert = priceFeedService.convertCurrency; + priceFeedService.convertCurrency = mock(async amount => String(amount)) as never; + try { + const result = await simulateMykoboOfframpFee( + { amount: new Big("98.987"), amountRaw: "98987000", chain: Networks.Base, token: EvmToken.EURC }, + phaseContext(), + { resolveWithdrawFee: async () => "0.35" } + ); + expect(result.metadata).toEqual({ anchorFeeEur: "0.35", grossAmountEur: "98.98" }); + expect(result.fees?.usd).toMatchObject({ anchor: "0.35", network: "1.25", total: "1.900000" }); + } finally { + priceFeedService.convertCurrency = originalConvert; + } + }); + + it("floors provider settlement to cents and subtracts the anchor only from fiat output", async () => { + const ctx = phaseContext(); + ctx.fees.displayFiat.anchor = "0.35"; + const result = await simulateMykoboOfframpPayout( + { amount: new Big("98.987654"), amountRaw: "98987654", chain: Networks.Base, token: EvmToken.EURC }, + ctx + ); + expect(result.metadata).toEqual({ + payoutAmountDecimal: new Big("98.63"), + payoutAmountRaw: "9863", + transferAmountDecimal: new Big("98.98"), + transferAmountRaw: "98980000" + }); + expect(result.output.amount.toString()).toBe("98.63"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.transactions.test.ts new file mode 100644 index 000000000..436ce9e16 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-offramp-base.transactions.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, mock } from "bun:test"; +import { EphemeralAccountType, EvmClientManager, EvmToken, Networks } from "@vortexfi/shared"; +import { decodeFunctionData, erc20Abi } from "viem"; +import type { PrepareCtx } from "../core/types"; +import type { MykoboOfframpPayoutRegistrationFacts } from "../phases/mykobo-offramp-payout/registration"; +import type { MykoboOfframpPayoutMetadata } from "../phases/mykobo-offramp-payout/simulation"; +import { prepareMykoboOfframpPayoutTxs } from "../phases/mykobo-offramp-payout/transactions"; + +const EPHEMERAL = "0x1212121212121212121212121212121212121212"; +const RECEIVABLES = "0x3434343434343434343434343434343434343434"; + +describe("EUR offramp payout transaction preparation", () => { + it("binds the signed payout to provider facts and appends all Base cleanup approvals", async () => { + const manager = EvmClientManager.getInstance() as unknown as { getClient: (...args: unknown[]) => unknown }; + const originalGetClient = manager.getClient; + manager.getClient = mock(() => ({ estimateFeesPerGas: async () => ({ maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }) })); + try { + const context: PrepareCtx = { + accounts: { [EphemeralAccountType.EVM]: { address: EPHEMERAL, type: EphemeralAccountType.EVM } }, + globals: {} as never, + ownMetadata: { + payoutAmountDecimal: "98.63", + payoutAmountRaw: "9863", + transferAmountDecimal: "98.98", + transferAmountRaw: "98980000" + }, + ownRegistrationFacts: { + mykoboEmail: "verified@example.com", + mykoboReceivablesAddress: RECEIVABLES, + mykoboTransactionId: "withdraw-1", + mykoboTransactionReference: "EUR-WITHDRAW-1" + }, + quote: {} as never + }; + const prepared = await prepareMykoboOfframpPayoutTxs(context); + expect(prepared.intents.map(intent => intent.phase)).toEqual([ + "mykoboPayoutOnBase", + "baseCleanupUsdc", + "baseCleanupEurc", + "baseCleanupAxlUsdc" + ]); + expect(prepared.intents.map(intent => intent.lane)).toEqual(["main", "cleanup", "cleanup", "cleanup"]); + expect(prepared.intents.every(intent => intent.network === Networks.Base && intent.signer === EPHEMERAL)).toBe(true); + expect(prepared.state).toEqual(context.ownRegistrationFacts); + + const payout = prepared.intents[0].txData as { data: `0x${string}`; to: string }; + const decoded = decodeFunctionData({ abi: erc20Abi, data: payout.data }); + expect(decoded.functionName).toBe("transfer"); + expect(decoded.args).toEqual([RECEIVABLES, 98_980_000n]); + expect(payout.to.toLowerCase()).not.toBe(RECEIVABLES.toLowerCase()); + } finally { + manager.getClient = originalGetClient; + } + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.flow.test.ts new file mode 100644 index 000000000..b3d327feb --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.flow.test.ts @@ -0,0 +1,204 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { + EPaymentMethod, + EvmToken, + FiatToken, + MykoboApiService, + Networks, + RampDirection, + RampPhase +} from "@vortexfi/shared"; +import Big from "big.js"; +import * as nablaNamespace from "../core/nabla"; +import * as squidrouterNamespace from "../core/squidrouter"; +import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; +import * as priceFeedNamespace from "../../../priceFeed.service"; +import * as feesNamespace from "../core/fees"; + +const feesReal = { ...feesNamespace }; +const nablaReal = { ...nablaNamespace }; +const partnerPricingReal = { ...partnerPricingNamespace }; +const priceFeedReal = { ...priceFeedNamespace }; +const squidrouterReal = { ...squidrouterNamespace }; + +const EXPECTED_FEES = { + displayFiat: { anchor: "0.06", currency: FiatToken.EURC, network: "0.1", partnerMarkup: "0", total: "0.26", vortex: "0.1" }, + usd: { anchor: "0.06", network: "0.1", partnerMarkup: "0", total: "0.26", vortex: "0.1" } +}; +let feeOverride: unknown; + +mock.module("../core/fees", () => ({ + calculateFees: async (_ctx: unknown, override: unknown) => { + feeOverride = override; + return EXPECTED_FEES; + }, + computeFees: async (ctx: { fees?: unknown }) => { + ctx.fees ??= EXPECTED_FEES; + } +})); + +mock.module("../core/nabla", () => ({ + calculateNablaSwapOutputEvm: async () => ({ + effectiveExchangeRate: "1.08", + nablaOutputAmountDecimal: new Big("107.9352"), + nablaOutputAmountRaw: "107935200" + }) +})); + +mock.module("../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: async () => ({ + finalEffectiveExchangeRate: "0.99", + finalGrossOutputAmountDecimal: new Big("107.5"), + networkFeeUSD: "0.1", + outputTokenDecimals: 6 + }), + getBridgeTargetTokenDetails: () => ({ + erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" + }) +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { getFiatToUsdExchangeRate: async () => new Big("1.08") } +})); + +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async () => null +})); + +afterAll(() => { + mock.module("../core/fees", () => ({ ...feesReal })); + mock.module("../core/nabla", () => ({ ...nablaReal })); + mock.module("../core/squidrouter", () => ({ ...squidrouterReal })); + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); + mock.module("../../../priceFeed.service", () => ({ ...priceFeedReal })); +}); + +const EUR_ONRAMP_BASE_CROSS_CHAIN: RampPhase[] = [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { getBlockMetadata } from "../core/metadata"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { PhaseCtx } from "../core/types"; +import { + eurOnrampBaseCrossChainFlow, + eurOnrampBaseCrossChainPhaseFlow, + makeEurOnrampBaseCrossChainFlow +} from "../flows/eur-onramp-base-cross-chain"; +import { DestinationTransferContext } from "../phases/destination-transfer/simulation"; +import { MykoboMint } from "../phases/mykobo-mint"; +import { MykoboMintContext } from "../phases/mykobo-mint/simulation"; +import { NablaSwap } from "../phases/nabla-swap"; +import { NablaSwapContext } from "../phases/nabla-swap/simulation"; +import { SquidRouterSwapContext } from "../phases/squid-router-swap/simulation"; + +const CORE_PHASES: RampPhase[] = [ + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer" +]; + +function buildCtx(): PhaseCtx { + return { + addNote: note => void note, + fees: EXPECTED_FEES, + notes: [], + now: new Date(), + partner: null, + request: { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + } + }; +} + +async function runFlow() { + MykoboApiService.getInstance = mock(() => ({ + defaultDepositFee: mock(async () => ({ total: "0.06" })) + })) as unknown as typeof MykoboApiService.getInstance; + return eurOnrampBaseCrossChainFlow.simulate(buildCtx()); +} + +describe("EUR cross-chain onramp flow", () => { + it("defines the phase flow for every non-Base EVM destination", () => { + expect(eurOnrampBaseCrossChainFlow.phases).toEqual(CORE_PHASES); + expect(eurOnrampBaseCrossChainPhaseFlow).toEqual(EUR_ONRAMP_BASE_CROSS_CHAIN); + expect(assemblePhaseFlow(makeEurOnrampBaseCrossChainFlow(Networks.Polygon, EvmToken.USDT))).toEqual( + EUR_ONRAMP_BASE_CROSS_CHAIN + ); + }); + + it("provides exactly one executor per phase", () => { + expect(eurOnrampBaseCrossChainFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + }); + + it.skip("rejects typed adjacency mismatches", () => { + // @ts-expect-error Mykobo emits Base EURC, not Base BRLA + const wrongToken = FlowBuilder.start(fiatRequestIO(FiatToken.EURC), MykoboMint).pipe(NablaSwap(Networks.Base, EvmToken.BRLA, EvmToken.USDC)); + void wrongToken; + }); + + it("simulates to the requested destination with provider fees", async () => { + const { output } = await runFlow(); + expect(output.amount.toFixed()).toBe("107.5"); + expect(output.token).toBe(EvmToken.USDC); + expect(output.chain).toBe(Networks.Arbitrum); + expect(feeOverride).toEqual({ + anchor: { amount: "0.06", currency: FiatToken.EURC }, + network: { amount: "0.1", currency: EvmToken.USDC } + }); + }); + + it("owns one metadata entry per phase and preserves Mykobo mint amounts", async () => { + const { metadata } = await runFlow(); + expect(Object.keys(metadata.blocks)).toEqual([ + "mykoboMint", + "fundEphemeral", + "subsidizePreSwap", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "finalSettlementSubsidy", + "destinationTransfer" + ]); + const mykoboMint = getBlockMetadata(metadata, MykoboMintContext); + expect(mykoboMint.mint).toMatchObject({ + currency: FiatToken.EURC, + inputAmountRaw: "100000000", + outputAmountRaw: "99940000" + }); + expect(Big(mykoboMint.mint.fee).toFixed()).toBe("0.06"); + const nablaSwap = getBlockMetadata(metadata, NablaSwapContext); + expect(nablaSwap.inputCurrency).toBe(EvmToken.EURC); + expect(nablaSwap.outputCurrency).toBe(EvmToken.USDC); + expect(getBlockMetadata(metadata, SquidRouterSwapContext).toNetwork).toBe(Networks.Arbitrum); + expect(getBlockMetadata(metadata, DestinationTransferContext).amountRaw).toBe("107500000"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.registration.test.ts new file mode 100644 index 000000000..aafdf4894 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.registration.test.ts @@ -0,0 +1,61 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { EphemeralAccountType, MykoboCurrency, MykoboTransactionType } from "@vortexfi/shared"; +import * as customerNamespace from "../../../mykobo/mykobo-customer.service"; + +const sharedReal = { ...sharedNamespace }; +const customerReal = { ...customerNamespace }; +const createTransactionIntent = mock(async () => ({ + instructions: { bank_account_name: "Mykobo Europe", iban: "DE89370400440532013000" }, + transaction: { id: "intent-1", reference: "EUR-REF-1" } +})); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + MykoboApiService: { getInstance: () => ({ createTransactionIntent }) } +})); +mock.module("../../../mykobo/mykobo-customer.service", () => ({ + resolveMykoboCustomerForUser: async () => ({ email: "verified@example.com" }) +})); + +const { registerMykoboMint } = await import("../phases/mykobo-mint/registration"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../../mykobo/mykobo-customer.service", () => ({ ...customerReal })); +}); + +describe("MykoboMint registration", () => { + it("derives the authenticated customer, creates the deposit intent, and returns owned facts and IBAN artifacts", async () => { + const result = await registerMykoboMint({ + authenticatedUser: { id: "user-1" }, + input: { email: "verified@example.com" }, + ipAddress: "203.0.113.4", + metadata: {} as never, + quote: { inputAmount: "100.129" } as never, + signingAccounts: [{ address: "0x1212121212121212121212121212121212121212", type: EphemeralAccountType.EVM }] + }); + + expect(createTransactionIntent).toHaveBeenCalledWith({ + currency: MykoboCurrency.EURC, + email_address: "verified@example.com", + ip_address: "203.0.113.4", + transaction_type: MykoboTransactionType.DEPOSIT, + value: "100.12", + wallet_address: "0x1212121212121212121212121212121212121212" + }); + expect(result.facts).toEqual({ + mykoboEmail: "verified@example.com", + mykoboTransactionId: "intent-1", + mykoboTransactionReference: "EUR-REF-1" + }); + expect(result.responseArtifacts).toEqual({ + ibanPaymentData: { + bic: "", + iban: "DE89370400440532013000", + receiverName: "Mykobo Europe", + reference: "EUR-REF-1" + } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.transactions.test.ts new file mode 100644 index 000000000..d2b4cef3f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.transactions.test.ts @@ -0,0 +1,240 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { + EphemeralAccountType, + EPaymentMethod, + EvmToken, + FiatToken, + Networks, + RampDirection, + signUnsignedTransactions +} from "@vortexfi/shared"; +import Big from "big.js"; +import { privateKeyToAccount } from "viem/accounts"; +import * as evmFundingNamespace from "../core/evm-funding"; +import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; + +const sharedReal = { ...sharedNamespace }; +const evmFundingReal = { ...evmFundingNamespace }; +const partnerPricingReal = { ...partnerPricingNamespace }; +const PRIVATE_KEY = "0x3434343434343434343434343434343434343434343434343434343434343434"; +const EPHEMERAL = privateKeyToAccount(PRIVATE_KEY).address; +const DESTINATION = "0x1212121212121212121212121212121212121212"; +const FUNDING = "0x9999999999999999999999999999999999999999"; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + createNablaTransactionsForOnrampOnEVM: async () => ({ + approve: { data: "0xc1", gas: "100000", to: "0x3333333333333333333333333333333333333333", value: "0" }, + swap: { data: "0xc2", gas: "500000", to: "0x3333333333333333333333333333333333333333", value: "0" } + }), + createOnrampSquidrouterTransactionsFromBaseToEvm: async () => ({ + approveData: { data: "0xa1", gas: "100000", to: "0x1111111111111111111111111111111111111111", value: "0" }, + squidRouterQuoteId: "squid-quote-id", + squidRouterReceiverHash: "0xreceiverhash", + squidRouterReceiverId: "receiver-id", + swapData: { data: "0xa2", gas: "500000", to: "0x1111111111111111111111111111111111111111", value: "123" } + }), + createOnrampSquidrouterTransactionsOnDestinationChain: async () => ({ + approveData: { data: "0xb1", gas: "100000", to: "0x2222222222222222222222222222222222222222", value: "0" }, + swapData: { data: "0xb2", gas: "500000", to: "0x2222222222222222222222222222222222222222", value: "0" } + }), + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ estimateFeesPerGas: async () => ({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000n }) }) + }) + }, + getNablaBasePool: () => ({ router: "0x4444444444444444444444444444444444444444" }) +})); +mock.module("../core/evm-funding", () => ({ getEvmFundingAccount: () => ({ address: FUNDING }) })); +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async () => ({ payoutAddressEvm: "0x8888888888888888888888888888888888888888" }) +})); + +const { makeEurOnrampBaseCrossChainFlow } = await import("../flows/eur-onramp-base-cross-chain"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/evm-funding", () => ({ ...evmFundingReal })); + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); +}); + +const REQUEST = { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum +}; + +function quote(): QuoteTicketAttributes { + return { + from: EPaymentMethod.SEPA, + id: "quote-eur", + inputAmount: "100", + inputCurrency: FiatToken.EURC, + metadata: { + evmToEvm: { inputAmountRaw: "107000000" }, + fees: { usd: { anchor: "0.06", network: "0.1", partnerMarkup: "0", total: "0.26", vortex: "0.1" } }, + mykoboMint: { outputAmountRaw: "99940000" }, + nablaSwapEvm: { inputAmountForSwapRaw: "99940000", outputAmountRaw: "108000000" } + }, + network: Networks.Arbitrum, + outputAmount: "106.5", + outputCurrency: EvmToken.USDC, + partnerId: null, + pricingPartnerId: null, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + } as unknown as QuoteTicketAttributes; +} + +function metadata() { + const subsidy = { + actualOutputAmountDecimal: new Big("106.5"), + actualOutputAmountRaw: "106500000", + adjustedDifference: new Big(0), + adjustedTargetDiscount: new Big(0), + applied: false, + expectedOutputAmountDecimal: new Big("106.5"), + expectedOutputAmountRaw: "106500000", + idealSubsidyAmountInOutputTokenDecimal: new Big(0), + idealSubsidyAmountInOutputTokenRaw: "0", + partnerId: null, + subsidyAmountInOutputTokenDecimal: new Big(0), + subsidyAmountInOutputTokenRaw: "0", + subsidyRate: new Big(0), + targetOutputAmountDecimal: new Big("106.5"), + targetOutputAmountRaw: "106500000" + }; + return { + blocks: { + destinationTransfer: { amountDecimal: new Big("106.5"), amountRaw: "106500000", network: Networks.Arbitrum, token: EvmToken.USDC }, + distributeFees: { anchorFeeUsd: "0.06", networkFeeUsd: "0.1", partnerMarkupUsd: "0", totalFeesUsd: "0.2", vortexFeeUsd: "0.1" }, + finalSettlementSubsidy: { ...subsidy, amountRaw: "106500000", network: Networks.Arbitrum, token: EvmToken.USDC }, + fundEphemeral: { network: Networks.Base, token: EvmToken.EURC }, + mykoboMint: { + mint: { + currency: FiatToken.EURC, + fee: new Big("0.06"), + inputAmountDecimal: new Big(100), + inputAmountRaw: "100000000", + outputAmountDecimal: new Big("99.94"), + outputAmountRaw: "99940000" + } + }, + nablaSwap: { + inputAmountForSwapDecimal: "99.94", + inputAmountForSwapRaw: "99940000", + inputCurrency: EvmToken.EURC, + inputDecimals: 6, + inputToken: "0x1111111111111111111111111111111111111111", + outputAmountDecimal: new Big(108), + outputAmountRaw: "108000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + outputToken: "0x2222222222222222222222222222222222222222" + }, + squidRouterSwap: { + fromNetwork: Networks.Base, + fromToken: "0x2222222222222222222222222222222222222222", + inputAmountDecimal: new Big(107), + inputAmountRaw: "107000000", + networkFeeUSD: "0.1", + outputAmountDecimal: new Big("106.5"), + outputAmountRaw: "106500000", + toNetwork: Networks.Arbitrum, + toToken: "0x3333333333333333333333333333333333333333" + }, + subsidizePostSwap: { ...subsidy, outputCurrency: EvmToken.USDC, outputDecimals: 6 }, + subsidizePreSwap: { + expectedOutputAmountDecimal: new Big(108), + expectedOutputAmountRaw: "108000000", + inputCurrency: EvmToken.EURC, + inputDecimals: 6, + network: Networks.Base, + targetInputAmountRaw: "99940000" + } + }, + globals: { + fees: { usd: { anchor: "0.06", network: "0.1", partnerMarkup: "0", total: "0.26", vortex: "0.1" } }, + partner: null, + request: REQUEST + } + } as never; +} + +describe("EUR onramp Base cross-chain transactions", () => { + it("preserves nonce lanes, cleanup, recovery, signing, and owned state", async () => { + const flow = makeEurOnrampBaseCrossChainFlow(Networks.Arbitrum, EvmToken.USDC); + const prepared = await flow.prepareTxs({ + accounts: { [EphemeralAccountType.EVM]: { address: EPHEMERAL, type: EphemeralAccountType.EVM } }, + destinationAddress: DESTINATION, + metadata: metadata(), + quote: quote(), + registrationFacts: { + mykoboMint: { + mykoboEmail: "user@example.com", + mykoboTransactionId: "intent-1", + mykoboTransactionReference: "EUR-REF-1" + } + } + }); + + const evmEphemeral = { address: EPHEMERAL, secret: PRIVATE_KEY, type: EphemeralAccountType.EVM }; + const signed = await signUnsignedTransactions(prepared.unsignedTxs, { evmEphemeral }); + expect(signed.length).toBeGreaterThanOrEqual(prepared.unsignedTxs.length); + expect(prepared.stateMeta.phaseFlow).toEqual([ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" + ]); + expect(prepared.stateMeta.blockState).toEqual({ + mykoboMint: { + mykoboEmail: "user@example.com", + mykoboTransactionId: "intent-1", + mykoboTransactionReference: "EUR-REF-1" + }, + nablaSwap: { softMinimumOutputRaw: expect.any(String) }, + squidRouterSwap: { + quoteId: "squid-quote-id", + receiverHash: "0xreceiverhash", + receiverId: "receiver-id" + } + }); + expect(prepared.unsignedTxs.map(tx => [tx.phase, tx.network, tx.nonce])).toEqual( + expect.arrayContaining([ + ["nablaApprove", Networks.Base, 0], + ["nablaSwap", Networks.Base, 1], + ["distributeFees", Networks.Base, 2], + ["squidRouterApprove", Networks.Base, 3], + ["squidRouterSwap", Networks.Base, 4], + ["baseCleanupEurc", Networks.Base, 5], + ["baseCleanupUsdc", Networks.Base, 6], + ["destinationTransfer", Networks.Arbitrum, 0], + ["backupSquidRouterApprove", Networks.Arbitrum, 1], + ["backupSquidRouterSwap", Networks.Arbitrum, 2], + ["backupApprove", Networks.Arbitrum, 0] + ]) + ); + expect(prepared.stateMeta.transactionPlan).toEqual({ + nativePrefunding: { [`${Networks.Base}:${EPHEMERAL.toLowerCase()}`]: "123" } + }); + expect(prepared.unsignedTxs.find(tx => tx.phase === "nablaApprove")?.txData).toMatchObject({ data: "0xc1" }); + expect(prepared.unsignedTxs.find(tx => tx.phase === "squidRouterSwap")?.txData).toMatchObject({ data: "0xa2" }); + expect(prepared.unsignedTxs.find(tx => tx.phase === "backupSquidRouterSwap")?.txData).toMatchObject({ data: "0xb2" }); + }, 60_000); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-direct.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-direct.flow.test.ts new file mode 100644 index 000000000..1bb70e130 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-direct.flow.test.ts @@ -0,0 +1,109 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { + EPaymentMethod, + EvmToken, + evmTokenConfig, + FiatToken, + MykoboApiService, + Networks, + RampDirection, + RampPhase +} from "@vortexfi/shared"; +import * as squidrouterNamespace from "../core/squidrouter"; +import * as feesNamespace from "../core/fees"; + +const feesReal = { ...feesNamespace }; +const squidrouterReal = { ...squidrouterNamespace }; + +const EXPECTED_FEES = { + displayFiat: { anchor: "0.06", currency: FiatToken.EURC, network: "0", partnerMarkup: "0", total: "0.16", vortex: "0.1" }, + usd: { anchor: "0.06", network: "0", partnerMarkup: "0", total: "0.16", vortex: "0.1" } +}; +const calculateBridgeFee = mock(async () => ({ networkFeeUSD: "99" })); +let feeOverride: unknown; + +mock.module("../core/fees", () => ({ + calculateFees: async (_ctx: unknown, override: unknown) => { + feeOverride = override; + return EXPECTED_FEES; + }, + computeFees: async (ctx: { fees?: unknown }) => { + ctx.fees ??= EXPECTED_FEES; + } +})); +mock.module("../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: calculateBridgeFee, + getBridgeTargetTokenDetails: () => evmTokenConfig[Networks.Base][EvmToken.EURC] +})); + +afterAll(() => { + mock.module("../core/fees", () => ({ ...feesReal })); + mock.module("../core/squidrouter", () => ({ ...squidrouterReal })); +}); + +const EUR_ONRAMP_BASE_DIRECT: RampPhase[] = [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "destinationTransfer", + "complete" +]; +import { getBlockMetadata } from "../core/metadata"; +import type { PhaseCtx } from "../core/types"; +import { resolveBlockFlow } from "../flows/catalog"; +import { DestinationTransferContext } from "../phases/destination-transfer/simulation"; +import { eurOnrampBaseDirectFlow, eurOnrampBaseDirectPhaseFlow } from "../flows/eur-onramp-base-direct"; + +const CORE_PHASES: RampPhase[] = ["mykoboOnrampDeposit", "fundEphemeral", "destinationTransfer"]; +const REQUEST = { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency: EvmToken.EURC, + rampType: RampDirection.BUY, + to: Networks.Base +}; + +function buildCtx(): PhaseCtx { + return { + addNote: () => undefined, + fees: EXPECTED_FEES, + notes: [], + now: new Date(), + partner: null, + request: REQUEST + }; +} + +describe("EUR direct Base onramp flow", () => { + it("defines the phase flow and exact executor sequence", () => { + expect(eurOnrampBaseDirectFlow.phases).toEqual(CORE_PHASES); + expect(eurOnrampBaseDirectPhaseFlow).toEqual(EUR_ONRAMP_BASE_DIRECT); + expect(eurOnrampBaseDirectFlow.executors.map(executor => executor.getPhaseName())).toEqual(CORE_PHASES); + }); + + it("simulates the provider-delivered EURC with zero direct network fee", async () => { + MykoboApiService.getInstance = mock(() => ({ + defaultDepositFee: mock(async () => ({ total: "0.06" })) + })) as unknown as typeof MykoboApiService.getInstance; + + const result = await eurOnrampBaseDirectFlow.simulate(buildCtx()); + + expect(result.output).toMatchObject({ amountRaw: "99940000", chain: Networks.Base, token: EvmToken.EURC }); + expect(feeOverride).toEqual({ + anchor: { amount: "0.06", currency: FiatToken.EURC }, + network: { amount: "0", currency: EvmToken.USDC } + }); + expect(calculateBridgeFee).not.toHaveBeenCalled(); + expect(Object.keys(result.metadata.blocks)).toEqual(["mykoboMint", "fundEphemeral", "destinationTransfer"]); + expect(getBlockMetadata(result.metadata, DestinationTransferContext).amountRaw).toBe("99940000"); + }); + + it("uses the exact SEPA EUR to Base EURC catalog predicate", () => { + expect(resolveBlockFlow(REQUEST).name).toBe("EurOnrampBaseDirect"); + expect(() => resolveBlockFlow({ ...REQUEST, from: EPaymentMethod.PIX })).toThrow(); + expect(resolveBlockFlow({ ...REQUEST, outputCurrency: EvmToken.USDC }).name).not.toBe("EurOnrampBaseDirect"); + expect(() => resolveBlockFlow({ ...REQUEST, rampType: RampDirection.SELL })).toThrow(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-direct.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-direct.registration.test.ts new file mode 100644 index 000000000..27c66a9b1 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-direct.registration.test.ts @@ -0,0 +1,84 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { EphemeralAccountType, EPaymentMethod, EvmToken, FiatToken, MykoboCurrency, MykoboTransactionType, Networks, RampDirection } from "@vortexfi/shared"; +import * as customerNamespace from "../../../mykobo/mykobo-customer.service"; + +const sharedReal = { ...sharedNamespace }; +const customerReal = { ...customerNamespace }; +const createTransactionIntent = mock(async () => ({ + instructions: { bank_account_name: "Mykobo Europe", iban: "DE89370400440532013000" }, + transaction: { id: "intent-direct", reference: "EUR-DIRECT-1" } +})); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + MykoboApiService: { getInstance: () => ({ createTransactionIntent }) } +})); +mock.module("../../../mykobo/mykobo-customer.service", () => ({ + resolveMykoboCustomerForUser: async () => ({ email: "verified@example.com" }) +})); + +const { eurOnrampBaseDirectFlow } = await import("../flows/eur-onramp-base-direct"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../../mykobo/mykobo-customer.service", () => ({ ...customerReal })); +}); + +describe("EUR_ONRAMP_BASE_DIRECT registration", () => { + it("registers through MykoboMint and returns phase-owned facts and payment artifacts", async () => { + const registered = await eurOnrampBaseDirectFlow.register({ + authenticatedUser: { id: "user-1" }, + input: { email: "verified@example.com" }, + ipAddress: "203.0.113.4", + metadata: { + blocks: { + destinationTransfer: {} as never, + fundEphemeral: {} as never, + mykoboMint: {} as never + }, + globals: { + fees: { usd: { anchor: "0.06", network: "0", partnerMarkup: "0", total: "0.16", vortex: "0.1" } }, + partner: null, + request: { + from: EPaymentMethod.SEPA, + inputAmount: "100.129", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency: EvmToken.EURC, + rampType: RampDirection.BUY, + to: Networks.Base + } + } + }, + quote: { inputAmount: "100.129" } as never, + signingAccounts: [{ address: "0x1212121212121212121212121212121212121212", type: EphemeralAccountType.EVM }] + }); + + expect(createTransactionIntent).toHaveBeenCalledWith({ + currency: MykoboCurrency.EURC, + email_address: "verified@example.com", + ip_address: "203.0.113.4", + transaction_type: MykoboTransactionType.DEPOSIT, + value: "100.12", + wallet_address: "0x1212121212121212121212121212121212121212" + }); + expect(registered.registrationFacts).toEqual({ + mykoboMint: { + mykoboEmail: "verified@example.com", + mykoboTransactionId: "intent-direct", + mykoboTransactionReference: "EUR-DIRECT-1" + } + }); + expect(registered.responseArtifacts).toEqual({ + mykoboMint: { + ibanPaymentData: { + bic: "", + iban: "DE89370400440532013000", + receiverName: "Mykobo Europe", + reference: "EUR-DIRECT-1" + } + } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-direct.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-direct.transactions.test.ts new file mode 100644 index 000000000..f1d9141b3 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-direct.transactions.test.ts @@ -0,0 +1,108 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { EphemeralAccountType, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; + +const sharedReal = { ...sharedNamespace }; +const EPHEMERAL = "0x3434343434343434343434343434343434343434"; +const DESTINATION = "0x1212121212121212121212121212121212121212"; +const REQUEST = { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency: EvmToken.EURC, + rampType: RampDirection.BUY, + to: Networks.Base +}; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ estimateFeesPerGas: async () => ({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000n }) }) + }) + } +})); + +const { eurOnrampBaseDirectFlow } = await import("../flows/eur-onramp-base-direct"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); +}); + +function quote(): QuoteTicketAttributes { + return { + ...REQUEST, + id: "quote-eur-direct", + metadata: { mykoboMint: { outputAmountRaw: "99940000" } }, + outputAmount: "99.94", + partnerId: null, + pricingPartnerId: null + } as unknown as QuoteTicketAttributes; +} + +describe("EUR onramp Base direct transactions", () => { + it("prepares the nonce-zero transfer with no cleanup and preserves owned state", async () => { + const prepared = await eurOnrampBaseDirectFlow.prepareTxs({ + accounts: { [EphemeralAccountType.EVM]: { address: EPHEMERAL, type: EphemeralAccountType.EVM } }, + destinationAddress: DESTINATION, + metadata: { + blocks: { + destinationTransfer: { + amountDecimal: new Big("99.94"), + amountRaw: "99940000", + network: Networks.Base, + token: EvmToken.EURC + }, + fundEphemeral: { network: Networks.Base, token: EvmToken.EURC }, + mykoboMint: { + mint: { + currency: FiatToken.EURC, + fee: new Big("0.06"), + inputAmountDecimal: new Big(100), + inputAmountRaw: "100000000", + outputAmountDecimal: new Big("99.94"), + outputAmountRaw: "99940000" + } + } + }, + globals: { + fees: { usd: { anchor: "0.06", network: "0", partnerMarkup: "0", total: "0.16", vortex: "0.1" } }, + partner: null, + request: REQUEST + } + }, + quote: quote(), + registrationFacts: { + mykoboMint: { + mykoboEmail: "user@example.com", + mykoboTransactionId: "intent-direct", + mykoboTransactionReference: "EUR-DIRECT-1" + } + } + }); + + expect(prepared.unsignedTxs.map(tx => [tx.phase, tx.network, tx.signer, tx.nonce])).toEqual([ + ["destinationTransfer", Networks.Base, EPHEMERAL, 0] + ]); + expect(prepared.unsignedTxs[0].txData).toMatchObject({ gas: "100000", value: "0" }); + expect(prepared.stateMeta).toEqual({ + accountAddresses: { EVM: EPHEMERAL }, + blockState: { + mykoboMint: { + mykoboEmail: "user@example.com", + mykoboTransactionId: "intent-direct", + mykoboTransactionReference: "EUR-DIRECT-1" + } + }, + destinationAddress: DESTINATION, + evmEphemeralAddress: EPHEMERAL, + flow: eurOnrampBaseDirectFlow.identity, + isDirectTransfer: true, + phaseFlow: ["initial", "mykoboOnrampDeposit", "fundEphemeral", "destinationTransfer", "complete"], + transactionPlan: { nativePrefunding: {} } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.flow.test.ts new file mode 100644 index 000000000..d7f2807fa --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.flow.test.ts @@ -0,0 +1,174 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { EPaymentMethod, EvmToken, evmTokenConfig, FiatToken, MykoboApiService, Networks, RampDirection, type RampPhase } from "@vortexfi/shared"; +import Big from "big.js"; +import * as nablaNamespace from "../core/nabla"; +import * as squidrouterNamespace from "../core/squidrouter"; +import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; +import * as priceFeedNamespace from "../../../priceFeed.service"; +import * as feesNamespace from "../core/fees"; + +const feesReal = { ...feesNamespace }; +const nablaReal = { ...nablaNamespace }; +const partnerPricingReal = { ...partnerPricingNamespace }; +const priceFeedReal = { ...priceFeedNamespace }; +const squidrouterReal = { ...squidrouterNamespace }; + +const EXPECTED_FEES = { + displayFiat: { anchor: "0.06", currency: FiatToken.EURC, network: "0.1", partnerMarkup: "0", total: "0.26", vortex: "0.1" }, + usd: { anchor: "0.06", network: "0.1", partnerMarkup: "0", total: "0.26", vortex: "0.1" } +}; +let feeOverride: unknown; + +mock.module("../core/fees", () => ({ + calculateFees: async (_ctx: unknown, override: unknown) => { + feeOverride = override; + return EXPECTED_FEES; + }, + computeFees: async (ctx: { fees?: unknown }) => { + ctx.fees ??= EXPECTED_FEES; + } +})); + +mock.module("../core/nabla", () => ({ + calculateNablaSwapOutputEvm: async () => ({ + effectiveExchangeRate: "1.08", + nablaOutputAmountDecimal: new Big("107.9352"), + nablaOutputAmountRaw: "107935200" + }) +})); + +mock.module("../core/squidrouter", () => ({ + calculateEvmBridgeAndNetworkFee: async ({ toToken }: { toToken: string }) => { + const token = Object.values(evmTokenConfig[Networks.Base]).find( + candidate => candidate?.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase() + ); + return { + finalEffectiveExchangeRate: "0.99", + finalGrossOutputAmountDecimal: new Big("107.5"), + networkFeeUSD: "0.1", + outputTokenDecimals: token?.decimals ?? 6 + }; + }, + getBridgeTargetTokenDetails: (token: EvmToken) => evmTokenConfig[Networks.Base][token] +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { getFiatToUsdExchangeRate: async () => new Big("1.08") } +})); + +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async () => null +})); + +afterAll(() => { + mock.module("../core/fees", () => ({ ...feesReal })); + mock.module("../core/nabla", () => ({ ...nablaReal })); + mock.module("../core/squidrouter", () => ({ ...squidrouterReal })); + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); + mock.module("../../../priceFeed.service", () => ({ ...priceFeedReal })); +}); + +import { getBlockMetadata } from "../core/metadata"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { PhaseCtx } from "../core/types"; +import { DestinationTransferContext } from "../phases/destination-transfer/simulation"; +import { MykoboMintContext } from "../phases/mykobo-mint/simulation"; +import { + eurOnrampBaseSameChainFlow, + eurOnrampBaseSameChainPhaseFlow, + eurOnrampBaseSameChainSwapPhaseFlow, + makeEurOnrampBaseSameChainSwapFlow +} from "../flows/eur-onramp-base-same-chain"; + +const ROUTED_BASE_OUTPUTS = [EvmToken.USDT, EvmToken.ETH, EvmToken.AXLUSDC, EvmToken.BRLA] as const; +const COMMON_PHASES: RampPhase[] = [ + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap" +]; +const EUR_ONRAMP_BASE_SAME_CHAIN: RampPhase[] = ["initial", ...COMMON_PHASES, "destinationTransfer", "complete"]; +const EUR_ONRAMP_BASE_SAME_CHAIN_SWAP: RampPhase[] = [ + "initial", + ...COMMON_PHASES, + "squidRouterSwap", + "destinationTransfer", + "complete" +]; + +function buildCtx(outputCurrency: EvmToken): PhaseCtx { + return { + addNote() {}, + fees: EXPECTED_FEES, + notes: [], + now: new Date(), + partner: null, + request: { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency, + rampType: RampDirection.BUY, + to: Networks.Base + } + }; +} + +async function simulate(outputCurrency: EvmToken) { + MykoboApiService.getInstance = mock(() => ({ + defaultDepositFee: mock(async () => ({ total: "0.06" })) + })) as unknown as typeof MykoboApiService.getInstance; + const flow = + outputCurrency === EvmToken.USDC + ? eurOnrampBaseSameChainFlow + : makeEurOnrampBaseSameChainSwapFlow(outputCurrency); + return flow.simulate(buildCtx(outputCurrency)); +} + +describe("EUR Base same-chain block flows", () => { + it("keeps Base USDC on EUR_ONRAMP_BASE_SAME_CHAIN without Squid", () => { + expect(eurOnrampBaseSameChainFlow.phases).toEqual([...COMMON_PHASES, "destinationTransfer"]); + expect(eurOnrampBaseSameChainPhaseFlow).toEqual(EUR_ONRAMP_BASE_SAME_CHAIN); + expect(eurOnrampBaseSameChainFlow.executors.map(executor => executor.getPhaseName())).toEqual([ + ...COMMON_PHASES, + "destinationTransfer" + ]); + }); + + it("uses one same-chain Squid phase for every other supported Base output", () => { + expect(eurOnrampBaseSameChainSwapPhaseFlow).toEqual(EUR_ONRAMP_BASE_SAME_CHAIN_SWAP); + for (const outputCurrency of ROUTED_BASE_OUTPUTS) { + const flow = makeEurOnrampBaseSameChainSwapFlow(outputCurrency); + expect(assemblePhaseFlow(flow)).toEqual(EUR_ONRAMP_BASE_SAME_CHAIN_SWAP); + expect(flow.executors.map(executor => executor.getPhaseName())).toEqual([ + ...COMMON_PHASES, + "squidRouterSwap", + "destinationTransfer" + ]); + expect(flow.phases).not.toContain("squidRouterPay"); + expect(flow.phases).not.toContain("finalSettlementSubsidy"); + } + }); + + for (const outputCurrency of [EvmToken.USDC, ...ROUTED_BASE_OUTPUTS]) { + it(`simulates EUR to Base ${outputCurrency} with provider fees and phase metadata`, async () => { + const { metadata, output } = await simulate(outputCurrency); + expect(output.chain).toBe(Networks.Base); + expect(output.token).toBe(outputCurrency); + expect(output.amount.gt(0)).toBe(true); + expect(Object.hasOwn(metadata.blocks, "squidRouterSwap")).toBe(outputCurrency !== EvmToken.USDC); + expect(getBlockMetadata(metadata, MykoboMintContext).mint.outputAmountRaw).toBe("99940000"); + const destinationTransfer = getBlockMetadata(metadata, DestinationTransferContext); + expect(destinationTransfer.network).toBe(Networks.Base); + expect(destinationTransfer.token).toBe(outputCurrency); + expect(feeOverride).toEqual({ + anchor: { amount: "0.06", currency: FiatToken.EURC }, + network: { amount: "0.1", currency: EvmToken.USDC } + }); + }); + } +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.registration.test.ts new file mode 100644 index 000000000..dd3d67c29 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.registration.test.ts @@ -0,0 +1,96 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { + EphemeralAccountType, + EPaymentMethod, + EvmToken, + FiatToken, + MykoboCurrency, + MykoboTransactionType, + Networks, + RampDirection +} from "@vortexfi/shared"; +import * as customerNamespace from "../../../mykobo/mykobo-customer.service"; + +const sharedReal = { ...sharedNamespace }; +const customerReal = { ...customerNamespace }; +const createTransactionIntent = mock(async () => ({ + instructions: { bank_account_name: "Mykobo Europe", iban: "DE89370400440532013000" }, + transaction: { id: "intent-base", reference: "EUR-BASE-1" } +})); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + MykoboApiService: { getInstance: () => ({ createTransactionIntent }) } +})); +mock.module("../../../mykobo/mykobo-customer.service", () => ({ + resolveMykoboCustomerForUser: async () => ({ email: "verified@example.com" }) +})); + +const { eurOnrampBaseSameChainFlow, makeEurOnrampBaseSameChainSwapFlow } = await import( + "../flows/eur-onramp-base-same-chain" +); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../../mykobo/mykobo-customer.service", () => ({ ...customerReal })); +}); + +describe("EUR Base same-chain registration", () => { + for (const outputCurrency of [EvmToken.USDC, EvmToken.USDT, EvmToken.ETH, EvmToken.AXLUSDC, EvmToken.BRLA]) { + it(`preserves Mykobo registration facts and artifacts for Base ${outputCurrency}`, async () => { + createTransactionIntent.mockClear(); + const flow = + outputCurrency === EvmToken.USDC + ? eurOnrampBaseSameChainFlow + : makeEurOnrampBaseSameChainSwapFlow(outputCurrency); + const registered = await flow.register({ + authenticatedUser: { id: "user-1" }, + input: { email: "verified@example.com" }, + ipAddress: "203.0.113.4", + metadata: { + blocks: Object.fromEntries(flow.contextKeys.map(key => [key, {}])), + globals: { + fees: { usd: { anchor: "0.06", network: "0.1", partnerMarkup: "0", total: "0.26", vortex: "0.1" } }, + partner: null, + request: { + from: EPaymentMethod.SEPA, + inputAmount: "100.129", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency, + rampType: RampDirection.BUY, + to: Networks.Base + } + } + } as never, + quote: { inputAmount: "100.129" } as never, + signingAccounts: [{ address: "0x1212121212121212121212121212121212121212", type: EphemeralAccountType.EVM }] + }); + + expect(createTransactionIntent).toHaveBeenCalledWith({ + currency: MykoboCurrency.EURC, + email_address: "verified@example.com", + ip_address: "203.0.113.4", + transaction_type: MykoboTransactionType.DEPOSIT, + value: "100.12", + wallet_address: "0x1212121212121212121212121212121212121212" + }); + expect(registered.registrationFacts).toEqual({ + mykoboMint: { + mykoboEmail: "verified@example.com", + mykoboTransactionId: "intent-base", + mykoboTransactionReference: "EUR-BASE-1" + } + }); + expect(registered.responseArtifacts.mykoboMint).toEqual({ + ibanPaymentData: { + bic: "", + iban: "DE89370400440532013000", + receiverName: "Mykobo Europe", + reference: "EUR-BASE-1" + } + }); + }); + } +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.transactions.test.ts new file mode 100644 index 000000000..9e36eb28e --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.transactions.test.ts @@ -0,0 +1,304 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { + EphemeralAccountType, + EPaymentMethod, + EvmToken, + EvmTokenDetails, + FiatToken, + Networks, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import { privateKeyToAccount } from "viem/accounts"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import * as evmFundingNamespace from "../core/evm-funding"; +import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; +import type { FlowMetadata } from "../core/metadata"; +import type { SubsidyMetadata } from "../phases/subsidize-pre/simulation"; + +const sharedReal = { ...sharedNamespace }; +const evmFundingReal = { ...evmFundingNamespace }; +const partnerPricingReal = { ...partnerPricingNamespace }; +const baseBuilderCalls: EvmToken[] = []; +const EPHEMERAL = privateKeyToAccount("0x3434343434343434343434343434343434343434343434343434343434343434").address; +const DESTINATION = "0x1212121212121212121212121212121212121212"; +const FUNDING = "0x9999999999999999999999999999999999999999"; +const OUTPUTS = [EvmToken.USDC, EvmToken.USDT, EvmToken.ETH, EvmToken.AXLUSDC, EvmToken.BRLA] as const; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + createNablaTransactionsForOnrampOnEVM: async () => ({ + approve: { data: "0xc1", gas: "100000", to: "0x3333333333333333333333333333333333333333", value: "0" }, + swap: { data: "0xc2", gas: "500000", to: "0x3333333333333333333333333333333333333333", value: "0" } + }), + createOnrampSquidrouterTransactionsFromBaseToEvm: async ({ toToken }: { toToken: string }) => { + const token = Object.entries(sharedReal.evmTokenConfig[Networks.Base]).find( + ([, details]) => details?.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase() + )?.[0] as EvmToken; + baseBuilderCalls.push(token); + return { + approveData: { data: "0xa1", gas: "100000", to: "0x1111111111111111111111111111111111111111", value: "0" }, + squidRouterQuoteId: `squid-${token}`, + squidRouterReceiverHash: `hash-${token}`, + squidRouterReceiverId: `receiver-${token}`, + swapData: { data: "0xa2", gas: "500000", to: "0x1111111111111111111111111111111111111111", value: "123" } + }; + }, + createOnrampSquidrouterTransactionsFromPolygonToEvm: async () => { + throw new Error("EUR Base same-chain preparation must not call the Polygon builder"); + }, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ estimateFeesPerGas: async () => ({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000n }) }) + }) + }, + getNablaBasePool: () => ({ router: "0x4444444444444444444444444444444444444444" }) +})); +mock.module("../core/evm-funding", () => ({ getEvmFundingAccount: () => ({ address: FUNDING }) })); +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async () => ({ payoutAddressEvm: "0x8888888888888888888888888888888888888888" }) +})); + +const { eurOnrampBaseSameChainFlow, makeEurOnrampBaseSameChainSwapFlow } = await import( + "../flows/eur-onramp-base-same-chain" +); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/evm-funding", () => ({ ...evmFundingReal })); + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); +}); + +function details(token: EvmToken): EvmTokenDetails { + return sharedReal.evmTokenConfig[Networks.Base][token] as EvmTokenDetails; +} + +function outputAmountRaw(token: EvmToken): string { + return new Big("17.5").mul(new Big(10).pow(details(token).decimals)).toFixed(0, 0); +} + +function request(outputCurrency: EvmToken) { + return { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency, + rampType: RampDirection.BUY, + to: Networks.Base + }; +} + +function quote(outputCurrency: EvmToken): QuoteTicketAttributes { + return { + ...request(outputCurrency), + id: `quote-${outputCurrency}`, + metadata: { + evmToEvm: { inputAmountRaw: "17600000", outputAmountRaw: outputAmountRaw(outputCurrency) }, + fees: { usd: { anchor: "0.06", network: "0.1", partnerMarkup: "0", total: "0.26", vortex: "0.1" } }, + mykoboMint: { outputAmountRaw: "99940000" }, + nablaSwapEvm: { inputAmountForSwapRaw: "99940000", outputAmountRaw: "18000000" } + }, + outputAmount: "17.5", + partnerId: null, + pricingPartnerId: null + } as unknown as QuoteTicketAttributes; +} + +function subsidy(token: EvmToken): SubsidyMetadata { + const amountRaw = outputAmountRaw(token); + return { + actualOutputAmountDecimal: new Big("17.5"), + actualOutputAmountRaw: amountRaw, + adjustedDifference: new Big(0), + adjustedTargetDiscount: new Big(0), + applied: false, + expectedOutputAmountDecimal: new Big("17.5"), + expectedOutputAmountRaw: amountRaw, + idealSubsidyAmountInOutputTokenDecimal: new Big(0), + idealSubsidyAmountInOutputTokenRaw: "0", + partnerId: null, + subsidyAmountInOutputTokenDecimal: new Big(0), + subsidyAmountInOutputTokenRaw: "0", + subsidyRate: new Big(0), + targetOutputAmountDecimal: new Big("17.5"), + targetOutputAmountRaw: amountRaw + }; +} + +function metadata(outputCurrency: EvmToken): FlowMetadata { + const blocks: Record = { + destinationTransfer: { + amountDecimal: new Big("17.5"), + amountRaw: outputAmountRaw(outputCurrency), + network: Networks.Base, + token: outputCurrency + }, + distributeFees: { + anchorFeeUsd: "0.06", + networkFeeUsd: "0.1", + partnerMarkupUsd: "0", + totalFeesUsd: "0.26", + vortexFeeUsd: "0.1" + }, + fundEphemeral: { network: Networks.Base, token: EvmToken.EURC }, + mykoboMint: { + mint: { + currency: FiatToken.EURC, + fee: new Big("0.06"), + inputAmountDecimal: new Big(100), + inputAmountRaw: "100000000", + outputAmountDecimal: new Big("99.94"), + outputAmountRaw: "99940000" + } + }, + nablaSwap: { + effectiveExchangeRate: "1.08", + inputAmountForSwapDecimal: "99.94", + inputAmountForSwapRaw: "99940000", + inputCurrency: EvmToken.EURC, + inputDecimals: 6, + inputToken: details(EvmToken.EURC).erc20AddressSourceChain, + outputAmountDecimal: new Big(18), + outputAmountRaw: "18000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + outputToken: details(EvmToken.USDC).erc20AddressSourceChain + }, + subsidizePostSwap: { ...subsidy(EvmToken.USDC), outputCurrency: EvmToken.USDC, outputDecimals: 6 }, + subsidizePreSwap: { + expectedOutputAmountDecimal: new Big(18), + expectedOutputAmountRaw: "18000000", + inputCurrency: EvmToken.EURC, + inputDecimals: 6, + network: Networks.Base, + targetInputAmountRaw: "99940000" + } + }; + if (outputCurrency !== EvmToken.USDC) { + blocks.squidRouterSwap = { + effectiveExchangeRate: "0.99", + fromNetwork: Networks.Base, + fromToken: details(EvmToken.USDC).erc20AddressSourceChain, + inputAmountDecimal: new Big("17.6"), + inputAmountRaw: "17600000", + networkFeeUSD: "0.1", + outputAmountDecimal: new Big("17.5"), + outputAmountRaw: outputAmountRaw(outputCurrency), + toNetwork: Networks.Base, + toToken: details(outputCurrency).erc20AddressSourceChain + }; + } + return { + blocks, + globals: { + fees: { usd: { anchor: "0.06", network: "0.1", partnerMarkup: "0", total: "0.26", vortex: "0.1" } }, + partner: null, + request: request(outputCurrency) + } + }; +} + +describe("EUR Base same-chain transactions", () => { + for (const outputCurrency of OUTPUTS) { + it(`preserves transactions, state, precision, and nonce order for Base ${outputCurrency}`, async () => { + baseBuilderCalls.length = 0; + const flow = + outputCurrency === EvmToken.USDC + ? eurOnrampBaseSameChainFlow + : makeEurOnrampBaseSameChainSwapFlow(outputCurrency); + const prepared = await flow.prepareTxs({ + accounts: { [EphemeralAccountType.EVM]: { address: EPHEMERAL, type: EphemeralAccountType.EVM } }, + destinationAddress: DESTINATION, + metadata: metadata(outputCurrency) as never, + quote: quote(outputCurrency), + registrationFacts: { + mykoboMint: { + mykoboEmail: "user@example.com", + mykoboTransactionId: "intent-1", + mykoboTransactionReference: "EUR-REF-1" + } + } + }); + + expect(prepared.unsignedTxs.every(tx => tx.network === Networks.Base && tx.signer === EPHEMERAL)).toBe(true); + expect(prepared.stateMeta.phaseFlow).toEqual( + outputCurrency === EvmToken.USDC + ? [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "destinationTransfer", + "complete" + ] + : [ + "initial", + "mykoboOnrampDeposit", + "fundEphemeral", + "subsidizePreSwap", + "nablaApprove", + "nablaSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "destinationTransfer", + "complete" + ] + ); + expect(prepared.stateMeta.blockState).toEqual({ + mykoboMint: { + mykoboEmail: "user@example.com", + mykoboTransactionId: "intent-1", + mykoboTransactionReference: "EUR-REF-1" + }, + nablaSwap: { softMinimumOutputRaw: expect.any(String) }, + ...(outputCurrency === EvmToken.USDC + ? {} + : { + squidRouterSwap: { + quoteId: `squid-${outputCurrency}`, + receiverHash: `hash-${outputCurrency}`, + receiverId: `receiver-${outputCurrency}` + } + }) + }); + expect(prepared.unsignedTxs.map(tx => [tx.phase, tx.nonce])).toEqual( + outputCurrency === EvmToken.USDC + ? [ + ["nablaApprove", 0], + ["nablaSwap", 1], + ["distributeFees", 2], + ["destinationTransfer", 3], + ["baseCleanupEurc", 4], + ["baseCleanupUsdc", 5] + ] + : [ + ["nablaApprove", 0], + ["nablaSwap", 1], + ["distributeFees", 2], + ["squidRouterApprove", 3], + ["squidRouterSwap", 4], + ["destinationTransfer", 5], + ["baseCleanupEurc", 6], + ["baseCleanupUsdc", 7] + ] + ); + expect(prepared.unsignedTxs.some(tx => tx.phase.startsWith("backup"))).toBe(false); + expect(prepared.stateMeta.transactionPlan).toEqual({ + nativePrefunding: + outputCurrency === EvmToken.USDC ? {} : { [`${Networks.Base}:${EPHEMERAL.toLowerCase()}`]: "123" } + }); + expect(prepared.unsignedTxs.find(tx => tx.phase === "destinationTransfer")?.txData).toMatchObject({ + gas: outputCurrency === EvmToken.ETH ? "21000" : "100000", + value: outputCurrency === EvmToken.ETH ? outputAmountRaw(outputCurrency) : "0" + }); + expect(baseBuilderCalls).toEqual(outputCurrency === EvmToken.USDC ? [] : [outputCurrency]); + }); + } +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts new file mode 100644 index 000000000..d4af34b54 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts @@ -0,0 +1,266 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { AlfredpayOnrampStatus, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { config } from "../../../../../config/vars"; +import type RampState from "../../../../../models/rampState.model"; +import * as quoteTicketNamespace from "../../../../../models/quoteTicket.model"; +import * as evmFundingNamespace from "../core/evm-funding"; +import * as financialOperationNamespace from "../core/financial-operation"; +import { priceFeedService } from "../../../priceFeed.service"; + +const sharedReal = { ...sharedNamespace }; +const quoteTicketReal = { ...quoteTicketNamespace }; +const evmFundingReal = { ...evmFundingNamespace }; +const financialOperationReal = { ...financialOperationNamespace }; +const findQuote = mock(async () => undefined as unknown); +const checkBalance = mock(async () => new Big(0)); +const getFundingBalance = mock(async () => new Big("1000000000")); +const getOnrampTransaction = mock( + async (): Promise<{ metadata?: { txHash?: string }; status: AlfredpayOnrampStatus }> => ({ + status: AlfredpayOnrampStatus.CREATED + }) +); +const sendTransaction = mock( + async () => "0x1111111111111111111111111111111111111111111111111111111111111111" as `0x${string}` +); +const waitForTransactionReceipt = mock(async () => ({ status: "success" as const })); +const fundingAccount = { address: "0x1111111111111111111111111111111111111111" as `0x${string}` }; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + AlfredpayApiService: { getInstance: () => ({ getOnrampTransaction }) }, + checkEvmBalanceForToken: checkBalance, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + estimateFeesPerGas: async () => ({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }), + getTransactionCount: async () => 0, + waitForTransactionReceipt + }), + sendTransactionWithBlindRetry: sendTransaction + }) + }, + getEvmBalance: getFundingBalance +})); +mock.module("../../../../../models/quoteTicket.model", () => ({ + ...quoteTicketReal, + default: { findByPk: findQuote } +})); +mock.module("../core/evm-funding", () => ({ + ...evmFundingReal, + getEvmFundingAccount: () => fundingAccount +})); +mock.module("../core/financial-operation", () => ({ + ...financialOperationReal, + requireFinancialFlowIdentity: () => ({ id: "test-flow", version: 1 }), + runFinancialOperation: async ({ perform }: { perform(key: string): Promise }) => perform("test-operation") +})); +const { SubsidizePostSwapExecutor } = await import("../phases/subsidize-post/execution"); +const { FinalSettlementSubsidyExecutor } = await import("../phases/final-settlement-subsidy/execution"); +const { AlfredpayOnrampMintExecutor } = await import("../phases/alfredpay-mint/execution"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../../../../models/quoteTicket.model", () => ({ ...quoteTicketReal })); + mock.module("../core/evm-funding", () => ({ ...evmFundingReal })); + mock.module("../core/financial-operation", () => ({ ...financialOperationReal })); +}); + +beforeEach(() => { + findQuote.mockClear(); + checkBalance.mockClear(); + getFundingBalance.mockClear(); + getOnrampTransaction.mockClear(); + sendTransaction.mockClear(); + waitForTransactionReceipt.mockClear(); +}); + +describe("EVM block executor regressions", () => { + it("splits BUY subsidy components and skips zero-value currency conversion", async () => { + checkBalance.mockResolvedValue(new Big("95000000")); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + subsidizePostSwap: { + actualOutputAmountRaw: "95000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + subsidyAmountInOutputTokenRaw: "5000000", + targetOutputAmountRaw: "100000000" + } + } + }, + outputAmount: "100", + outputCurrency: EvmToken.USDC + }); + const conversions: string[] = []; + const originalConvertCurrency = priceFeedService.convertCurrency; + priceFeedService.convertCurrency = mock(async amount => { + conversions.push(String(amount)); + return String(amount); + }) as typeof priceFeedService.convertCurrency; + const executor = Object.create(SubsidizePostSwapExecutor.prototype) as any; + executor.createSubsidy = mock(async () => undefined); + + try { + await executor.executePhase({ + quoteId: "quote-1", + state: { evmEphemeralAddress: "0x2222222222222222222222222222222222222222" }, + type: RampDirection.BUY + } as RampState); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + } + + expect(conversions).toEqual(["100", "5"]); + expect(sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("allows a sub-$1 discount subsidy above the runtime percentage cap", async () => { + checkBalance.mockResolvedValue(new Big("563600")); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + subsidizePostSwap: { + actualOutputAmountRaw: "563612", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + subsidyAmountInOutputTokenRaw: "39319", + targetOutputAmountRaw: "602931" + } + } + }, + outputAmount: "0.602893", + outputCurrency: EvmToken.USDC + }); + const originalConvertCurrency = priceFeedService.convertCurrency; + priceFeedService.convertCurrency = mock(async amount => String(amount)) as typeof priceFeedService.convertCurrency; + const executor = Object.create(SubsidizePostSwapExecutor.prototype) as any; + executor.createSubsidy = mock(async () => undefined); + + try { + await executor.executePhase({ + quoteId: "quote-1", + state: { evmEphemeralAddress: "0x2222222222222222222222222222222222222222" }, + type: RampDirection.BUY + } as RampState); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + } + + expect(sendTransaction).toHaveBeenCalledTimes(1); + expect(executor.createSubsidy).toHaveBeenCalledWith( + expect.anything(), + 0.039331, + EvmToken.USDC, + fundingAccount.address, + expect.any(String) + ); + }); + + it("retains the runtime percentage cap for discount subsidies of at least $1", async () => { + checkBalance.mockResolvedValue(new Big("10000000")); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + subsidizePostSwap: { + actualOutputAmountRaw: "10000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + subsidyAmountInOutputTokenRaw: "1000000", + targetOutputAmountRaw: "11000000" + } + } + }, + outputAmount: "10", + outputCurrency: EvmToken.USDC + }); + const originalConvertCurrency = priceFeedService.convertCurrency; + const originalDiscountCap = config.subsidy.evmPostSwapDiscountSubsidyQuoteFraction; + config.subsidy.evmPostSwapDiscountSubsidyQuoteFraction = 0.05; + priceFeedService.convertCurrency = mock(async amount => String(amount)) as typeof priceFeedService.convertCurrency; + const executor = Object.create(SubsidizePostSwapExecutor.prototype) as any; + executor.createSubsidy = mock(async () => undefined); + + try { + await expect( + executor.executePhase({ + quoteId: "quote-1", + state: { evmEphemeralAddress: "0x2222222222222222222222222222222222222222" }, + type: RampDirection.BUY + } as RampState) + ).rejects.toMatchObject({ isRecoverable: true }); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + config.subsidy.evmPostSwapDiscountSubsidyQuoteFraction = originalDiscountCap; + } + + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("records AlfredPay SELL settlement subsidy as Polygon USDT", async () => { + checkBalance.mockResolvedValue(new Big("900000")); + findQuote.mockResolvedValue({ + metadata: { blocks: { alfredpayOfframp: { inputAmountRaw: "1000000" } } }, + network: Networks.Polygon, + outputAmount: "1", + outputCurrency: FiatToken.MXN + }); + const state = { + id: "ramp-1", + quoteId: "quote-1", + state: { + evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + transactionPlan: { + settlementBaselines: { + "polygon:0x2222222222222222222222222222222222222222:0xc2132d05d31c914a87c6611c10748aeb04b58e8f": "0" + } + } + }, + type: RampDirection.SELL, + update: mock(async () => state) + } as unknown as RampState; + const executor = Object.create(FinalSettlementSubsidyExecutor.prototype) as any; + executor.createSubsidy = mock(async () => undefined); + const originalConvertCurrency = priceFeedService.convertCurrency; + priceFeedService.convertCurrency = mock(async amount => String(amount)) as typeof priceFeedService.convertCurrency; + + try { + await executor.executePhase(state); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + } + + expect(executor.createSubsidy).toHaveBeenCalledWith(state, 0.1, EvmToken.USDT, fundingAccount.address, expect.any(String)); + }); + + it("propagates rejected AlfredPay statuses and records on-chain completion while balance confirmation continues", async () => { + const executor = Object.create(AlfredpayOnrampMintExecutor.prototype) as any; + const state = { state: {}, update: mock(async () => state) } as unknown as RampState; + const controller = new AbortController(); + getOnrampTransaction.mockRejectedValueOnce({ failureReason: "rejected", kind: "failed" }); + + await expect(executor.pollStatus("tx-1", state, 0, controller.signal)).rejects.toEqual({ + failureReason: "rejected", + kind: "failed" + }); + + getOnrampTransaction.mockClear(); + getOnrampTransaction.mockResolvedValue({ + metadata: { txHash: "0x2222222222222222222222222222222222222222222222222222222222222222" }, + status: AlfredpayOnrampStatus.ON_CHAIN_COMPLETED + }); + const polling = executor.pollStatus("tx-2", state, 1, controller.signal); + await new Promise(resolve => setTimeout(resolve, 10)); + controller.abort(); + await polling.catch(() => undefined); + + expect(getOnrampTransaction.mock.calls.length).toBeGreaterThan(1); + expect(state.update).toHaveBeenCalledWith({ + state: { + alfredpayOnrampMintTxHash: "0x2222222222222222222222222222222222222222222222222222222222222222" + } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts new file mode 100644 index 000000000..46537f65c --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts @@ -0,0 +1,106 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import type QuoteTicket from "../../../../../models/quoteTicket.model"; +import type RampState from "../../../../../models/rampState.model"; +import * as userTxVerifier from "../../../phases/helpers/user-tx-verifier"; + +const verifyUserSubmittedTxByHash = mock(async () => undefined); +mock.module("../../../phases/helpers/user-tx-verifier", () => ({ + ...userTxVerifier, + verifyUserSubmittedTxByHash +})); +const { FundEphemeralExecutor } = await import("../phases/fund-ephemeral/execution"); + +afterAll(() => { + mock.module("../../../phases/helpers/user-tx-verifier", () => ({ ...userTxVerifier })); +}); + +function makeQuote(outputCurrency: FiatToken = FiatToken.BRL) { + return { + metadata: { + blocks: { + evmOfframpSource: { + fromNetwork: Networks.Arbitrum, + fromToken: EvmToken.USDC, + inputAmountRaw: "1000000", + toNetwork: Networks.Base, + toToken: EvmToken.USDC + } + }, + globals: { + fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } }, + partner: null, + request: {} + } + }, + outputCurrency + } as unknown as QuoteTicket; +} + +function makeState(signer: string, stateOverrides: Record = {}) { + return { + from: Networks.Arbitrum, + state: { + evmEphemeralAddress: "0x00000000000000000000000000000000000000ee", + squidRouterSwapHash: "0xswap", + ...stateOverrides + }, + type: RampDirection.SELL, + unsignedTxs: [ + { phase: "squidRouterApprove", signer }, + { phase: "squidRouterSwap", signer } + ] + } as unknown as RampState; +} + +describe("FundEphemeralExecutor user hash verification", () => { + it("allows an omitted approval hash but still verifies the user swap", async () => { + verifyUserSubmittedTxByHash.mockClear(); + const handler = Object.create(FundEphemeralExecutor.prototype) as any; + const state = makeState("0x00000000000000000000000000000000000000aa"); + + await handler.verifyUserSubmittedSourceTransactions(state, makeQuote()); + + expect(verifyUserSubmittedTxByHash).toHaveBeenCalledTimes(1); + expect(verifyUserSubmittedTxByHash).toHaveBeenCalledWith( + expect.objectContaining({ hash: "0xswap", presignedPhase: "squidRouterSwap" }) + ); + }); + + it("does not treat ephemeral-owned Squid transactions as user submissions", async () => { + verifyUserSubmittedTxByHash.mockClear(); + const handler = Object.create(FundEphemeralExecutor.prototype) as any; + const state = makeState("0x00000000000000000000000000000000000000ee"); + + await handler.verifyUserSubmittedSourceTransactions(state, makeQuote()); + + expect(verifyUserSubmittedTxByHash).not.toHaveBeenCalled(); + }); + + it("verifies Mykobo EUR SELL source transactions", async () => { + verifyUserSubmittedTxByHash.mockClear(); + const handler = Object.create(FundEphemeralExecutor.prototype) as any; + + await handler.verifyUserSubmittedSourceTransactions( + makeState("0x00000000000000000000000000000000000000aa"), + makeQuote(FiatToken.EURC) + ); + + expect(verifyUserSubmittedTxByHash).toHaveBeenCalledWith( + expect.objectContaining({ hash: "0xswap", presignedPhase: "squidRouterSwap" }) + ); + }); + + it("preserves AlfredPay and AssetHub exclusions", async () => { + verifyUserSubmittedTxByHash.mockClear(); + const handler = Object.create(FundEphemeralExecutor.prototype) as any; + const alfredpayState = makeState("0x00000000000000000000000000000000000000aa"); + const assethubState = makeState("0x00000000000000000000000000000000000000aa"); + assethubState.from = Networks.AssetHub; + + await handler.verifyUserSubmittedSourceTransactions(alfredpayState, makeQuote(FiatToken.MXN)); + await handler.verifyUserSubmittedSourceTransactions(assethubState, makeQuote()); + + expect(verifyUserSubmittedTxByHash).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/mykobo-mint.executor.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/mykobo-mint.executor.test.ts new file mode 100644 index 000000000..456d2c931 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/mykobo-mint.executor.test.ts @@ -0,0 +1,45 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import Big from "big.js"; +import type RampState from "../../../../../models/rampState.model"; +import * as quoteTicketNamespace from "../../../../../models/quoteTicket.model"; + +const sharedReal = { ...sharedNamespace }; +const quoteTicketReal = { ...quoteTicketNamespace }; +const waitForBalance = mock(async () => undefined); +const findQuote = mock(async () => ({ + metadata: { blocks: { mykoboMint: { mint: { outputAmountRaw: "100000000" } } } } +})); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + checkEvmBalancePeriodically: waitForBalance, + getEvmTokenBalance: async () => new Big("95000000") +})); +mock.module("../../../../../models/quoteTicket.model", () => ({ + ...quoteTicketReal, + default: { findByPk: findQuote } +})); + +const { MykoboOnrampDepositExecutor } = await import("../phases/mykobo-mint/execution"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../../../../models/quoteTicket.model", () => ({ ...quoteTicketReal })); +}); + +describe("MykoboOnrampDepositExecutor recovery", () => { + it("accepts an already-settled balance at the 95% recovery threshold without starting the live wait", async () => { + const state = { + phaseHistory: [{ phase: "mykoboOnrampDeposit", timestamp: new Date() }], + quoteId: "quote-eur", + state: { evmEphemeralAddress: "0x1212121212121212121212121212121212121212" } + } as unknown as RampState; + const executor = new MykoboOnrampDepositExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + expect(await executor.executePhase(state)).toBe(state); + expect(waitForBalance).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/mykobo-offramp-payout.executor.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/mykobo-offramp-payout.executor.test.ts new file mode 100644 index 000000000..3405ed87f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/mykobo-offramp-payout.executor.test.ts @@ -0,0 +1,69 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { MykoboTransactionStatus, Networks } from "@vortexfi/shared"; +import type RampState from "../../../../../models/rampState.model"; + +const sharedReal = { ...sharedNamespace }; +const waitForReceipt = mock(async () => ({ status: "success" })); +const send = mock(async () => "0xnew"); +const getTransaction = mock(async () => ({ transaction: { status: MykoboTransactionStatus.COMPLETED } })); +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ waitForTransactionReceipt: waitForReceipt }), + sendRawTransactionWithRetry: send + }) + }, + MykoboApiService: { getInstance: () => ({ getTransaction }) } +})); +const { MykoboOfframpPayoutExecutor } = await import("../phases/mykobo-offramp-payout/execution"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); +}); + +describe("Mykobo offramp payout executor recovery", () => { + it("reuses a confirmed payout hash and resumes provider polling without rebroadcasting", async () => { + const state = { + presignedTxs: [{ phase: "mykoboPayoutOnBase", txData: "0xsigned" }], + state: { + blockState: { + mykoboOfframpPayout: { + mykoboEmail: "verified@example.com", + mykoboReceivablesAddress: "0x3434343434343434343434343434343434343434", + mykoboTransactionId: "withdraw-1", + mykoboTransactionReference: "EUR-WITHDRAW-1" + } + }, + mykoboPayoutTxHash: `0x${"1".repeat(64)}` + } + } as unknown as RampState; + const executor = new MykoboOfframpPayoutExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + expect(await executor.executePhase(state)).toBe(state); + expect(waitForReceipt).toHaveBeenCalledTimes(1); + expect(send).not.toHaveBeenCalled(); + expect(getTransaction).toHaveBeenCalledWith("withdraw-1"); + }); + + it("resumes persisted flat payout state after the executor migration", async () => { + const state = { + presignedTxs: [{ phase: "mykoboPayoutOnBase", txData: "0xsigned" }], + state: { + mykoboEmail: "verified@example.com", + mykoboPayoutTxHash: `0x${"2".repeat(64)}`, + mykoboReceivablesAddress: "0x3434343434343434343434343434343434343434", + mykoboTransactionId: "flat-state-withdraw", + mykoboTransactionReference: "EUR-FLAT-STATE-1" + } + } as unknown as RampState; + const executor = new MykoboOfframpPayoutExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + expect(await executor.executePhase(state)).toBe(state); + expect(getTransaction).toHaveBeenCalledWith("flat-state-withdraw"); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/nabla-swap.executor.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/nabla-swap.executor.test.ts new file mode 100644 index 000000000..3d0c8c780 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/nabla-swap.executor.test.ts @@ -0,0 +1,153 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { parseTransaction } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import * as financialOperationNamespace from "../core/financial-operation"; + +const sharedReal = { ...sharedNamespace }; +const financialOperationReal = { ...financialOperationNamespace }; +const account = privateKeyToAccount("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); +const unexpectedAddress = "0x1111111111111111111111111111111111111111"; +const routerAddress = "0x2222222222222222222222222222222222222222"; +const swapHash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const swapTx = await account.signTransaction({ + chainId: 8453, + data: "0x12345678", + gas: 500000n, + maxFeePerGas: 2000000000n, + maxPriorityFeePerGas: 1000000n, + nonce: 0, + to: routerAddress, + type: "eip1559", + value: 0n +}); + +const call = mock(async () => ({ data: "0x" })); +const sendRawTransaction = mock(async () => swapHash); +const waitForTransactionReceipt = mock(async () => ({ status: "success" })); +const checkEvmBalanceForToken = mock(async () => undefined); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + checkEvmBalanceForToken, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ call, sendRawTransaction, waitForTransactionReceipt }) + }) + }, + evmTokenConfig: { + [sharedReal.Networks.Base]: { + [sharedReal.EvmToken.USDC]: { + assetSymbol: sharedReal.EvmToken.USDC, + decimals: 6, + erc20AddressSourceChain: "0x3333333333333333333333333333333333333333", + isNative: false, + network: sharedReal.Networks.Base + } + } + } +})); +mock.module("../core/financial-operation", () => ({ + ...financialOperationReal, + requireFinancialFlowIdentity: () => ({ id: "test-flow", version: 1 }), + runFinancialOperation: async ({ perform }: { perform(key: string): Promise }) => perform("test-operation") +})); + +const { default: QuoteTicket } = await import("../../../../../models/quoteTicket.model"); +const { NablaSwapExecutor } = await import("../phases/nabla-swap/execution"); +const realQuoteTicketFindByPk = QuoteTicket.findByPk; + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/financial-operation", () => ({ ...financialOperationReal })); + QuoteTicket.findByPk = realQuoteTicketFindByPk; +}); + +QuoteTicket.findByPk = mock(async () => ({ + metadata: { + blocks: { + nablaSwap: { + inputAmountForSwapRaw: "1000000", + inputCurrency: sharedReal.EvmToken.USDC, + network: sharedReal.Networks.Base + } + } + } +})) as typeof QuoteTicket.findByPk; + +function makeState(evmEphemeralAddress = account.address) { + return { + currentPhase: "nablaSwap", + errorLogs: [], + get() { + return this; + }, + id: "ramp-1", + phaseHistory: [], + presignedTxs: [ + { + meta: {}, + network: sharedReal.Networks.Base, + nonce: 0, + phase: "nablaSwap", + signer: account.address, + txData: swapTx + } + ], + quoteId: "quote-1", + state: { evmEphemeralAddress }, + type: sharedReal.RampDirection.SELL, + async update(updateData: Record) { + Object.assign(this, updateData); + return this; + } + } as any; +} + +describe("NablaSwapExecutor EVM transaction validation", () => { + beforeEach(() => { + call.mockClear(); + sendRawTransaction.mockClear(); + waitForTransactionReceipt.mockClear(); + checkEvmBalanceForToken.mockClear(); + }); + + it("dry-runs the decoded swap before broadcasting", async () => { + const decoded = parseTransaction(swapTx); + + await new NablaSwapExecutor().execute(makeState()); + + expect(call).toHaveBeenCalledWith({ + accessList: decoded.accessList, + account: account.address, + blockTag: "pending", + data: decoded.data, + gas: decoded.gas, + maxFeePerGas: decoded.maxFeePerGas, + maxPriorityFeePerGas: decoded.maxPriorityFeePerGas, + to: decoded.to, + type: "eip1559", + value: decoded.value + }); + expect(sendRawTransaction).toHaveBeenCalledWith({ serializedTransaction: swapTx }); + }); + + it("does not broadcast when the dry-run reverts", async () => { + call.mockRejectedValueOnce(new Error("EXCEEDS_MAX_COVERAGE_RATIO")); + const state = makeState(); + + await expect(new NablaSwapExecutor().execute(state)).rejects.toThrow("EXCEEDS_MAX_COVERAGE_RATIO"); + + expect(sendRawTransaction).not.toHaveBeenCalled(); + expect(state.errorLogs.at(-1)).toMatchObject({ recoverable: true }); + }); + + it("rejects a swap signed by an unexpected sender before dry-running or broadcasting", async () => { + const state = makeState(unexpectedAddress); + await expect(new NablaSwapExecutor().execute(state)).rejects.toThrow("sender mismatch"); + + expect(call).not.toHaveBeenCalled(); + expect(sendRawTransaction).not.toHaveBeenCalled(); + expect(state.errorLogs.at(-1)).toMatchObject({ recoverable: false }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/offramp-subsidy-usd-valuation.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/offramp-subsidy-usd-valuation.test.ts new file mode 100644 index 000000000..b44129424 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/offramp-subsidy-usd-valuation.test.ts @@ -0,0 +1,99 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import * as partnerPricingService from "../../../partners/partner-pricing.service"; +import { priceFeedService } from "../../../priceFeed.service"; + +const findPartnerWithPricing = mock(async () => null); +mock.module("../../../partners/partner-pricing.service", () => ({ + ...partnerPricingService, + findPartnerWithPricing +})); + +const { simulateOfframpSubsidizePost } = await import("../phases/subsidize-post/simulation"); + +afterAll(() => { + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingService })); +}); + +describe("block offramp subsidy USD valuation", () => { + it("values BRLA input in USD before applying the inverted BRL rate", async () => { + const originalRate = priceFeedService.getFiatToUsdExchangeRate; + priceFeedService.getFiatToUsdExchangeRate = mock(async () => new Big("0.2")) as never; + const notes: string[] = []; + + try { + const result = await simulateOfframpSubsidizePost( + { amount: new Big("100"), amountRaw: "100000000000000000000", chain: Networks.Base, token: EvmToken.BRLA }, + { + addNote: note => notes.push(note), + fees: { + displayFiat: { anchor: "0", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0", vortex: "0" }, + usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } + }, + notes, + now: new Date(), + partner: null, + request: { + from: Networks.Base, + inputAmount: "100", + inputCurrency: EvmToken.BRLA, + network: Networks.Base, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX + }, + targetFeeFiatCurrency: FiatToken.BRL + } + ); + + expect(result.metadata.expectedOutputAmountDecimal.toString()).toBe("100"); + expect(notes).toContain( + "OfframpSubsidizePost: valued input 100 BRLA at 20.000000 USD for discount calculation" + ); + } finally { + priceFeedService.getFiatToUsdExchangeRate = originalRate; + } + }); + + it("uses the source block's bridged USDC amount for non-pegged input", async () => { + const originalRate = priceFeedService.getFiatToUsdExchangeRate; + priceFeedService.getFiatToUsdExchangeRate = mock(async () => new Big("0.2")) as never; + + try { + const result = await simulateOfframpSubsidizePost( + { + amount: new Big("499"), + amountRaw: "499000000000000000000", + chain: Networks.Base, + requestInputAmountUsd: new Big("100"), + token: EvmToken.BRLA + }, + { + addNote: () => undefined, + fees: { + displayFiat: { anchor: "0", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0", vortex: "0" }, + usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } + }, + notes: [], + now: new Date(), + partner: null, + request: { + from: Networks.Ethereum, + inputAmount: "0.05", + inputCurrency: EvmToken.ETH, + network: Networks.Ethereum, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: EPaymentMethod.PIX + }, + targetFeeFiatCurrency: FiatToken.BRL + } + ); + + expect(result.metadata.expectedOutputAmountDecimal.toString()).toBe("500"); + } finally { + priceFeedService.getFiatToUsdExchangeRate = originalRate; + } + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/onramp-discount.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/onramp-discount.test.ts new file mode 100644 index 000000000..934e23e24 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/onramp-discount.test.ts @@ -0,0 +1,174 @@ +import { afterAll, afterEach, describe, expect, it, mock, setSystemTime } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { config } from "../../../../../config/vars"; +import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; +import * as priceFeedNamespace from "../../../priceFeed.service"; +import * as squidrouterNamespace from "../core/squidrouter"; +import type { PhaseCtx } from "../core/types"; + +const partnerPricingReal = { ...partnerPricingNamespace }; +const priceFeedReal = { ...priceFeedNamespace }; +const squidrouterReal = { ...squidrouterNamespace }; + +const pricingById = new Map(); +const bridgeQuoteRequests: Array<{ + amountDecimal: string; + fromNetwork: Networks; + inputCurrency: EvmToken; + outputCurrency: EvmToken; + toNetwork: Networks; +}> = []; + +mock.module("../../../partners/partner-pricing.service", () => ({ + findPartnerWithPricing: async ({ id }: { id?: string }, _rampType: RampDirection, fiatCurrency: FiatToken) => { + const pricing = id ? pricingById.get(id) : undefined; + if (!id || !pricing || pricing.fiatCurrency !== fiatCurrency) return null; + return { + displayName: id, + fiatCurrency, + id, + logoUrl: null, + markupCurrency: EvmToken.USDC, + markupType: "none", + markupValue: 0, + maxDynamicDifference: 0.01, + maxSubsidy: 0.5, + minDynamicDifference: -0.01, + name: id, + payoutAddressEvm: null, + payoutAddressSubstrate: null, + rampType: RampDirection.BUY, + targetDiscount: pricing.targetDiscount, + vortexFeeType: "none", + vortexFeeValue: 0 + }; + } +})); + +mock.module("../../../priceFeed.service", () => ({ + priceFeedService: { + getFiatToUsdExchangeRate: async (currency: FiatToken) => new Big(currency === FiatToken.BRL ? "0.2" : "1.08") + } +})); + +mock.module("../core/squidrouter", () => ({ + getEvmBridgeQuote: async (request: (typeof bridgeQuoteRequests)[number]) => { + bridgeQuoteRequests.push(request); + return { outputAmountDecimal: new Big(request.amountDecimal).times("0.9") }; + } +})); + +afterEach(() => { + pricingById.clear(); + bridgeQuoteRequests.length = 0; + setSystemTime(); +}); + +afterAll(() => { + mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); + mock.module("../../../priceFeed.service", () => ({ ...priceFeedReal })); + mock.module("../core/squidrouter", () => ({ ...squidrouterReal })); +}); + +const { calculateExpectedOutput, resolveActivePartnerById } = await import("../core/discount"); +const { simulateDistributeFees } = await import("../phases/distribute-fees/simulation"); +const { simulateSubsidizePost } = await import("../phases/subsidize-post/simulation"); + +function buildCtx(fiatCurrency: FiatToken, partnerId: string, to: Networks, outputCurrency: EvmToken): PhaseCtx { + return { + addNote() {}, + fees: { + displayFiat: { + anchor: "0", + currency: fiatCurrency, + network: "1.25", + partnerMarkup: "0.5", + total: "2.5", + vortex: "0.75" + }, + usd: { anchor: "0", network: "1.25", partnerMarkup: "0.5", total: "2.5", vortex: "0.75" } + }, + notes: [], + now: new Date(), + partner: { id: partnerId }, + request: { + from: fiatCurrency === FiatToken.BRL ? EPaymentMethod.PIX : EPaymentMethod.SEPA, + inputAmount: fiatCurrency === FiatToken.BRL ? "500" : "100", + inputCurrency: fiatCurrency, + network: Networks.Base, + outputCurrency, + rampType: RampDirection.BUY, + to + } + }; +} + +describe("onramp discount semantics", () => { + it("applies BRL dynamic adjustment, post-swap fee deduction, and a non-1:1 Squid rate", async () => { + const partnerId = "brl-dynamic-partner"; + pricingById.set(partnerId, { fiatCurrency: FiatToken.BRL, targetDiscount: 0.02 }); + setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const partner = await resolveActivePartnerById(partnerId, RampDirection.BUY, FiatToken.BRL); + calculateExpectedOutput("500", new Big("0.2"), 0.02, false, partner); + setSystemTime(new Date("2026-01-01T00:11:00.000Z")); + + const ctx = buildCtx(FiatToken.BRL, partnerId, Networks.Arbitrum, EvmToken.USDC); + const afterFees = await simulateDistributeFees( + { amount: new Big("100"), amountRaw: "100000000", chain: Networks.Base, token: EvmToken.USDC }, + ctx + ); + expect(afterFees.metadata).toMatchObject({ + networkFeeUsd: "1.25", + partnerMarkupUsd: "0.5", + totalFeesUsd: "2.5", + vortexFeeUsd: "0.75" + }); + expect(afterFees.output.amount.toFixed()).toBe("97.5"); + expect(afterFees.output.amountRaw).toBe("97500000"); + const result = await simulateSubsidizePost(afterFees.output, ctx); + + expect(result.metadata.partnerId).toBe(partnerId); + expect(Big(result.metadata.actualOutputAmountDecimal).toFixed()).toBe("97.5"); + expect(result.metadata.actualOutputAmountRaw).toBe("97500000"); + const adjustedDifference = new Big(config.quote.deltaDBasisPoints).div(10000); + const adjustedTargetDiscount = new Big("0.02").plus(adjustedDifference); + const bridgeInput = new Big(100).times(adjustedTargetDiscount.plus(1)); + const expectedOutput = bridgeInput.div("0.9"); + expect(Big(result.metadata.adjustedDifference).toFixed()).toBe(adjustedDifference.toFixed()); + expect(Big(result.metadata.adjustedTargetDiscount).toFixed()).toBe(adjustedTargetDiscount.toFixed()); + expect(Big(result.metadata.expectedOutputAmountDecimal).toFixed(6)).toBe(expectedOutput.toFixed(6)); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).toFixed(6)).toBe(expectedOutput.minus("97.5").toFixed(6)); + expect(result.metadata.applied).toBe(true); + expect(bridgeQuoteRequests).toEqual([ + { + amountDecimal: bridgeInput.toFixed(), + fromNetwork: Networks.Base, + inputCurrency: EvmToken.USDC, + outputCurrency: EvmToken.USDC, + toNetwork: Networks.Arbitrum + } + ]); + }); + + it("applies the resolved EUR partner discount on the Base USDC 1:1 route", async () => { + const partnerId = "eur-discount-partner"; + pricingById.set(partnerId, { fiatCurrency: FiatToken.EURC, targetDiscount: 0.01 }); + const ctx = buildCtx(FiatToken.EURC, partnerId, Networks.Base, EvmToken.USDC); + + const afterFees = await simulateDistributeFees( + { amount: new Big("107"), amountRaw: "107000000", chain: Networks.Base, token: EvmToken.USDC }, + ctx + ); + const result = await simulateSubsidizePost(afterFees.output, ctx); + + expect(result.metadata.partnerId).toBe(partnerId); + expect(Big(result.metadata.actualOutputAmountDecimal).toFixed()).toBe("104.5"); + expect(Big(result.metadata.adjustedDifference).toFixed()).toBe("0"); + expect(Big(result.metadata.adjustedTargetDiscount).toFixed()).toBe("0.01"); + expect(Big(result.metadata.expectedOutputAmountDecimal).toFixed()).toBe("109.08"); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).toFixed()).toBe("4.58"); + expect(result.metadata.applied).toBe(true); + expect(bridgeQuoteRequests).toEqual([]); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/pendulum-executor-regressions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/pendulum-executor-regressions.test.ts new file mode 100644 index 000000000..084c46d39 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/pendulum-executor-regressions.test.ts @@ -0,0 +1,253 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, mock, spyOn } from "bun:test"; +import * as solangNamespace from "@pendulum-chain/api-solang"; +import * as sharedNamespace from "@vortexfi/shared"; +import Big from "big.js"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import type RampState from "../../../../../models/rampState.model"; +import * as financialOperationNamespace from "../core/financial-operation"; + +const sharedReal = { ...sharedNamespace }; +const financialOperationReal = { ...financialOperationNamespace }; +const fundingAccount = { address: "funding" }; +type BalanceResponse = { free: { toString(): string } }; +const accounts = mock(async (_address: string, _currencyId: unknown): Promise => ({ + free: { toString: (): string => "0" } +})); +const approvals = mock(async (): Promise<{ toString(): string }> => ({ toString: (): string => "0" })); +const executeApiCall = mock(async () => ({ hash: "0xsubsidy" })); +const decodeSubmittableExtrinsic = mock(() => ({})); +const submitExtrinsic = mock(async () => ({ status: { type: "success" }, txHash: { toString: (): string => "0xfee" } })); +const submitXTokens = mock(async () => ({ hash: "0xxcm" })); +const getEvmTokenBalance = mock(async () => new Big(100)); +const waitUntilTrueWithTimeout = mock(async (predicate: () => Promise) => { + if (!(await predicate())) throw new Error("balance did not settle"); +}); +const pendulum = { + api: { + query: { tokenAllowance: { approvals }, tokens: { accounts } }, + registry: { getChainProperties: () => undefined }, + tx: { tokens: { transfer: () => ({}) } } + }, + ss58Format: 57 +}; +const getApi = mock(async () => pendulum); +const ownedSpies: Array<{ mockRestore(): void }> = []; + +mock.module("../core/financial-operation", () => ({ + ...financialOperationReal, + requireFinancialFlowIdentity: () => ({ id: "test-flow", version: 1 }), + runFinancialOperation: async ({ perform }: { perform(key: string): Promise }) => perform("test-operation") +})); + +let SubsidizePreSwapExecutor: typeof import("../phases/subsidize-pre/execution").SubsidizePreSwapExecutor; +let SubsidizePostSwapExecutor: typeof import("../phases/subsidize-post/execution").SubsidizePostSwapExecutor; +let DistributeFeesExecutor: typeof import("../phases/distribute-fees/execution").DistributeFeesExecutor; +let NablaApproveExecutor: typeof import("../phases/nabla-swap/execution").NablaApproveExecutor; +let PendulumToAveniaXcmExecutor: typeof import("../phases/avenia-pendulum-offramp/execution").PendulumToAveniaXcmExecutor; +const originalFindByPk = QuoteTicket.findByPk; +const originalFindOne = QuoteTicket.findOne; +const originalFetch = globalThis.fetch; + +beforeAll(async () => { + const funding = await import("../../../../controllers/subsidize.controller"); + ownedSpies.push( + spyOn(sharedNamespace.ApiManager, "getInstance").mockReturnValue({ executeApiCall, getApi } as never), + spyOn(sharedNamespace, "decodeSubmittableExtrinsic").mockImplementation(decodeSubmittableExtrinsic as never), + spyOn(sharedNamespace, "getAddressForFormat").mockImplementation((address: string) => address), + spyOn(sharedNamespace, "getEvmTokenBalance").mockImplementation(getEvmTokenBalance as never), + spyOn(sharedNamespace, "submitXTokens").mockImplementation(submitXTokens as never), + spyOn(sharedNamespace, "waitUntilTrueWithTimeout").mockImplementation(waitUntilTrueWithTimeout as never), + spyOn(solangNamespace, "submitExtrinsic").mockImplementation(submitExtrinsic as never), + spyOn(funding, "getFundingAccount").mockReturnValue(fundingAccount as never) + ); + ({ SubsidizePreSwapExecutor } = await import("../phases/subsidize-pre/execution")); + ({ SubsidizePostSwapExecutor } = await import("../phases/subsidize-post/execution")); + ({ DistributeFeesExecutor } = await import("../phases/distribute-fees/execution")); + ({ NablaApproveExecutor } = await import("../phases/nabla-swap/execution")); + ({ PendulumToAveniaXcmExecutor } = await import("../phases/avenia-pendulum-offramp/execution")); +}); + +afterEach(() => { + accounts.mockReset(); + approvals.mockReset(); + executeApiCall.mockReset(); + executeApiCall.mockImplementation(async () => ({ hash: "0xsubsidy" })); + decodeSubmittableExtrinsic.mockReset(); + decodeSubmittableExtrinsic.mockImplementation(() => ({})); + submitExtrinsic.mockClear(); + submitXTokens.mockClear(); + getApi.mockReset(); + getApi.mockImplementation(async () => pendulum); + getEvmTokenBalance.mockReset(); + getEvmTokenBalance.mockImplementation(async () => new Big(100)); + waitUntilTrueWithTimeout.mockClear(); + globalThis.fetch = originalFetch; +}); + +afterAll(() => { + QuoteTicket.findByPk = originalFindByPk; + QuoteTicket.findOne = originalFindOne; + globalThis.fetch = originalFetch; + for (const spy of ownedSpies) spy.mockRestore(); + mock.module("../core/financial-operation", () => ({ ...financialOperationReal })); +}); + +function state(overrides: Record = {}): RampState { + return { + id: "ramp-1", + presignedTxs: [{ phase: "distributeFees", txData: "0x01" }, { phase: "pendulumToMoonbeamXcm", txData: "0x02" }], + quoteId: "quote-1", + state: { substrateEphemeralAddress: "ephemeral", ...overrides }, + update: mock(async () => undefined) + } as unknown as RampState; +} + +function setQuoteBlock(key: string, metadata: Record): void { + const quote = { metadata: { blocks: { [key]: metadata } } }; + QuoteTicket.findByPk = mock(async () => quote) as typeof QuoteTicket.findByPk; + QuoteTicket.findOne = mock(async () => quote) as typeof QuoteTicket.findOne; +} + +function expose(executor: T): T & { executePhase(state: RampState): Promise; createSubsidy: ReturnType } { + return executor as T & { executePhase(state: RampState): Promise; createSubsidy: ReturnType }; +} + +describe("Pendulum block executor regressions", () => { + it("waits for pre-subsidy target balance settlement", async () => { + setQuoteBlock("subsidizePreSwap", { + inputCurrency: "BRL", + inputCurrencyId: { token: "BRL" }, + inputDecimals: 2, + network: "pendulum", + targetInputAmountRaw: "100" + }); + accounts.mockImplementation(async address => ({ + free: { toString: () => (address === "funding" ? "1000" : accounts.mock.calls.length >= 3 ? "100" : "50") } + })); + const executor = expose(new SubsidizePreSwapExecutor()); + executor.createSubsidy = mock(async () => undefined); + + await executor.executePhase(state()); + + expect(waitUntilTrueWithTimeout).toHaveBeenCalledTimes(1); + expect(accounts).toHaveBeenCalledTimes(3); + }); + + it("waits for post-subsidy target balance settlement", async () => { + setQuoteBlock("subsidizePostSwap", { + network: "pendulum", + outputCurrency: "USDC", + outputCurrencyId: { token: "USDC" }, + outputDecimals: 6, + targetOutputAmountRaw: "100" + }); + accounts.mockImplementation(async address => ({ + free: { toString: () => (address === "funding" ? "1000" : accounts.mock.calls.length >= 3 ? "100" : "50") } + })); + const executor = expose(new SubsidizePostSwapExecutor()); + executor.createSubsidy = mock(async () => undefined); + + await executor.executePhase(state()); + + expect(waitUntilTrueWithTimeout).toHaveBeenCalledTimes(1); + expect(accounts).toHaveBeenCalledTimes(3); + }); + + it("keeps transient subsidy RPC errors recoverable but insufficient funding unrecoverable", async () => { + setQuoteBlock("subsidizePreSwap", { + inputCurrency: "BRL", + inputCurrencyId: { token: "BRL" }, + inputDecimals: 2, + network: "pendulum", + targetInputAmountRaw: "100" + }); + accounts.mockRejectedValueOnce(new Error("RPC unavailable")); + const executor = expose(new SubsidizePreSwapExecutor()); + await expect(executor.executePhase(state())).rejects.toMatchObject({ isRecoverable: true }); + + accounts.mockImplementation(async address => ({ free: { toString: () => (address === "funding" ? "1" : "50") } })); + await expect(executor.executePhase(state())).rejects.toMatchObject({ isRecoverable: false }); + + setQuoteBlock("subsidizePostSwap", { + network: "pendulum", + outputCurrency: "USDC", + outputCurrencyId: { token: "USDC" }, + outputDecimals: 6, + targetOutputAmountRaw: "100" + }); + accounts.mockRejectedValueOnce(new Error("RPC unavailable")); + await expect(expose(new SubsidizePostSwapExecutor()).executePhase(state())).rejects.toMatchObject({ isRecoverable: true }); + }); + + it("checks a persisted Pendulum fee hash before advancing", async () => { + setQuoteBlock("distributeFees", { network: "pendulum", totalFeesUsd: "1" }); + globalThis.fetch = mock(async () => + Response.json({ code: 0, data: { success: true } }) + ) as unknown as typeof fetch; + + await expose(new DistributeFeesExecutor()).executePhase(state({ distributeFeeHash: "0xexisting" })); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(submitExtrinsic).not.toHaveBeenCalled(); + }); + + it("keeps Pendulum fee status, query, decode, and submission failures recoverable", async () => { + setQuoteBlock("distributeFees", { + network: "pendulum", + outputCurrencyId: { token: "USDC" }, + outputDecimals: 6, + totalFeesUsd: "1" + }); + const executor = expose(new DistributeFeesExecutor()); + globalThis.fetch = mock(async () => { + throw new Error("Subscan unavailable"); + }) as unknown as typeof fetch; + await expect(executor.executePhase(state({ distributeFeeHash: "0xexisting" }))).rejects.toMatchObject({ isRecoverable: true }); + + accounts.mockRejectedValueOnce(new Error("RPC unavailable")); + await expect(executor.executePhase(state())).rejects.toMatchObject({ isRecoverable: true }); + + accounts.mockResolvedValue({ free: { toString: () => "1000000" } }); + decodeSubmittableExtrinsic.mockImplementationOnce(() => { + throw new Error("decode unavailable"); + }); + await expect(executor.executePhase(state())).rejects.toMatchObject({ isRecoverable: true }); + + submitExtrinsic.mockRejectedValueOnce(new Error("submission unavailable")); + await expect(executor.executePhase(state())).rejects.toMatchObject({ isRecoverable: true }); + }); + + it("keeps Pendulum Nabla allowance-query failures recoverable", async () => { + setQuoteBlock("nablaSwap", { + inputAmountForSwapRaw: "100", + inputCurrencyId: { token: "BRL" }, + network: "pendulum" + }); + approvals.mockRejectedValueOnce(new Error("RPC unavailable")); + + await expect(expose(new NablaApproveExecutor()).executePhase(state())).rejects.toMatchObject({ isRecoverable: true }); + }); + + it("records the fixed GLMR subsidy after a new Pendulum-to-Avenia XCM submission", async () => { + setQuoteBlock("aveniaPendulumOfframp", { + pendulumCurrencyId: { token: "BRL" }, + transferAmountRaw: "100" + }); + accounts.mockResolvedValue({ free: { toString: () => "100" } }); + const rampState = state({ + blockState: { aveniaPendulumOfframp: { brlaEvmAddress: "0x1111111111111111111111111111111111111111" } } + }); + const executor = expose(new PendulumToAveniaXcmExecutor()); + executor.createSubsidy = mock(async () => undefined); + + await executor.executePhase(rampState); + + expect(executor.createSubsidy).toHaveBeenCalledWith( + rampState, + sharedReal.nativeToDecimal(sharedReal.MOONBEAM_XCM_FEE_GLMR, 18).toNumber(), + "GLMR", + "ephemeral", + "0xxcm" + ); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/settlement.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/settlement.test.ts new file mode 100644 index 000000000..e9ea39d8e --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/settlement.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; +import Big from "big.js"; +import { calculateSettlementSubsidyRaw } from "../core/settlement"; + +describe("settlement subsidy", () => { + it("excludes the pre-bridge balance from delivered funds", () => { + const subsidy = calculateSettlementSubsidyRaw(new Big(100), new Big(50), new Big(40), new Big(0)); + expect(subsidy.toFixed(0)).toBe("50"); + }); + + it("never tops up beyond the observable on-chain shortfall", () => { + const subsidy = calculateSettlementSubsidyRaw(new Big(100), new Big(95), new Big(40), new Big(0)); + expect(subsidy.toFixed(0)).toBe("5"); + }); + + it("includes a native gas reserve without double-counting the baseline", () => { + const subsidy = calculateSettlementSubsidyRaw(new Big(100), new Big(100), new Big(10), new Big(5)); + expect(subsidy.toFixed(0)).toBe("5"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts new file mode 100644 index 000000000..749ed76ce --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts @@ -0,0 +1,320 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { Networks } from "@vortexfi/shared"; +import Big from "big.js"; +import type QuoteTicket from "../../../../../models/quoteTicket.model"; +import type RampState from "../../../../../models/rampState.model"; +import * as financialOperationNamespace from "../core/financial-operation"; +import { settlementBalanceKey } from "../core/settlement"; + +const sharedReal = { ...sharedNamespace }; +const financialOperationReal = { ...financialOperationNamespace }; +const SWAP_HASH = "0x31365ff4337000801303097a0494fd97ecc1661ea84fedee801f01825b236f49"; +const getStatus = mock(async (..._args: unknown[]) => ({ + id: "", + isGMPTransaction: true, + routeStatus: [], + squidTransactionStatus: "", + status: "ongoing" +})); +const getStatusAxelarScan = mock(async (..._args: unknown[]) => undefined as unknown); +const recoverAxelarStuckConfirm = mock(async (..._args: unknown[]) => "AXELAR_RECOVERY_HASH"); +const checkEvmBalanceForToken = mock(async (..._args: unknown[]) => new Big("900100")); +const estimateFeesPerGas = mock(async () => ({ maxFeePerGas: 10n, maxPriorityFeePerGas: 3n })); +const sendTransaction = mock(async (_transaction: Record) => "0xgasfunding" as `0x${string}`); +const fundingAccount = { address: "0x1111111111111111111111111111111111111111" as `0x${string}` }; + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + chain: {}, + estimateFeesPerGas, + getTransactionCount: async () => 0, + waitForTransactionReceipt: async () => ({ status: "success" }) + }), + getWalletClient: () => ({ account: fundingAccount, sendTransaction }) + }) + }, + checkEvmBalanceForToken, + getStatus, + getStatusAxelarScan, + recoverAxelarStuckConfirm +})); +mock.module("../core/financial-operation", () => ({ + ...financialOperationReal, + requireFinancialFlowIdentity: () => ({ id: "test-flow", version: 1 }), + runFinancialOperation: async ({ perform }: { perform(key: string): Promise }) => perform("test-operation") +})); + +const { SquidRouterPayExecutor } = await import("../phases/squid-router-swap/execution"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../core/financial-operation", () => ({ ...financialOperationReal })); +}); + +beforeEach(() => { + getStatus.mockClear(); + getStatusAxelarScan.mockClear(); + recoverAxelarStuckConfirm.mockClear(); + checkEvmBalanceForToken.mockClear(); + estimateFeesPerGas.mockClear(); + sendTransaction.mockClear(); + getStatus.mockImplementation(async () => ({ + id: "", + isGMPTransaction: true, + routeStatus: [], + squidTransactionStatus: "", + status: "ongoing" + })); + getStatusAxelarScan.mockImplementation(async () => undefined as unknown); +}); + +function makeQuote(fromNetwork: Networks = Networks.Arbitrum) { + return { + metadata: { + blocks: { + squidRouterSwap: { + fromNetwork, + fromToken: "0x1111111111111111111111111111111111111111", + inputAmountRaw: "1000000", + outputAmountRaw: "1000000", + toNetwork: Networks.Base, + toToken: "0x2222222222222222222222222222222222222222" + } + } + }, + outputCurrency: "USDC", + to: Networks.Base + } as unknown as QuoteTicket; +} + +function makeState(stateOverrides: Record = {}) { + return { + errorLogs: [], + id: "block-ramp-1", + phaseHistory: [], + state: { + blockState: { squidRouterSwap: { quoteId: "block-squid-quote" } }, + squidRouterPayTxHash: "0xinitialpay", + squidRouterSwapHash: SWAP_HASH, + ...stateOverrides + } + } as unknown as RampState; +} + +const FEE_STATUS = { + call: { chain: "arbitrum" }, + fees: { + execute_gas_multiplier: 1.1, + source_base_fee: 0.01, + source_token: { gas_price: "0.00000002", gas_price_in_units: { decimals: 18, value: "20000000000" } } + }, + id: `${SWAP_HASH}_55_172`, + is_insufficient_fee: true, + status: "called" +}; + +describe("SquidRouterPayExecutor reliability", () => { + it("fails recoverably when the configured bridge polling deadline expires", async () => { + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.initialDelayMs = 0; + + const execution = handler.checkBridgeStatus(makeState(), SWAP_HASH, makeQuote(), 0); + + await expect(execution).rejects.toMatchObject({ isRecoverable: true }); + await expect(execution).rejects.toThrow("Bridge status check timed out after 0ms"); + }); + + it("uses the block quote ID and fresh timeout signals for Squid and Axelar fallback requests", async () => { + const requestSignals: AbortSignal[] = []; + getStatus.mockImplementationOnce(async (...args: unknown[]) => { + requestSignals.push(args[4] as AbortSignal); + throw new Error("squid unavailable"); + }); + getStatusAxelarScan.mockImplementationOnce(async (...args: unknown[]) => { + requestSignals.push(args[1] as AbortSignal); + return { id: `${SWAP_HASH}_55_172`, status: "executed" } as never; + }); + + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + const status = await handler.getSquidrouterStatus(SWAP_HASH, makeState(), makeQuote()); + + expect(status.status).toBe("success"); + expect(getStatus).toHaveBeenCalledWith(SWAP_HASH, "42161", "8453", "block-squid-quote", requestSignals[0]); + expect(requestSignals[0]).toBeInstanceOf(AbortSignal); + expect(requestSignals[1]).toBeInstanceOf(AbortSignal); + expect(requestSignals[0]).not.toBe(requestSignals[1]); + expect(status.evidenceProvider).toBe("axelar"); + }); + + it("persists a route-scoped 90% balance fallback instead of accepting any positive balance", async () => { + const quote = makeQuote(); + const tokenDetails = sharedReal.getOnChainTokenDetails(Networks.Base, quote.outputCurrency as never) as { + erc20AddressSourceChain: string; + }; + const address = "0x2222222222222222222222222222222222222222"; + const baselineKey = settlementBalanceKey(Networks.Base, address, tokenDetails.erc20AddressSourceChain); + const state = makeState({ + evmEphemeralAddress: address, + transactionPlan: { settlementBaselines: { [baselineKey]: "100" } } + }); + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.initialDelayMs = 60_000; + handler.patchStateKey = mock(async (target: RampState, key: string, value: unknown) => { + target.state = { ...target.state, [key]: value }; + return 1; + }); + + await handler.checkStatus(state, SWAP_HASH, quote); + + expect(checkEvmBalanceForToken).toHaveBeenCalledWith( + expect.objectContaining({ + amountDesiredRaw: "900100", + chain: Networks.Base, + ownerAddress: address + }) + ); + expect(state.state.squidRouterDeliveryEvidence).toMatchObject({ + baselineRaw: "100", + expectedAmountRaw: "1000000", + kind: "destination-balance", + minimumRatioBps: 9000, + sourceTransactionHash: SWAP_HASH + }); + }); + + it("persists the initial payment hash with a single-key patch", async () => { + getStatusAxelarScan + .mockImplementationOnce(async () => FEE_STATUS as never) + .mockImplementationOnce(async () => ({ ...FEE_STATUS, status: "executed" }) as never); + + const state = makeState({ squidRouterPayTxHash: undefined }); + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.initialDelayMs = 0; + handler.pollIntervalMs = 0; + handler.stuckAlertThresholdMs = Number.POSITIVE_INFINITY; + handler.executeFundTransaction = mock(async () => "0xgasfunding"); + handler.createSubsidy = mock(async () => undefined); + handler.patchStateKey = mock(async (target: RampState, key: string, value: string) => { + target.state = { ...target.state, [key]: value }; + return 1; + }); + + await handler.checkBridgeStatus(state, SWAP_HASH, makeQuote(), 1000); + + expect(handler.patchStateKey).toHaveBeenCalledWith(state, "squidRouterPayTxHash", "0xgasfunding"); + expect(state.state.squidRouterPayTxHash).toBe("0xgasfunding"); + }); + + it("atomically claims and sends at most one supplemental gas top-up on the block source chain", async () => { + const state = makeState(); + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.executeFundTransaction = mock(async () => "0xtopup"); + handler.patchStateKey = mock(async (target: RampState, key: string, value: string) => { + target.state = { ...target.state, [key]: value }; + return 1; + }); + + const first = await handler.maybeTopUpGas(state, SWAP_HASH, makeQuote(), FEE_STATUS); + const second = await handler.maybeTopUpGas(state, SWAP_HASH, makeQuote(), FEE_STATUS); + + expect(first).toContain("0xtopup"); + expect(second).toContain("already sent"); + expect(handler.patchStateKey).toHaveBeenNthCalledWith( + 1, + state, + "squidRouterExtraGasTxHash", + "pending", + `state->>'squidRouterExtraGasTxHash' IS NULL` + ); + expect(handler.executeFundTransaction).toHaveBeenCalledTimes(1); + expect(handler.executeFundTransaction.mock.calls[0]?.[1]).toBe(Networks.Arbitrum); + }); + + it("records stuck-confirm recovery before broadcasting and honors its cooldown", async () => { + const state = makeState(); + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.patchStateKey = mock(async (target: RampState, key: string, value: string) => { + target.state = { ...target.state, [key]: value }; + return 1; + }); + + const first = await handler.maybeRecoverStuckConfirm(state, SWAP_HASH, "arbitrum"); + const second = await handler.maybeRecoverStuckConfirm(state, SWAP_HASH, "arbitrum"); + + expect(first).toContain("AXELAR_RECOVERY_HASH"); + expect(second).toContain("on cooldown"); + expect(handler.patchStateKey).toHaveBeenCalledTimes(1); + expect(recoverAxelarStuckConfirm).toHaveBeenCalledTimes(1); + }); + + it("alerts once with block context and the current stuck classification", async () => { + const state = makeState(); + state.phaseHistory = [{ phase: "squidRouterPay", timestamp: new Date(Date.now() - 30 * 60 * 1000) }]; + state.errorLogs = [{ error: "Bridge status check timed out", phase: "squidRouterPay", timestamp: new Date().toISOString() }]; + const sendMessage = mock(async (_message: { text: string }) => undefined); + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.slackNotifier = { sendMessage }; + handler.patchStateKey = mock(async (target: RampState, key: string, value: string) => { + target.state = { ...target.state, [key]: value }; + return 1; + }); + handler.maybeRecoverStuckConfirm = mock(async () => "confirm recovery on cooldown"); + + await handler.monitorStuckGmp(state, SWAP_HASH, makeQuote(), { + call: { chain: "arbitrum" }, + id: `${SWAP_HASH}_55_172`, + status: "called" + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + const text = sendMessage.mock.calls[0]![0].text; + expect(text).toContain("classification: waiting_source_confirmation"); + expect(text).toContain("block-ramp-1"); + expect(text).toContain("block-squid-quote"); + expect(text).toContain("Bridge status check timed out"); + }); + + it("suppresses a stuck alert when another execution claims the alert slot", async () => { + const sendMessage = mock(async (_message: { text: string }) => undefined); + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.stuckAlertThresholdMs = 0; + handler.slackNotifier = { sendMessage }; + handler.patchStateKey = mock(async () => 0); + handler.maybeRecoverStuckConfirm = mock(async () => "already attempted"); + + await handler.monitorStuckGmp(makeState(), SWAP_HASH, makeQuote(), { + call: { chain: "arbitrum" }, + id: `${SWAP_HASH}_55_172`, + status: "called" + }); + + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("stops bridge polling when the processor aborts the signal", async () => { + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.initialDelayMs = 1000; + handler.pollIntervalMs = 1000; + const controller = new AbortController(); + + const execution = handler.checkBridgeStatus(makeState(), SWAP_HASH, makeQuote(), 5000, controller.signal); + controller.abort(new Error("phase timed out")); + + await expect(execution).rejects.toThrow(); + expect(getStatus).not.toHaveBeenCalled(); + }); + + it("uses the configured EIP-1559 multipliers for Polygon and Base gas payments", async () => { + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + + await handler.executeFundTransaction(makeState(), Networks.Polygon, "1", SWAP_HASH, 1, "initial-gas-payment"); + expect(sendTransaction.mock.calls[0]?.[0]).toMatchObject({ maxFeePerGas: 10n, maxPriorityFeePerGas: 3n }); + + await handler.executeFundTransaction(makeState(), Networks.Base, "1", SWAP_HASH, 1, "initial-gas-payment"); + expect(sendTransaction.mock.calls[1]?.[0]).toMatchObject({ maxFeePerGas: 20n, maxPriorityFeePerGas: 6n }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/squid-router-swap.executor.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/squid-router-swap.executor.test.ts new file mode 100644 index 000000000..89ade175b --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/squid-router-swap.executor.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { EvmToken, Networks } from "@vortexfi/shared"; +import Big from "big.js"; +import type RampState from "../../../../../models/rampState.model"; +import * as quoteTicketNamespace from "../../../../../models/quoteTicket.model"; +import { settlementBalanceKey } from "../core/settlement"; + +const sharedReal = { ...sharedNamespace }; +const quoteTicketReal = { ...quoteTicketNamespace }; +const sendRawTransactionMock = mock(async () => "0xunexpected"); +const waitForTransactionReceiptMock = mock(async () => ({ status: "success" as const })); +const getEvmBalanceMock = mock(async () => new Big("999")); +const findQuoteMock = mock(async () => undefined as unknown); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + checkEvmBalanceForToken: mock(async () => new Big("1000000")), + getEvmBalance: getEvmBalanceMock, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + sendRawTransaction: sendRawTransactionMock, + waitForTransactionReceipt: waitForTransactionReceiptMock + }) + }) + } +})); + +mock.module("../../../../../models/quoteTicket.model", () => ({ + ...quoteTicketReal, + default: { findByPk: findQuoteMock } +})); +const { SquidRouterSwapExecutor } = await import("../phases/squid-router-swap/execution"); + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../../../../../models/quoteTicket.model", () => ({ ...quoteTicketReal })); +}); + +beforeEach(() => { + sendRawTransactionMock.mockClear(); + waitForTransactionReceiptMock.mockClear(); + findQuoteMock.mockClear(); + getEvmBalanceMock.mockClear(); +}); + +describe("SquidRouterSwapExecutor", () => { + it("waits for a persisted swap hash without broadcasting the swap again", async () => { + const ephemeralAddress = "0x3434343434343434343434343434343434343434"; + const approveHash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const swapHash = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const sourceToken = sharedReal.getOnChainTokenDetails(Networks.Polygon, EvmToken.USDT); + const destinationToken = sharedReal.getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC); + if (!sourceToken || sourceToken.type !== "evm" || !destinationToken || destinationToken.type !== "evm") { + throw new Error("Polygon token configuration missing for SquidRouterSwapExecutor test"); + } + + findQuoteMock.mockResolvedValue({ + metadata: { + blocks: { + squidRouterSwap: { + fromNetwork: Networks.Polygon, + fromToken: sourceToken.erc20AddressSourceChain, + inputAmountRaw: "1000000", + toNetwork: Networks.Polygon, + toToken: destinationToken.erc20AddressSourceChain + } + } + }, + outputCurrency: EvmToken.USDC + }); + + const state = { + currentPhase: "squidRouterSwap", + id: "ramp-with-persisted-swap", + presignedTxs: [ + { network: Networks.Polygon, nonce: 0, phase: "squidRouterApprove", signer: ephemeralAddress, txData: "0xapprove" }, + { network: Networks.Polygon, nonce: 1, phase: "squidRouterSwap", signer: ephemeralAddress, txData: "0xswap" } + ], + state: { + evmEphemeralAddress: ephemeralAddress, + squidRouterApproveHash: approveHash, + squidRouterSwapHash: swapHash, + transactionPlan: { + settlementBaselines: { + [settlementBalanceKey(Networks.Polygon, ephemeralAddress, destinationToken.erc20AddressSourceChain)]: "0" + } + } + }, + update: mock(async () => state) + } as unknown as RampState; + + const result = await new SquidRouterSwapExecutor().execute(state); + + expect(result).toBe(state); + expect(sendRawTransactionMock).not.toHaveBeenCalled(); + expect(waitForTransactionReceiptMock).toHaveBeenCalledTimes(2); + expect(waitForTransactionReceiptMock).toHaveBeenNthCalledWith(1, { hash: approveHash }); + expect(waitForTransactionReceiptMock).toHaveBeenNthCalledWith(2, { hash: swapHash }); + expect(getEvmBalanceMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/squidrouter-quote-route.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/squidrouter-quote-route.test.ts new file mode 100644 index 000000000..978f9b004 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/squidrouter-quote-route.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import { + EvmToken, + evmTokenConfig, + getNetworkId, + Networks, +} from "@vortexfi/shared"; +import { + prepareSquidrouterRouteParams, + type SquidrouterQuoteRouteParams, +} from "../core/squidrouter-route"; + +const SELL_ROUTE_CASES: Array<{ + label: string; + params: SquidrouterQuoteRouteParams; +}> = [ + { + label: "Base settlement used by BRL and EUR offramps", + params: { + amountRaw: "100000000", + fromNetwork: Networks.Base, + fromToken: + evmTokenConfig[Networks.Base][EvmToken.EURC]!.erc20AddressSourceChain, + toToken: + evmTokenConfig[Networks.Base][EvmToken.USDC]!.erc20AddressSourceChain, + toNetwork: Networks.Base, + }, + }, + { + label: "Polygon settlement used by Alfredpay offramps", + params: { + amountRaw: "100000000", + fromNetwork: Networks.Base, + fromToken: + evmTokenConfig[Networks.Base][EvmToken.USDC]!.erc20AddressSourceChain, + toToken: + evmTokenConfig[Networks.Polygon][EvmToken.USDT]! + .erc20AddressSourceChain, + toNetwork: Networks.Polygon, + }, + }, +]; + +describe("SquidRouter SELL quote routes", () => { + it.each(SELL_ROUTE_CASES)("routes to the requested $label", ({ params }) => { + const route = prepareSquidrouterRouteParams(params); + + expect(route.fromChain).toBe(getNetworkId(params.fromNetwork).toString()); + expect(route.fromToken.toLowerCase()).toBe(params.fromToken.toLowerCase()); + expect(route.toChain).toBe(getNetworkId(params.toNetwork).toString()); + expect(route.toToken.toLowerCase()).toBe(params.toToken.toLowerCase()); + expect(route.toChain).not.toBe(getNetworkId(Networks.Moonbeam).toString()); + expect(route.postHook).toBeUndefined(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/wiring.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/wiring.test.ts new file mode 100644 index 000000000..da0042f55 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/wiring.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, mock } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import { APIError } from "../../../../errors/api-error"; + + +const { BlockInitialExecutor } = await import("../core/initial-executor"); +const { getBlockExecutorFlows, resolveBlockFlow } = await import("../flows/catalog"); +const { getBlockFlowHandlers } = await import("../register-handlers"); +const { PhaseProcessor } = await import("../../phase-processor"); + +const mappedRequest = { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum +}; + +describe("block flow production wiring", () => { + it("resolves a mapped request to its destination-specific flow", () => { + const flow = resolveBlockFlow(mappedRequest); + expect(flow.name).toBe("BrlOnrampBaseCrossChain"); + expect(flow.phases).toEqual(getBlockExecutorFlows().find(candidate => candidate.name === flow.name)!.phases); + }); + + it("resolves Alfredpay cross-chain requests", () => { + const flow = resolveBlockFlow({ + ...mappedRequest, + from: EPaymentMethod.SPEI, + inputCurrency: FiatToken.MXN + }); + expect(flow.name).toBe("AlfredpayOnrampCrossChain"); + }); + + it("resolves non-Base SEPA EUR onramps to the Mykobo cross-chain flow", () => { + const flow = resolveBlockFlow({ + ...mappedRequest, + from: EPaymentMethod.SEPA, + inputCurrency: FiatToken.EURC + }); + expect(flow.name).toBe("EurOnrampBaseCrossChain"); + expect(() => + resolveBlockFlow({ + ...mappedRequest, + from: EPaymentMethod.ACH, + inputCurrency: FiatToken.EURC + }) + ).toThrow(APIError); + }); + + it("resolves every supported EUR Base output to its exact non-overlapping flow", () => { + const eurBaseRequest = { + ...mappedRequest, + from: EPaymentMethod.SEPA, + inputCurrency: FiatToken.EURC, + network: Networks.Base, + to: Networks.Base + }; + expect(resolveBlockFlow({ ...eurBaseRequest, outputCurrency: EvmToken.EURC }).name).toBe("EurOnrampBaseDirect"); + expect(resolveBlockFlow({ ...eurBaseRequest, outputCurrency: EvmToken.USDC }).name).toBe("EurOnrampBaseSameChain"); + for (const outputCurrency of [EvmToken.USDT, EvmToken.ETH, EvmToken.AXLUSDC, EvmToken.BRLA]) { + expect(resolveBlockFlow({ ...eurBaseRequest, outputCurrency }).name).toBe("EurOnrampBaseSameChainSwap"); + } + expect(() => resolveBlockFlow({ ...eurBaseRequest, from: EPaymentMethod.ACH, outputCurrency: EvmToken.USDC })).toThrow( + APIError + ); + }); + + it("resolves both Alfredpay Polygon variants to the direct flow family", () => { + for (const outputCurrency of [EvmToken.USDT, EvmToken.USDC]) { + const flow = resolveBlockFlow({ + ...mappedRequest, + from: EPaymentMethod.SPEI, + inputCurrency: FiatToken.MXN, + network: Networks.Polygon, + outputCurrency, + to: Networks.Polygon + }); + expect(flow.name).toBe("AlfredpayOnrampDirect"); + } + }); + + it("resolves every supported BRL Base output to its exact static flow", () => { + expect(resolveBlockFlow({ ...mappedRequest, network: Networks.Base, to: Networks.Base }).name).toBe( + "BrlOnrampBaseSameChain" + ); + for (const outputCurrency of [EvmToken.USDT, EvmToken.ETH, EvmToken.AXLUSDC, EvmToken.EURC]) { + expect(resolveBlockFlow({ ...mappedRequest, network: Networks.Base, outputCurrency, to: Networks.Base }).name).toBe( + "BrlOnrampBaseSameChainSwap" + ); + } + }); + + it("rejects Base requests outside the exact BRL PIX predicates", () => { + expect(() => + resolveBlockFlow({ ...mappedRequest, from: EPaymentMethod.SPEI, network: Networks.Base, to: Networks.Base }) + ).toThrow(APIError); + }); + + it("resolves BRL to BRLA on Base to the direct flow only", () => { + const flow = resolveBlockFlow({ + ...mappedRequest, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + to: Networks.Base + }); + expect(flow.name).toBe("BrlOnrampBaseDirect"); + expect(flow.phases).toEqual(["brlaOnrampMint", "fundEphemeral", "destinationTransfer"]); + }); + + it("rejects mismatched direct-flow payment rails", () => { + expect(() => + resolveBlockFlow({ + ...mappedRequest, + from: EPaymentMethod.SPEI, + network: Networks.Base, + outputCurrency: EvmToken.BRLA, + to: Networks.Base + }) + ).toThrow(APIError); + expect(() => + resolveBlockFlow({ + ...mappedRequest, + from: EPaymentMethod.ACH, + inputCurrency: FiatToken.MXN, + network: Networks.Polygon, + outputCurrency: EvmToken.USDT, + to: Networks.Polygon + }) + ).toThrow(APIError); + }); + + it("rejects unsupported Alfredpay Polygon outputs during catalog resolution", () => { + expect(() => + resolveBlockFlow({ + ...mappedRequest, + from: EPaymentMethod.SPEI, + inputCurrency: FiatToken.MXN, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + to: Networks.Polygon + }) + ).toThrow(APIError); + }); + + it("derives one non-conflicting executor per phase from the catalog", () => { + const handlers = getBlockFlowHandlers(); + const phases = handlers.map(handler => handler.getPhaseName()); + expect(handlers[0]).toBeInstanceOf(BlockInitialExecutor); + expect(new Set(phases).size).toBe(phases.length); + expect(phases).toEqual([ + "initial", + ...new Set(getBlockExecutorFlows().flatMap(flow => flow.phases)) + ]); + }); + + it("rejects handler shortcuts outside the persisted flow transition graph", () => { + const flow = resolveBlockFlow(mappedRequest); + const originalPhase = flow.phases[0]; + const state = { + id: "ramp-1", + state: { + flow: flow.identity, + phaseFlow: ["initial", ...flow.phases, "complete"] + } + }; + const processor = new PhaseProcessor() as unknown as { + resolveNextPhase(original: string, result: { currentPhase: string }, state: unknown): string; + }; + + expect(() => processor.resolveNextPhase(originalPhase, { currentPhase: "complete" }, state)).toThrow( + "is not allowed" + ); + expect(processor.resolveNextPhase(originalPhase, { currentPhase: "failed" }, state)).toBe("failed"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/core/accounts.ts b/apps/api/src/api/services/phases/blocks/core/accounts.ts new file mode 100644 index 000000000..9c31bf67c --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/accounts.ts @@ -0,0 +1,17 @@ +import { type AccountMeta, EphemeralAccountType } from "@vortexfi/shared"; +import type { AccountCapabilities } from "./types"; + +export function accountCapabilities(accounts: readonly AccountMeta[]): AccountCapabilities { + return Object.fromEntries(accounts.map(account => [account.type, account])); +} + +export function requireAccount( + accounts: AccountCapabilities, + type: Type +): AccountMeta & { type: Type } { + const account = accounts[type]; + if (!account) { + throw new Error(`Block flow transaction preparation requires a ${type} ephemeral account`); + } + return account as AccountMeta & { type: Type }; +} diff --git a/apps/api/src/api/services/phases/blocks/core/avenia-registration.ts b/apps/api/src/api/services/phases/blocks/core/avenia-registration.ts new file mode 100644 index 000000000..0f456d850 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/avenia-registration.ts @@ -0,0 +1,204 @@ +import { + AveniaPaymentMethod, + BrlaApiService, + BrlaCurrency, + FiatToken, + generateReferenceLabel, + type Limit, + normalizeTaxId, + RampDirection, + validateMaskedNumber +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { Op } from "sequelize"; +import logger from "../../../../../config/logger"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import RampState from "../../../../../models/rampState.model"; +import { APIError } from "../../../../errors/api-error"; +import { findAveniaCustomerByTaxId } from "../../../avenia/avenia-customer.service"; +import { PriceFeedService } from "../../../priceFeed.service"; + +type AveniaApi = Pick< + BrlaApiService, + "createPayInQuote" | "createPixInputTicket" | "getSubaccountUsedLimit" | "subaccountInfo" | "validatePixKey" +>; + +interface PendingRamp { + quote?: { inputAmount: string; outputAmount: string }; +} + +export interface AveniaRegistrationDependencies { + aveniaApi: AveniaApi; + convertBrlToUsd: (amount: string) => Promise; + findAveniaCustomer: (taxId: string) => Promise<{ providerSubaccountId: string | null } | null>; + findPendingRamps: (taxId: string, direction: RampDirection) => Promise; +} + +function defaultDependencies(): AveniaRegistrationDependencies { + return { + aveniaApi: BrlaApiService.getInstance(), + convertBrlToUsd: amount => PriceFeedService.getInstance().convertCurrency(amount, FiatToken.BRL, FiatToken.USD, 2), + findAveniaCustomer: findAveniaCustomerByTaxId, + findPendingRamps: async (taxId, direction) => + RampState.findAll({ + include: [{ as: "quote", model: QuoteTicket }], + where: { + currentPhase: { [Op.notIn]: ["complete", "failed", "timedOut", "initial"] }, + "state.taxId": normalizeTaxId(taxId), + type: direction + } + }) as Promise + }; +} + +export async function getPendingBrlVolume( + taxId: string, + direction: RampDirection, + dependencies: AveniaRegistrationDependencies = defaultDependencies() +): Promise { + const pendingRamps = await dependencies.findPendingRamps(normalizeTaxId(taxId), direction); + let totalPendingBrl = new Big(0); + + for (const ramp of pendingRamps) { + if (!ramp.quote) continue; + totalPendingBrl = totalPendingBrl.plus(direction === RampDirection.BUY ? ramp.quote.inputAmount : ramp.quote.outputAmount); + } + + return totalPendingBrl; +} + +export async function validateAveniaLimits( + amountBrl: string, + limits: Limit[], + direction: RampDirection, + taxId: string, + dependencies: AveniaRegistrationDependencies = defaultDependencies() +): Promise { + const pendingBrl = await getPendingBrlVolume(taxId, direction, dependencies); + const effectiveAmountBrl = new Big(amountBrl).plus(pendingBrl); + const brlLimits = limits.find(limit => limit.currency === BrlaCurrency.BRL); + + if (!brlLimits) { + throw new APIError({ message: "BRL limits not found.", status: httpStatus.BAD_REQUEST }); + } + + const brlRemaining = + direction === RampDirection.BUY + ? Number(brlLimits.maxFiatIn) - Number(brlLimits.usedLimit.usedFiatIn) + : Number(brlLimits.maxFiatOut) - Number(brlLimits.usedLimit.usedFiatOut); + + if (effectiveAmountBrl.gt(brlRemaining)) { + throw new APIError({ message: "Amount exceeds BRL limit.", status: httpStatus.BAD_REQUEST }); + } + + const globalLimits = limits.find(limit => limit.currency === "*"); + if (!globalLimits) return; + + const effectiveAmountUsd = await dependencies.convertBrlToUsd(effectiveAmountBrl.toFixed(2)); + const globalRemaining = + direction === RampDirection.BUY + ? Number(globalLimits.maxFiatIn) - Number(globalLimits.usedLimit.usedFiatIn) + : Number(globalLimits.maxFiatOut) - Number(globalLimits.usedLimit.usedFiatOut); + + if (Number(effectiveAmountUsd) > globalRemaining) { + throw new APIError({ message: "Amount exceeds global limit.", status: httpStatus.BAD_REQUEST }); + } +} + +export async function createAveniaOnrampTicket( + taxId: string, + quote: { id: string }, + amount: string, + dependencies: AveniaRegistrationDependencies = defaultDependencies() +): Promise<{ brCode: string; aveniaTicketId: string }> { + const aveniaCustomer = await dependencies.findAveniaCustomer(taxId); + if (!aveniaCustomer) { + throw new APIError({ message: "Subaccount not found.", status: httpStatus.BAD_REQUEST }); + } + const subAccountId = aveniaCustomer.providerSubaccountId ?? ""; + const accountLimits = await dependencies.aveniaApi.getSubaccountUsedLimit(subAccountId); + if (!accountLimits) { + throw new APIError({ message: "Failed to fetch subaccount limits.", status: httpStatus.INTERNAL_SERVER_ERROR }); + } + + await validateAveniaLimits(amount, accountLimits.limitInfo.limits, RampDirection.BUY, taxId, dependencies); + const aveniaQuote = await dependencies.aveniaApi.createPayInQuote({ + inputAmount: String(amount), + inputCurrency: BrlaCurrency.BRL, + inputPaymentMethod: AveniaPaymentMethod.PIX, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.INTERNAL, + outputThirdParty: false, + subAccountId + }); + const ticket = await dependencies.aveniaApi.createPixInputTicket( + { + quoteToken: aveniaQuote.quoteToken, + ticketBlockchainOutput: { beneficiaryWalletId: "00000000-0000-0000-0000-000000000000" }, + ticketBrlPixInput: { additionalData: generateReferenceLabel(quote) } + }, + subAccountId + ); + + return { aveniaTicketId: ticket.id, brCode: ticket.brCode }; +} + +export async function validateAveniaOfframpRecipient( + taxId: string, + pixKey: string, + receiverTaxId: string, + amount: string, + dependencies: AveniaRegistrationDependencies = defaultDependencies() +): Promise<{ wallets: { evm: string }; brCode: string }> { + const aveniaCustomer = await dependencies.findAveniaCustomer(taxId); + if (!aveniaCustomer) { + throw new APIError({ message: "Subaccount not found", status: httpStatus.BAD_REQUEST }); + } + const subAccountId = aveniaCustomer.providerSubaccountId ?? ""; + const subaccount = await dependencies.aveniaApi.subaccountInfo(subAccountId); + const accountLimits = await dependencies.aveniaApi.getSubaccountUsedLimit(subAccountId); + if (!accountLimits) { + throw new APIError({ message: "Failed to fetch subaccount limits", status: httpStatus.INTERNAL_SERVER_ERROR }); + } + + let pixKeyData; + try { + pixKeyData = await dependencies.aveniaApi.validatePixKey(pixKey); + } catch (error) { + logger.warn( + `validateAveniaOfframpRecipient: pix-info lookup failed for pixKey=${pixKey}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + throw new APIError({ message: "Invalid pixKey or receiverTaxId.", status: httpStatus.BAD_REQUEST }); + } + + let masksMatch: boolean; + try { + masksMatch = validateMaskedNumber(pixKeyData.taxId, normalizeTaxId(receiverTaxId)); + } catch (error) { + logger.warn( + `validateAveniaOfframpRecipient: pix key owner taxId is not comparable to receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId(receiverTaxId)}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + throw new APIError({ message: "Invalid pixKey or receiverTaxId.", status: httpStatus.BAD_REQUEST }); + } + + if (!masksMatch) { + logger.warn( + `validateAveniaOfframpRecipient: pix key owner taxId does not match receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId(receiverTaxId)}` + ); + throw new APIError({ message: "Invalid pixKey or receiverTaxId.", status: httpStatus.BAD_REQUEST }); + } + + await validateAveniaLimits(amount, accountLimits.limitInfo.limits, RampDirection.SELL, taxId, dependencies); + const evmAddress = subaccount?.wallets.find(wallet => wallet.chain === "EVM")?.walletAddress; + if (!evmAddress) { + throw new APIError({ message: "EVM wallet not found in subaccount.", status: httpStatus.INTERNAL_SERVER_ERROR }); + } + + return { brCode: subaccount.brCode, wallets: { evm: evmAddress } }; +} diff --git a/apps/api/src/api/services/phases/blocks/core/cancellation.ts b/apps/api/src/api/services/phases/blocks/core/cancellation.ts new file mode 100644 index 000000000..523f823c0 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/cancellation.ts @@ -0,0 +1,30 @@ +function abortError(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error("Phase execution aborted"); +} + +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortError(signal); + } +} + +/** + * Stop awaiting an API/RPC operation when the phase is abandoned. This cannot + * cancel transports that do not expose AbortSignal support, but it prevents + * the abandoned phase from performing any subsequent work or side effect. + */ +export function abortableCall(signal: AbortSignal | undefined, call: () => Promise): Promise { + throwIfAborted(signal); + if (!signal) return call(); + + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + + call() + .then(resolve, reject) + .finally(() => { + signal.removeEventListener("abort", onAbort); + }); + }); +} diff --git a/apps/api/src/api/services/phases/blocks/core/combinators.ts b/apps/api/src/api/services/phases/blocks/core/combinators.ts new file mode 100644 index 000000000..6733bb4e9 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/combinators.ts @@ -0,0 +1,50 @@ +import type { RampPhase } from "@vortexfi/shared"; +import type { AnyContextMetadata, ContextSimulation } from "./metadata"; +import type { ChainBrand, Phase, PhaseCtx, PhaseIO, TokenBrand } from "./types"; + +export function branch( + context: Context, + select: (ctx: PhaseCtx) => Promise | number, + branches: [Phase, ...Phase[]] +): Phase { + const unionPhases: RampPhase[] = []; + const seen = new Set(); + for (const branchPhase of branches) { + if (branchPhase.context.key !== context.key) { + throw new Error(`branch: expected metadata key ${context.key}, received ${branchPhase.context.key}`); + } + for (const phase of branchPhase.phases) { + if (!seen.has(phase)) { + seen.add(phase); + unionPhases.push(phase); + } + } + } + return { + context, + name: "branch", + phases: unionPhases, + async simulate(input: I, ctx: PhaseCtx) { + const index = await select(ctx); + const chosen = branches[index]; + if (!chosen) { + throw new Error(`branch: select returned ${index}, no branch at that index`); + } + return chosen.simulate(input, ctx); + } + }; +} + +export function passthrough( + context: Context, + metadata: ContextSimulation +): Phase, PhaseIO> { + return { + context, + name: "passthrough", + phases: [], + async simulate(input: PhaseIO) { + return { metadata, output: input }; + } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts new file mode 100644 index 000000000..6b9e68e67 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test"; +import { Op } from "sequelize"; +import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../../../../constants/constants"; +import { getPersistedBlockFlowCompatibilityScope } from "./compatibility-scope"; + +describe("persisted block-flow compatibility scope", () => { + it("scopes pending quotes and resumable ramps to the current flow variant", () => { + const now = new Date("2026-07-31T12:00:00.000Z"); + const initialRampCutoff = new Date(now.getTime() - RAMP_START_EXPIRATION_TIME_SECONDS * 1000); + + expect(getPersistedBlockFlowCompatibilityScope("mykobo", now)).toEqual({ + pendingQuoteWhere: { + expiresAt: { [Op.gt]: now }, + flowVariant: "mykobo", + status: "pending" + }, + resumableRampWhere: { + flowVariant: "mykobo", + [Op.or]: [ + { currentPhase: { [Op.notIn]: ["complete", "failed", "timedOut", "initial"] } }, + { createdAt: { [Op.gte]: initialRampCutoff }, currentPhase: "initial" } + ] + } + }); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts new file mode 100644 index 000000000..4cdeb1019 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts @@ -0,0 +1,34 @@ +import { Op } from "sequelize"; +import type { FlowVariant } from "../../../../../config/vars"; +import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../../../../constants/constants"; + +const TERMINAL_RAMP_PHASES = ["complete", "failed", "timedOut"] as const; + +/** + * Selects only persisted state that this backend could still execute. + * + * A registered ramp remains in `initial` until startRamp is called. Both updateRamp + * and startRamp reject it after the shared expiration window, before either can run + * the persisted-flow lifecycle. Older initial rows therefore cannot be resumed and + * must not make a later deployment depend on legacy quote metadata. Once a ramp has + * entered a financial phase, age never makes it safe to ignore: every non-terminal + * phase owned by this flow variant stays fail-closed. + */ +export function getPersistedBlockFlowCompatibilityScope(flowVariant: FlowVariant, now = new Date()) { + const initialRampCutoff = new Date(now.getTime() - RAMP_START_EXPIRATION_TIME_SECONDS * 1000); + + return { + pendingQuoteWhere: { + expiresAt: { [Op.gt]: now }, + flowVariant, + status: "pending" as const + }, + resumableRampWhere: { + flowVariant, + [Op.or]: [ + { currentPhase: { [Op.notIn]: [...TERMINAL_RAMP_PHASES, "initial"] } }, + { createdAt: { [Op.gte]: initialRampCutoff }, currentPhase: "initial" } + ] + } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts b/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts new file mode 100644 index 000000000..7ca1e8e2b --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { Networks } from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; +import { UnrecoverablePhaseError } from "../../../../errors/phase-error"; +import { ensurePresignedTransferFunded } from "./destination-funding"; + +const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); +const recipient = "0x0000000000000000000000000000000000000001"; + +describe("ensurePresignedTransferFunded", () => { + it("fails unrecoverably when a server-generated transaction cannot be parsed", async () => { + await expect(ensurePresignedTransferFunded("0xdead", Networks.Base, "testPayout")).rejects.toBeInstanceOf( + UnrecoverablePhaseError + ); + }); + + it("fails unrecoverably for a zero-value payout instead of broadcasting it", async () => { + const rawTx = await account.signTransaction({ + chainId: 8453, + gas: 21_000n, + gasPrice: 1n, + nonce: 0, + to: recipient, + value: 0n + }); + + await expect(ensurePresignedTransferFunded(rawTx, Networks.Base, "testPayout")).rejects.toBeInstanceOf( + UnrecoverablePhaseError + ); + }); +}); diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/blocks/core/destination-funding.ts similarity index 54% rename from apps/api/src/api/services/phases/handlers/helpers.ts rename to apps/api/src/api/services/phases/blocks/core/destination-funding.ts index 6575e364b..4189a751f 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/blocks/core/destination-funding.ts @@ -4,42 +4,39 @@ import { checkEvmNativeBalancePeriodically, EvmClientManager, EvmNetworks, - Networks as VortexNetworks + Networks } 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 logger from "../../../../../config/logger"; import { BASE_EPHEMERAL_STARTING_BALANCE_UNITS, GLMR_FUNDING_AMOUNT_RAW, PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS -} from "../../../../constants/constants"; -import { multiplyByPowerOfTen } from "../../pendulum/helpers"; +} from "../../../../../constants/constants"; +import { UnrecoverablePhaseError } from "../../../../errors/phase-error"; +import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; export async function isPendulumEphemeralFunded(pendulumEphemeralAddress: string, pendulumNode: API): Promise { const fundingAmountUnits = Big(PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS); const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, pendulumNode.decimals).toFixed(); - //@ts-ignore + // @ts-ignore const { data: balance } = await pendulumNode.api.query.system.account(pendulumEphemeralAddress); return Big(balance.free.toString()).gte(fundingAmountRaw); } export async function isMoonbeamEphemeralFunded(moonbeamEphemeralAddress: string, moonbeamNode: API): Promise { - //@ts-ignore + // @ts-ignore const { data: balance } = await moonbeamNode.api.query.system.account(moonbeamEphemeralAddress); return Big(balance.free.toString()).gte(GLMR_FUNDING_AMOUNT_RAW); } export async function isBaseEphemeralFunded(baseEphemeralAddress: string): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const baseClient = evmClientManager.getClient(VortexNetworks.Base); - - const balance = await baseClient.getBalance({ - address: baseEphemeralAddress as `0x${string}` - }); + const baseClient = EvmClientManager.getInstance().getClient(Networks.Base); + const balance = await baseClient.getBalance({ address: baseEphemeralAddress as `0x${string}` }); const fundingAmountRaw = new Big( multiplyByPowerOfTen(BASE_EPHEMERAL_STARTING_BALANCE_UNITS, base.nativeCurrency.decimals).toFixed() ); @@ -48,12 +45,8 @@ export async function isBaseEphemeralFunded(baseEphemeralAddress: string): Promi } export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const polygonClient = evmClientManager.getClient(VortexNetworks.Polygon); - - const balance = await polygonClient.getBalance({ - address: polygonEphemeralAddress as `0x${string}` - }); + const polygonClient = EvmClientManager.getInstance().getClient(Networks.Polygon); + const balance = await polygonClient.getBalance({ address: polygonEphemeralAddress as `0x${string}` }); const fundingAmountRaw = new Big( multiplyByPowerOfTen(POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, polygon.nativeCurrency.decimals).toFixed() ); @@ -61,40 +54,32 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): return Big(balance.toString()).gte(fundingAmountRaw); } -// Native-token funding amounts sent to the destination EVM ephemeral so it can pay -// gas for the final destination transfer. Threshold MUST match what is sent in -// `fundDestinationEvmEphemeralAccount`; otherwise the post-funding balance poll -// will spuriously succeed (or fail) and downstream phases may run with a -// short-funded ephemeral. export const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { - [VortexNetworks.Ethereum]: "0.00016", - [VortexNetworks.Arbitrum]: "0.000045", - [VortexNetworks.Base]: "0.000034", - [VortexNetworks.Polygon]: "0.6", - [VortexNetworks.BSC]: "0.000115", - [VortexNetworks.Avalanche]: "0.0034", - [VortexNetworks.Moonbeam]: "0.34", - [VortexNetworks.PolygonAmoy]: "0.2", - [VortexNetworks.BaseSepolia]: "0.000034" + [Networks.Ethereum]: "0.005", + [Networks.Arbitrum]: "0.0002", + [Networks.Base]: "0.000034", + [Networks.Polygon]: "0.6", + [Networks.BSC]: "0.000115", + [Networks.Avalanche]: "0.0034", + [Networks.Moonbeam]: "0.34", + [Networks.PolygonAmoy]: "0.2", + [Networks.BaseSepolia]: "0.000034" }; export async function isDestinationEvmEphemeralFunded( evmEphemeralAddress: string, destinationNetwork: EvmNetworks ): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const destinationClient = evmClientManager.getClient(destinationNetwork); + const destinationClient = EvmClientManager.getInstance().getClient(destinationNetwork); const chain = destinationClient.chain; if (!chain) { throw new Error(`isDestinationEvmEphemeralFunded: Could not get chain info for ${destinationNetwork}`); } - const balance = await destinationClient.getBalance({ - address: evmEphemeralAddress as `0x${string}` - }); - - const fundingAmountUnits = DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork]; - const fundingAmountRaw = new Big(multiplyByPowerOfTen(fundingAmountUnits, chain.nativeCurrency.decimals).toFixed()); + const balance = await destinationClient.getBalance({ address: evmEphemeralAddress as `0x${string}` }); + const fundingAmountRaw = new Big( + multiplyByPowerOfTen(DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork], chain.nativeCurrency.decimals).toFixed() + ); return Big(balance.toString()).gte(fundingAmountRaw); } @@ -102,18 +87,12 @@ export async function isDestinationEvmEphemeralFunded( 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 { +export async function ensurePresignedTransferFunded( + rawTx: `0x${string}`, + network: EvmNetworks, + phase: string, + signal?: AbortSignal +): Promise { let sender: `0x${string}`; let tokenAddress: `0x${string}` | undefined; let amountRaw: bigint; @@ -127,19 +106,20 @@ export async function ensurePresignedTransferFunded(rawTx: `0x${string}`, networ } 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; + throw new Error(`expected an ERC-20 transfer, got ${functionName}`); } 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; + logger.error(`${phase}: invalid server-generated presigned payout transfer - ${(error as Error).message}`); + throw new UnrecoverablePhaseError( + `${phase}: server-generated presigned payout transfer could not be validated: ${(error as Error).message}` + ); } if (amountRaw <= 0n) { - return; + throw new UnrecoverablePhaseError(`${phase}: server-generated presigned payout transfer has no positive value`); } if (tokenAddress) { @@ -149,7 +129,8 @@ export async function ensurePresignedTransferFunded(rawTx: `0x${string}`, networ amountRaw.toString(), PRESIGNED_TRANSFER_BALANCE_POLL_MS, PRESIGNED_TRANSFER_BALANCE_TIMEOUT_MS, - network + network, + signal ); } else { await checkEvmNativeBalancePeriodically( @@ -157,7 +138,8 @@ export async function ensurePresignedTransferFunded(rawTx: `0x${string}`, networ amountRaw.toString(), PRESIGNED_TRANSFER_BALANCE_POLL_MS, PRESIGNED_TRANSFER_BALANCE_TIMEOUT_MS, - network + network, + signal ); } } diff --git a/apps/api/src/api/services/quote/engines/discount/helpers.test.ts b/apps/api/src/api/services/phases/blocks/core/discount.test.ts similarity index 90% rename from apps/api/src/api/services/quote/engines/discount/helpers.test.ts rename to apps/api/src/api/services/phases/blocks/core/discount.test.ts index 66c844db0..a5779db70 100644 --- a/apps/api/src/api/services/quote/engines/discount/helpers.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/discount.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test"; import Big from "big.js"; import { priceFeedService } from "../../../priceFeed.service"; -import { QuoteContext } from "../../core/types"; -import { calculateExpectedOutput, calculateSubsidyAmount, getUsdDenominatedInputAmount } from "./helpers"; +import { QuoteContext } from "../../../quote/core/types"; +import { calculateExpectedOutput, calculateSubsidyAmount, getUsdDenominatedInputAmount } from "./discount"; describe("calculateSubsidyAmount", () => { it("returns 0 when actual output meets expected output", () => { @@ -15,9 +15,9 @@ describe("calculateSubsidyAmount", () => { expect(result.toString()).toBe("0"); }); - it("returns full shortfall when no maxSubsidy cap", () => { + it("returns 0 when maxSubsidy is disabled", () => { const result = calculateSubsidyAmount(new Big(100), new Big(90), 0); - expect(result.toString()).toBe("10"); + expect(result.toString()).toBe("0"); }); it("caps subsidy at maxSubsidy fraction of expected output", () => { @@ -119,11 +119,10 @@ describe("getUsdDenominatedInputAmount", () => { expect(usd.toString()).toBe("194.757138"); }); - it("falls back to the raw input when the fiat-peg rate lookup fails and no bridged amount exists", async () => { + it("fails when the fiat-peg rate lookup fails and no USD route amount exists", async () => { rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockRejectedValue(new Error("feed down")); - const usd = await getUsdDenominatedInputAmount(makeCtx("BRLA", "1000")); - expect(usd.toString()).toBe("1000"); + await expect(getUsdDenominatedInputAmount(makeCtx("BRLA", "1000"))).rejects.toThrow("Cannot value BRLA input in USD"); }); it("falls back to the bridged USDC amount for tokens without a fiat peg", async () => { @@ -131,8 +130,7 @@ describe("getUsdDenominatedInputAmount", () => { expect(usd.toString()).toBe("1834.201"); }); - it("falls back to the request amount when no bridged amount is available", async () => { - const usd = await getUsdDenominatedInputAmount(makeCtx("ETH", "0.5")); - expect(usd.toString()).toBe("0.5"); + it("fails for non-pegged input when no USD route amount is available", async () => { + await expect(getUsdDenominatedInputAmount(makeCtx("ETH", "0.5"))).rejects.toThrow("Cannot value ETH input in USD"); }); }); diff --git a/apps/api/src/api/services/quote/engines/discount/helpers.ts b/apps/api/src/api/services/phases/blocks/core/discount.ts similarity index 78% rename from apps/api/src/api/services/quote/engines/discount/helpers.ts rename to apps/api/src/api/services/phases/blocks/core/discount.ts index bcc1fe0f3..5ddefb3e9 100644 --- a/apps/api/src/api/services/quote/engines/discount/helpers.ts +++ b/apps/api/src/api/services/phases/blocks/core/discount.ts @@ -4,9 +4,8 @@ import logger from "../../../../../config/logger"; import { config } from "../../../../../config/vars"; import { findPartnerWithPricing, PartnerWithPricing } from "../../../partners/partner-pricing.service"; import { priceFeedService } from "../../../priceFeed.service"; -import { getTargetFiatCurrency } from "../../core/helpers"; -import { QuoteContext } from "../../core/types"; -import { DiscountComputation } from "./index"; +import { QuoteContext } from "../../../quote/core/types"; +import { getTargetFiatCurrency } from "./helpers"; export const DEFAULT_PARTNER_NAME = "vortex"; @@ -38,12 +37,6 @@ export type ActivePartner = { stateKey: string; } | null; -export interface DiscountSubsidyPayload { - actualOutputAmountDecimal: Big; - actualOutputAmountRaw: string; - expectedOutputAmountDecimal: Big; -} - export function toActivePartner(pricing: PartnerWithPricing): ActivePartner { return { id: pricing.id, @@ -65,7 +58,10 @@ export async function resolveActivePartnerById( return pricing ? toActivePartner(pricing) : null; } -export async function resolveDiscountPartner(ctx: QuoteContext, rampType: RampDirection): Promise { +export async function resolveDiscountPartner( + ctx: Pick, + rampType: RampDirection +): Promise { const partnerId = ctx.partner?.id; const fiatCurrency = getTargetFiatCurrency(rampType, ctx.request.inputCurrency, ctx.request.outputCurrency); @@ -98,11 +94,9 @@ const FIAT_PEG_BY_STABLECOIN: Record = { * USD amount by the inverted FIAT-USD oracle rate, but request.inputAmount is denominated * in the input token: USD-like stables pass through unchanged, fiat-pegged stables * (BRLA, EURC) are valued at their peg's FIAT-USD oracle rate, and any other token falls - * back to the bridged USDC amount when available. - * - * A rate-feed failure while valuing a fiat-pegged stable MUST NOT fail the quote: the - * engine already holds the bridged USDC amount, a good USD-denominated proxy, so we fall - * back to it (or the raw input as a last resort) rather than throwing from discount math. + * back to an independently computed bridged USDC amount when available. A raw non-USD + * input is never relabeled as USD; inability to establish the denomination fails quote + * creation. */ export async function getUsdDenominatedInputAmount(ctx: QuoteContext): Promise { const { inputAmount, inputCurrency } = ctx.request; @@ -134,7 +128,9 @@ function usdFallbackFromContext(ctx: QuoteContext): Big { if (ctx.evmToEvm?.outputAmountDecimal) { return ctx.evmToEvm.outputAmountDecimal; } - return new Big(ctx.request.inputAmount); + throw new Error( + `Cannot value ${ctx.request.inputCurrency} input in USD: no fresh rate or independently derived USD route amount` + ); } /** @@ -230,38 +226,11 @@ export function calculateSubsidyAmount(expectedOutput: Big, actualOutput: Big, m return new Big(0); } - const shortfall = expectedOutput.minus(actualOutput); - - // Cap at maxSubsidy if configured - const maxSubsidyBig = new Big(maxSubsidy); - if (maxSubsidy > 0) { - const maxAllowedSubsidy = expectedOutput.mul(maxSubsidyBig); - return shortfall.gt(maxAllowedSubsidy) ? maxAllowedSubsidy : shortfall; + if (maxSubsidy <= 0) { + return new Big(0); } - return shortfall; -} -export function buildDiscountSubsidy(computation: DiscountComputation): QuoteContext["subsidy"] { - // Trim to 6 decimal places for output token decimal representation - const subsidyAmountInOutputTokenDecimal = Big(computation.subsidyAmountInOutputTokenDecimal.toFixed(6, 0)); - const idealSubsidyAmountInOutputTokenDecimal = Big(computation.idealSubsidyAmountInOutputTokenDecimal.toFixed(6, 0)); - - return { - ...computation, - applied: computation.subsidyAmountInOutputTokenDecimal.gt(0), - idealSubsidyAmountInOutputTokenDecimal, - subsidyAmountInOutputTokenDecimal - }; -} - -export function formatPartnerNote(ctx: QuoteContext, computation: DiscountComputation): string { - const isCapped = computation.subsidyAmountInOutputTokenDecimal.lt(computation.idealSubsidyAmountInOutputTokenDecimal); - return ( - `partner=${computation.partnerId || DEFAULT_PARTNER_NAME}, ` + - `targetDiscount=${ctx.partner?.targetDiscount}, ` + - `maxSubsidy=${ctx.partner?.maxSubsidy}, ` + - `idealSubsidy=${computation.idealSubsidyAmountInOutputTokenDecimal.toString()}, ` + - `actualSubsidy=${computation.subsidyAmountInOutputTokenDecimal.toString()}` + - (isCapped ? " [CAPPED]" : "") - ); + const shortfall = expectedOutput.minus(actualOutput); + const maxAllowedSubsidy = expectedOutput.mul(maxSubsidy); + return shortfall.gt(maxAllowedSubsidy) ? maxAllowedSubsidy : shortfall; } diff --git a/apps/api/src/api/services/phases/evm-funding.ts b/apps/api/src/api/services/phases/blocks/core/evm-funding.ts similarity index 89% rename from apps/api/src/api/services/phases/evm-funding.ts rename to apps/api/src/api/services/phases/blocks/core/evm-funding.ts index 60aad4f08..ba11035a9 100644 --- a/apps/api/src/api/services/phases/evm-funding.ts +++ b/apps/api/src/api/services/phases/blocks/core/evm-funding.ts @@ -1,6 +1,6 @@ import { EvmNetworks } from "@vortexfi/shared"; import { type PrivateKeyAccount, privateKeyToAccount } from "viem/accounts"; -import { EVM_FUNDING_PRIVATE_KEY } from "../../../config/vars"; +import { EVM_FUNDING_PRIVATE_KEY } from "../../../../../config/vars"; let cachedAccount: PrivateKeyAccount | undefined; diff --git a/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts b/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts new file mode 100644 index 000000000..743396625 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts @@ -0,0 +1,82 @@ +import { EvmClientManager, type EvmNetworks, type EvmTransactionData } from "@vortexfi/shared"; +import { encodeFunctionData } from "viem/utils"; +import erc20ABI from "../../../../../contracts/ERC20"; + +export function encodeEvmTransactionData(data: unknown) { + return data; +} + +export async function prepareBaseCleanupApproval( + tokenAddress: `0x${string}`, + fundingAddress: string, + network: EvmNetworks +): Promise { + const approveCallData = encodeFunctionData({ + abi: erc20ABI, + args: [fundingAddress, (2n ** 256n - 1n).toString()], + functionName: "approve" + }); + const publicClient = EvmClientManager.getInstance().getClient(network); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + return { + data: approveCallData as `0x${string}`, + gas: "100000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: tokenAddress, + value: "0" + }; +} + +export async function createDestinationTransferTransaction(params: { + toAddress: string; + toToken: `0x${string}`; + amountRaw: string; + destinationNetwork: EvmNetworks; + isNativeToken?: boolean; +}): Promise { + const { toAddress, amountRaw, destinationNetwork, toToken, isNativeToken } = params; + const publicClient = EvmClientManager.getInstance().getClient(destinationNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + if (isNativeToken) { + return { + data: "0x", + gas: "21000", + maxFeePerGas: String(maxFeePerGas * 3n), + maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), + to: toAddress as `0x${string}`, + value: amountRaw + }; + } + + return { + data: encodeFunctionData({ abi: erc20ABI, args: [toAddress, amountRaw], functionName: "transfer" }), + gas: "100000", + maxFeePerGas: String(maxFeePerGas * 3n), + maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), + to: toToken, + value: "0" + }; +} + +export async function createDestinationApprovalTransaction(params: { + amountRaw: string; + spenderAddress: string; + tokenAddress: `0x${string}`; + destinationNetwork: EvmNetworks; +}): Promise { + const { amountRaw, spenderAddress, tokenAddress, destinationNetwork } = params; + const publicClient = EvmClientManager.getInstance().getClient(destinationNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + + return { + data: encodeFunctionData({ abi: erc20ABI, args: [spenderAddress, amountRaw], functionName: "approve" }), + gas: "100000", + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), + to: tokenAddress, + value: "0" + }; +} diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/phases/blocks/core/fee-distribution.ts similarity index 85% rename from apps/api/src/api/services/transactions/common/feeDistribution.ts rename to apps/api/src/api/services/phases/blocks/core/fee-distribution.ts index ec4fefc1f..ae430a2b8 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/phases/blocks/core/fee-distribution.ts @@ -1,5 +1,4 @@ import { - AccountMeta, ApiManager, EvmClientManager, EvmToken, @@ -10,20 +9,19 @@ import { Networks, PENDULUM_USDC_ASSETHUB, PENDULUM_USDC_AXL, - RampDirection, - UnsignedTx + RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import { encodeFunctionData } from "viem/utils"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import erc20ABI from "../../../../contracts/ERC20"; -import { MULTICALL3_ADDRESS, multicall3ABI } from "../../../../contracts/Multicall3"; -import { QuoteTicketAttributes } from "../../../../models/quoteTicket.model"; -import { findPartnerWithPricing } from "../../partners/partner-pricing.service"; -import { multiplyByPowerOfTen } from "../../pendulum/helpers"; -import { getTargetFiatCurrency } from "../../quote/core/helpers"; -import { getZenlinkIdForAsset } from "../../zenlink"; +import logger from "../../../../../config/logger"; +import { config } from "../../../../../config/vars"; +import erc20ABI from "../../../../../contracts/ERC20"; +import { MULTICALL3_ADDRESS, multicall3ABI } from "../../../../../contracts/Multicall3"; +import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import { findPartnerWithPricing } from "../../../partners/partner-pricing.service"; +import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; +import { getZenlinkIdForAsset } from "../../../zenlink"; +import { getTargetFiatCurrency } from "./helpers"; function getQuotePricingPartnerId(quote: QuoteTicketAttributes): string | null { return quote.pricingPartnerId ?? quote.partnerId ?? null; @@ -169,39 +167,6 @@ export async function createSubstrateFeeDistributionTransaction(quote: QuoteTick return null; } -/** - * Adds fee distribution transaction if available. - * Shared between onramp and offramp flows. - * - * @param quote Quote ticket - * @param account Account metadata - * @param unsignedTxs Array to add transactions to - * @param nextNonce Next available nonce - * @returns Updated nonce - */ -export async function addFeeDistributionTransaction( - quote: QuoteTicketAttributes, - account: AccountMeta, - unsignedTxs: UnsignedTx[], - nextNonce: number -): Promise { - const feeDistributionTx = await createSubstrateFeeDistributionTransaction(quote); - - if (feeDistributionTx) { - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: nextNonce, - phase: "distributeFees", - signer: account.address, - txData: feeDistributionTx - }); - nextNonce++; - } - - return nextNonce; -} - /** * Creates an EVM fee distribution transaction for Base network. * Splits fees: network + vortex fees go to vortex EVM payout address, @@ -363,35 +328,3 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr value: "0" }; } - -/** - * Adds EVM fee distribution transaction for Base network if available. - * - * @param quote Quote ticket - * @param account Account metadata - * @param unsignedTxs Array to add transactions to - * @param nextNonce Next available nonce - * @returns Updated nonce - */ -export async function addEvmFeeDistributionTransaction( - quote: QuoteTicketAttributes, - account: AccountMeta, - unsignedTxs: UnsignedTx[], - nextNonce: number -): Promise { - const feeDistributionTx = await createEvmFeeDistributionTransaction(quote); - - if (feeDistributionTx) { - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: nextNonce, - phase: "distributeFees", - signer: account.address, - txData: feeDistributionTx - }); - nextNonce++; - } - - return nextNonce; -} diff --git a/apps/api/src/api/services/phases/blocks/core/fees.ts b/apps/api/src/api/services/phases/blocks/core/fees.ts new file mode 100644 index 000000000..83e3be4df --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/fees.ts @@ -0,0 +1,99 @@ +import { EvmToken, RampCurrency } from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../priceFeed.service"; +import { calculateFeeComponents } from "./quote-fees"; +import type { PhaseCtx } from "./types"; + +interface FeeOverride { + anchor: { amount: string; currency: RampCurrency }; + network?: { amount: string; currency: RampCurrency }; +} + +export async function overrideFees(ctx: PhaseCtx, override: FeeOverride): Promise> { + if (!ctx.fees?.displayFiat || !ctx.fees.usd) { + throw new Error("Cannot override an incomplete fee snapshot"); + } + const displayCurrency = ctx.fees.displayFiat.currency; + const [anchorUsd, anchorDisplay, networkUsd, networkDisplay] = await Promise.all([ + priceFeedService.convertCurrency(override.anchor.amount, override.anchor.currency, EvmToken.USDC), + priceFeedService.convertCurrency(override.anchor.amount, override.anchor.currency, displayCurrency), + override.network + ? priceFeedService.convertCurrency(override.network.amount, override.network.currency, EvmToken.USDC) + : ctx.fees.usd.network, + override.network + ? priceFeedService.convertCurrency(override.network.amount, override.network.currency, displayCurrency) + : ctx.fees.displayFiat.network + ]); + return { + displayFiat: { + ...ctx.fees.displayFiat, + anchor: anchorDisplay, + network: networkDisplay, + total: new Big(anchorDisplay) + .plus(networkDisplay) + .plus(ctx.fees.displayFiat.partnerMarkup) + .plus(ctx.fees.displayFiat.vortex) + .toFixed(2) + }, + usd: { + ...ctx.fees.usd, + anchor: anchorUsd, + network: networkUsd, + total: new Big(anchorUsd).plus(networkUsd).plus(ctx.fees.usd.partnerMarkup).plus(ctx.fees.usd.vortex).toFixed(6) + } + }; +} + +export async function calculateFees(ctx: PhaseCtx, override?: FeeOverride): Promise> { + const { vortexFee, anchorFee, partnerMarkupFee, feeCurrency } = await calculateFeeComponents({ + from: ctx.request.from, + inputAmount: ctx.request.inputAmount, + inputCurrency: ctx.request.inputCurrency, + outputAmountOfframp: "0", + outputCurrency: ctx.request.outputCurrency, + partnerId: ctx.partner?.id || undefined, + rampType: ctx.request.rampType, + to: ctx.request.to + }); + + const USD = EvmToken.USDC as RampCurrency; + const displayCurrency = ctx.targetFeeFiatCurrency ?? feeCurrency; + const anchor = override?.anchor ?? { amount: anchorFee, currency: feeCurrency }; + const network = override?.network ?? { amount: "0", currency: USD }; + const [vortexUsd, anchorUsd, partnerUsd, networkUsd, vortexDisplay, anchorDisplay, partnerDisplay, networkDisplay] = + await Promise.all([ + priceFeedService.convertCurrency(vortexFee, feeCurrency, USD), + priceFeedService.convertCurrency(anchor.amount, anchor.currency, USD), + priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, USD), + priceFeedService.convertCurrency(network.amount, network.currency, USD), + priceFeedService.convertCurrency(vortexFee, feeCurrency, displayCurrency), + priceFeedService.convertCurrency(anchor.amount, anchor.currency, displayCurrency), + priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, displayCurrency), + priceFeedService.convertCurrency(network.amount, network.currency, displayCurrency) + ]); + + const totalUsd = new Big(vortexUsd).plus(anchorUsd).plus(partnerUsd).plus(networkUsd).toFixed(6); + const totalDisplay = new Big(vortexDisplay).plus(anchorDisplay).plus(partnerDisplay).plus(networkDisplay).toFixed(2); + + return { + displayFiat: { + anchor: anchorDisplay, + currency: displayCurrency, + network: networkDisplay, + partnerMarkup: partnerDisplay, + total: totalDisplay, + vortex: vortexDisplay + }, + usd: { + anchor: anchorUsd, + network: networkUsd, + partnerMarkup: partnerUsd, + total: totalUsd, + vortex: vortexUsd + } + }; +} + +export async function computeFees(ctx: PhaseCtx): Promise { + if (!ctx.fees) ctx.fees = await calculateFees(ctx); +} diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts new file mode 100644 index 000000000..88b3dfc92 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts @@ -0,0 +1,144 @@ +import { beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import FinancialOperation from "../../../../../models/financialOperation.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../../../test-utils/db"; +import type { FlowIdentity } from "./identity"; +import { FinancialOperationRejectedError, runFinancialOperation } from "./financial-operation"; + +const flow: FlowIdentity = { + blockSchemaVersions: { payout: 1 }, + catalogVersion: 1, + id: "test-flow", + metadataSchemaVersion: 1, + registrationFactsSchemaVersion: 1, + stateSchemaVersion: 1, + topologyHash: "test-topology", + transactionPlanSchemaVersion: 1, + version: 2 +}; + +const baseOperation = { + attemptClass: "provider-ticket", + flow, + phase: "payout", + provider: "test-provider", + request: { amount: "10", recipient: "recipient-1" }, + scopeId: "ramp-1", + scopeType: "ramp" as const +}; + +beforeAll(async () => { + await setupTestDatabase(); +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +describe("runFinancialOperation", () => { + it("returns the persisted result without repeating a confirmed side effect", async () => { + const perform = mock(async (idempotencyKey: string) => ({ id: "external-1", idempotencyKey })); + + const first = await runFinancialOperation({ + ...baseOperation, + externalId: result => result.id, + perform + }); + const second = await runFinancialOperation({ + ...baseOperation, + externalId: result => result.id, + perform + }); + + expect(perform).toHaveBeenCalledTimes(1); + expect(second).toEqual(first); + expect(await FinancialOperation.findOne()).toMatchObject({ + externalId: "external-1", + status: "confirmed" + }); + }); + + it("halts retries after an ambiguous provider failure", async () => { + const perform = mock(async () => { + throw new Error("connection reset after submission"); + }); + + await expect(runFinancialOperation({ ...baseOperation, perform })).rejects.toThrow( + "connection reset after submission" + ); + await expect(runFinancialOperation({ ...baseOperation, perform })).rejects.toThrow("requires reconciliation"); + + expect(perform).toHaveBeenCalledTimes(1); + expect(await FinancialOperation.findOne()).toMatchObject({ status: "unknown" }); + }); + + it("rejects reuse of an operation identity with different financial inputs", async () => { + await runFinancialOperation({ + ...baseOperation, + perform: async () => ({ id: "external-1" }) + }); + + await expect( + runFinancialOperation({ + ...baseOperation, + perform: async () => ({ id: "external-2" }), + request: { amount: "11", recipient: "recipient-1" } + }) + ).rejects.toThrow("different inputs"); + }); + + it("allows corrected input after a definitive rejection without a side effect", async () => { + const rejected = new FinancialOperationRejectedError("invalid recipient"); + await expect( + runFinancialOperation({ + ...baseOperation, + perform: async () => { + throw rejected; + } + }) + ).rejects.toThrow("invalid recipient"); + + const result = await runFinancialOperation({ + ...baseOperation, + perform: async () => ({ id: "external-2" }), + request: { amount: "10", recipient: "recipient-2" }, + retryFailed: true + }); + + expect(result).toEqual({ id: "external-2" }); + expect(await FinancialOperation.findOne()).toMatchObject({ status: "confirmed" }); + }); + + for (const status of [408, 409, 422]) { + it(`does not infer a definitive rejection from HTTP ${status}`, async () => { + const rejected = Object.assign(new Error(`provider returned ${status}`), { status }); + const perform = mock(async () => { + throw rejected; + }); + + await expect(runFinancialOperation({ ...baseOperation, perform, retryFailed: true })).rejects.toThrow( + `provider returned ${status}` + ); + await expect(runFinancialOperation({ ...baseOperation, perform, retryFailed: true })).rejects.toThrow( + "requires reconciliation" + ); + + expect(perform).toHaveBeenCalledTimes(1); + expect(await FinancialOperation.findOne()).toMatchObject({ status: "unknown" }); + }); + } + + it("does not start an external operation when the phase is already aborted", async () => { + const controller = new AbortController(); + controller.abort(new Error("phase timed out")); + const perform = mock(async () => ({ id: "external-1" })); + + await expect( + runFinancialOperation({ + ...baseOperation, + perform, + signal: controller.signal + }) + ).rejects.toThrow("phase timed out"); + expect(perform).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts new file mode 100644 index 000000000..7b67f6bed --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts @@ -0,0 +1,187 @@ +import { createHash } from "node:crypto"; +import httpStatus from "http-status"; +import FinancialOperation from "../../../../../models/financialOperation.model"; +import { APIError } from "../../../../errors/api-error"; +import type { StateMetadata } from "../../../phases/meta-state-types"; +import { abortableCall, throwIfAborted } from "./cancellation"; +import type { FlowIdentity } from "./identity"; + +export interface RunFinancialOperationArgs { + scopeType: "quote" | "ramp"; + scopeId: string; + flow: FlowIdentity; + phase: string; + attemptClass: string; + provider: string; + request: unknown; + retryFailed?: boolean; + signal?: AbortSignal; + perform(idempotencyKey: string): Promise; + reconcile?: (operation: FinancialOperation) => Promise; + externalId?: (result: Result) => string | undefined; +} + +export class FinancialOperationReconciliationRequiredError extends APIError { + readonly requiresManualReconciliation = true; + + constructor(operation: FinancialOperation, reason: string) { + super({ + message: `Financial operation ${operation.id} ${reason} and requires reconciliation`, + status: httpStatus.SERVICE_UNAVAILABLE + }); + } +} + +export class FinancialOperationRejectedError extends APIError { + constructor(message: string) { + super({ message, status: httpStatus.UNPROCESSABLE_ENTITY }); + } +} + +function canonicalize(value: unknown): string { + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + if (value && typeof value === "object") { + if ("toJSON" in value && typeof value.toJSON === "function") { + return canonicalize(value.toJSON()); + } + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => `${JSON.stringify(key)}:${canonicalize(nested)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function digest(value: unknown): string { + return createHash("sha256").update(canonicalize(value)).digest("hex"); +} + +function serializable(result: Result): Result { + return JSON.parse(JSON.stringify(result)) as Result; +} + +export function requireFinancialFlowIdentity(state: Readonly): FlowIdentity { + if (!state.flow) { + throw new Error("Ramp state is missing the persisted flow identity required for a financial operation"); + } + return state.flow; +} + +export async function runFinancialOperation({ + scopeType, + scopeId, + flow, + phase, + attemptClass, + provider, + request, + perform, + reconcile, + externalId, + retryFailed = false, + signal +}: RunFinancialOperationArgs): Promise { + throwIfAborted(signal); + const requestHash = digest(request); + const operationKey = digest({ + attemptClass, + flowId: flow.id, + flowVersion: flow.version, + phase, + scopeId, + scopeType + }); + const [operation, created] = await FinancialOperation.findOrCreate({ + defaults: { + attemptClass, + flowId: flow.id, + flowVersion: flow.version, + operationKey, + phase, + provider, + requestHash, + scopeId, + scopeType, + status: "not_started" + }, + where: { operationKey } + }); + + if (operation.requestHash !== requestHash && !(operation.status === "failed" && retryFailed)) { + throw new APIError({ + message: `Financial operation ${operation.id} was already claimed with different inputs`, + status: httpStatus.CONFLICT + }); + } + if (!created) { + if (operation.status === "confirmed" && operation.response !== null) { + return operation.response as Result; + } + if (reconcile) { + const reconciled = await reconcile(operation); + if (reconciled !== null) { + const stored = serializable(reconciled); + await operation.update({ + externalId: externalId?.(reconciled) ?? operation.externalId, + response: stored, + status: "confirmed" + }); + return reconciled; + } + } + if (operation.status === "failed") { + if (!retryFailed) { + throw new APIError({ + message: `Financial operation ${operation.id} definitively failed; a new authorized attempt is required`, + status: httpStatus.CONFLICT + }); + } + // Only an explicit FinancialOperationRejectedError may enter this branch. + // That signal means the integration proved no financial side effect occurred, + // so corrected input may safely reuse the stable operation identity. + await operation.update({ errorMessage: null, requestHash, response: null, status: "not_started" }); + } else if (operation.status === "not_started") { + // The creator could have crashed before claiming the operation. Claiming is an + // atomic state change made before the provider call, so only this state is safe + // to resume automatically. + } else { + if (operation.status === "submitted") { + await operation.update({ status: "unknown" }); + } + throw new FinancialOperationReconciliationRequiredError(operation, `has ${operation.status} outcome`); + } + } + + const [claimed] = await FinancialOperation.update( + { errorMessage: null, status: "submitted" }, + { where: { id: operation.id, status: "not_started" } } + ); + if (claimed !== 1) { + throw new FinancialOperationReconciliationRequiredError(operation, "is already in progress"); + } + + if (signal?.aborted) { + // The durable claim exists, but no external call has started. Return it to + // the only state that recovery may safely claim automatically. + await operation.update({ status: "not_started" }); + throwIfAborted(signal); + } + + try { + const result = await abortableCall(signal, () => perform(operationKey)); + const stored = serializable(result); + await operation.update({ + externalId: externalId?.(result) ?? null, + response: stored, + status: "confirmed" + }); + return result; + } catch (error) { + await operation.update({ + errorMessage: (error instanceof Error ? error.message : String(error)).slice(0, 500), + status: error instanceof FinancialOperationRejectedError ? "failed" : "unknown" + }); + throw error; + } +} diff --git a/apps/api/src/api/services/phases/blocks/core/flow.ts b/apps/api/src/api/services/phases/blocks/core/flow.ts new file mode 100644 index 000000000..7e3c3404f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/flow.ts @@ -0,0 +1,380 @@ +import { EphemeralAccountType, type RampPhase } from "@vortexfi/shared"; +import type { PhaseHandler } from "../../../phases/base-phase-handler"; +import type { StateMetadata } from "../../../phases/meta-state-types"; +import { computeFees } from "./fees"; +import { runFinancialOperation } from "./financial-operation"; +import { assertFlowIdentity, BLOCK_FLOW_CATALOG_VERSION, buildFlowIdentity } from "./identity"; +import { type AnyContextMetadata, isRecord } from "./metadata"; +import { aggregateNativePrefunding, allocateNonces } from "./prepare"; +import type { + Flow, + FlowInputResolver, + FlowPrepareCtx, + FlowRegisterCtx, + FlowStartCtx, + FlowStartResult, + PhaseCtx, + PhaseIO, + PhaseResult, + PrepareCtx, + PreparedFlowTxs, + PreparedPhaseTxs, + RegistrationResult, + StartResult, + TxIntent +} from "./types"; + +// Internal type-erased phase storage. `never` input makes any Phase assignable under +// contravariance; the builder's pipe() adjacency check is what guarantees the runtime inputs line up. +type AnyPhase = { + readonly executors?: PhaseHandler[]; + readonly context: AnyContextMetadata; + readonly externalOperations?: { + register?: { provider: string; attemptClass?: string }; + start?: { provider: string; attemptClass?: string; request?: (ctx: never) => unknown }; + }; + readonly name: string; + readonly phases: RampPhase[]; + readonly prepareTxs?: (ctx: never) => Promise<{ intents: TxIntent[]; state?: unknown }>; + readonly register?: (ctx: never) => Promise>; + readonly simulate: (input: never, ctx: PhaseCtx) => Promise>; + readonly start?: (ctx: never) => Promise>; +}; + +export class FlowBuilder { + private constructor( + private readonly inputResolver: FlowInputResolver, + private readonly phaseList: AnyPhase[] + ) {} + + static start( + inputResolver: FlowInputResolver, + first: AnyPhase & { simulate: (input: First, ctx: PhaseCtx) => Promise> } + ): FlowBuilder { + return new FlowBuilder(inputResolver, [first]); + } + + pipe( + next: AnyPhase & { simulate: (input: O, ctx: PhaseCtx) => Promise> } + ): FlowBuilder { + return new FlowBuilder(this.inputResolver, [...this.phaseList, next]); + } + + build( + name: string, + staticStateMeta: Partial = {}, + version = 1, + catalogVersion = BLOCK_FLOW_CATALOG_VERSION + ): Flow { + const inputResolver = this.inputResolver; + const phaseList = this.phaseList; + const seenKeys = new Set(); + const seenPhases = new Set(); + for (const phase of phaseList) { + if (seenKeys.has(phase.context.key)) { + throw new Error(`Flow ${name} defines duplicate metadata key ${phase.context.key}`); + } + seenKeys.add(phase.context.key); + const phaseExecutors = phase.executors ?? []; + if (phase.phases.length !== phaseExecutors.length) { + throw new Error( + `Flow ${name} block ${phase.name} defines ${phase.phases.length} phases but ${phaseExecutors.length} executors` + ); + } + for (const [index, phaseName] of phase.phases.entries()) { + if (seenPhases.has(phaseName)) { + throw new Error(`Flow ${name} defines duplicate phase ${phaseName}`); + } + seenPhases.add(phaseName); + if (phaseExecutors[index]?.getPhaseName() !== phaseName) { + throw new Error(`Flow ${name} block ${phase.name} executor ${index} does not match persisted phase ${phaseName}`); + } + } + } + const phases: RampPhase[] = phaseList.flatMap(phase => phase.phases); + const executors = phaseList.flatMap(phase => phase.executors ?? []); + const phaseFlow: RampPhase[] = ["initial", ...phases, "complete"]; + if (new Set(phaseFlow).size !== phaseFlow.length) { + throw new Error(`Flow ${name} phase sequence contains duplicate names`); + } + const transitions: Record = {}; + for (let index = 0; index < phaseFlow.length - 1; index++) { + const from = phaseFlow[index]; + const next = phaseFlow[index + 1]; + transitions[from] = next === "failed" ? [next] : [next, "failed"]; + } + const identity = buildFlowIdentity({ + catalogVersion, + contextSchemaVersions: phaseList.map(phase => [phase.context.key, phase.context.schemaVersion] as const), + id: name, + phases, + transitions, + version + }); + + const assertMetadata = (metadata: unknown, options: { allowLegacy?: boolean } = {}): void => { + if (!isRecord(metadata) || !isRecord(metadata.blocks) || !isRecord(metadata.globals)) { + throw new Error(`Invalid persisted metadata envelope for ${name}@${identity.version}`); + } + if (metadata.flow === undefined) { + if (!options.allowLegacy) { + throw new Error(`Persisted metadata is missing the flow identity for ${name}@${identity.version}`); + } + } else { + assertFlowIdentity(metadata.flow, identity); + } + const actualKeys = Object.keys(metadata.blocks).sort(); + const expectedKeys = [...seenKeys].sort(); + if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) { + throw new Error( + `Persisted block set does not match ${name}@${identity.version}: got ${actualKeys.join(",")}, expected ${expectedKeys.join(",")}` + ); + } + for (const key of expectedKeys) { + if (!isRecord(metadata.blocks[key])) { + throw new Error(`Persisted metadata for ${name}@${identity.version} block ${key} is not an object`); + } + } + }; + + const assertState = (state: unknown): void => { + if (!isRecord(state)) { + throw new Error(`Invalid persisted state envelope for ${name}@${identity.version}`); + } + assertFlowIdentity(state.flow, identity); + const storedPhaseFlow = state.phaseFlow; + if ( + !Array.isArray(storedPhaseFlow) || + storedPhaseFlow.some(value => typeof value !== "string") || + JSON.stringify(storedPhaseFlow) !== JSON.stringify(phaseFlow) + ) { + throw new Error(`Persisted phase sequence does not match ${name}@${identity.version}`); + } + if (state.blockState !== undefined) { + if (!isRecord(state.blockState)) { + throw new Error(`Invalid persisted block state for ${name}@${identity.version}`); + } + for (const [key, value] of Object.entries(state.blockState)) { + if (!seenKeys.has(key) || !isRecord(value)) { + throw new Error(`Invalid persisted state for ${name}@${identity.version} block ${key}`); + } + } + } + if (state.transactionPlan !== undefined) { + if (!isRecord(state.transactionPlan)) { + throw new Error(`Invalid transaction plan for ${name}@${identity.version}`); + } + for (const field of ["nativePrefunding", "settlementBaselines"] as const) { + const values = state.transactionPlan[field]; + if (values !== undefined && (!isRecord(values) || Object.values(values).some(value => typeof value !== "string"))) { + throw new Error(`Invalid ${field} transaction-plan values for ${name}@${identity.version}`); + } + } + } + }; + + return { + assertMetadata, + assertState, + contextKeys: [...seenKeys], + executors, + identity, + name, + phases, + async prepareTxs(ctx: FlowPrepareCtx): Promise { + assertMetadata(ctx.metadata, { allowLegacy: true }); + if (ctx.registrationFacts !== undefined) { + if (!isRecord(ctx.registrationFacts)) { + throw new Error(`Invalid registration facts for ${name}@${identity.version}`); + } + for (const [key, value] of Object.entries(ctx.registrationFacts)) { + if (!seenKeys.has(key) || !isRecord(value)) { + throw new Error(`Invalid registration facts for ${name}@${identity.version} block ${key}`); + } + } + } + const intents: TxIntent[] = []; + const blockState: Record = {}; + const accountAddresses = Object.fromEntries( + Object.entries(ctx.accounts).flatMap(([type, account]) => (account ? [[type, account.address]] : [])) + ); + const stateMeta: Omit, "blockState"> = { + accountAddresses, + ...(ctx.destinationAddress ? { destinationAddress: ctx.destinationAddress } : {}), + ...(ctx.accounts[EphemeralAccountType.EVM] + ? { evmEphemeralAddress: ctx.accounts[EphemeralAccountType.EVM].address } + : {}), + ...(ctx.accounts[EphemeralAccountType.Substrate] + ? { substrateEphemeralAddress: ctx.accounts[EphemeralAccountType.Substrate].address } + : {}), + ...staticStateMeta + }; + for (const phase of phaseList) { + if (!phase.prepareTxs) { + continue; + } + const prepareTxs = phase.prepareTxs as (ctx: PrepareCtx) => Promise; + const prepared = await prepareTxs({ + accounts: ctx.accounts, + destinationAddress: ctx.destinationAddress, + globals: ctx.metadata.globals, + ownMetadata: ctx.metadata.blocks[phase.context.key] as never, + ownRegistrationFacts: ctx.registrationFacts?.[phase.context.key] as never, + quote: ctx.quote, + taxId: ctx.taxId, + userId: ctx.userId + }); + intents.push(...prepared.intents); + if (prepared.state !== undefined) { + blockState[phase.context.key] = prepared.state; + } + } + // Same bookends added by assemblePhaseFlow. + return { + stateMeta: { + ...stateMeta, + blockState, + flow: identity, + phaseFlow, + transactionPlan: { nativePrefunding: aggregateNativePrefunding(intents) } + }, + unsignedTxs: allocateNonces(intents) + }; + }, + async register(ctx: FlowRegisterCtx) { + assertMetadata(ctx.metadata, { allowLegacy: true }); + const blocks = { ...ctx.metadata.blocks }; + const registrationFacts: Record = {}; + const responseArtifacts: Record = {}; + for (const phase of phaseList) { + if (!phase.register) { + continue; + } + const registerContext = { + authenticatedUser: ctx.authenticatedUser, + input: ctx.input, + ipAddress: ctx.ipAddress, + metadata: blocks[phase.context.key], + quote: ctx.quote, + signingAccounts: ctx.signingAccounts, + transaction: ctx.transaction + }; + const registrationOperation = phase.externalOperations?.register; + const result = + registrationOperation && ctx.transaction + ? await runFinancialOperation({ + attemptClass: registrationOperation.attemptClass ?? "registration", + flow: identity, + perform: () => phase.register?.(registerContext as never) as Promise>, + phase: phase.phases[0] ?? phase.context.key, + provider: registrationOperation.provider, + request: { + authenticatedUserId: ctx.authenticatedUser.id, + input: ctx.input, + ipAddress: ctx.ipAddress, + metadata: blocks[phase.context.key], + quoteId: ctx.quote.id, + signingAccounts: ctx.signingAccounts + }, + retryFailed: true, + scopeId: ctx.quote.id, + scopeType: "quote" + }) + : await phase.register(registerContext as never); + registrationFacts[phase.context.key] = result.facts; + if (result.metadata !== undefined) { + blocks[phase.context.key] = result.metadata; + } + if (result.responseArtifacts !== undefined) { + responseArtifacts[phase.context.key] = result.responseArtifacts; + } + } + return { + metadata: { ...ctx.metadata, blocks, flow: identity }, + registrationFacts, + responseArtifacts + }; + }, + async simulate(ctx: PhaseCtx) { + await computeFees(ctx); + if (!ctx.fees?.usd) { + throw new Error("Flow simulation requires computed USD fees"); + } + let current: PhaseIO = await inputResolver(ctx); + let expiresAt: Date | undefined; + const blocks: Record = {}; + for (const phase of phaseList) { + const result = await phase.simulate(current as never, ctx); + if (result.fees) { + ctx.fees = result.fees; + } + blocks[phase.context.key] = result.metadata; + if (result.expiresAt && (!expiresAt || result.expiresAt < expiresAt)) { + expiresAt = result.expiresAt; + } + current = result.output; + } + return { + expiresAt, + metadata: { + blocks, + flow: identity, + globals: { fees: ctx.fees as never, partner: ctx.partner, request: ctx.request } + }, + output: current as O + }; + }, + async start(ctx: FlowStartCtx): Promise { + assertMetadata(ctx.metadata); + assertState(ctx.state); + let metadata = ctx.metadata; + let state = ctx.state as StateMetadata; + const responseArtifacts: Record = {}; + for (const phase of phaseList) { + if (!phase.start) { + continue; + } + const startContext = { + metadata: metadata.blocks[phase.context.key], + ownState: state.blockState?.[phase.context.key], + quote: ctx.quote, + rampId: ctx.rampId, + state, + userId: ctx.userId + }; + const startOperation = phase.externalOperations?.start; + const result = + startOperation && ctx.rampId + ? await runFinancialOperation({ + attemptClass: startOperation.attemptClass ?? "start", + flow: identity, + perform: () => phase.start?.(startContext as never) as Promise>, + phase: phase.phases[0] ?? phase.context.key, + provider: startOperation.provider, + request: startOperation.request?.(startContext as never) ?? { + metadata: metadata.blocks[phase.context.key], + ownState: state.blockState?.[phase.context.key], + quoteId: ctx.quote.id, + userId: ctx.userId + }, + retryFailed: true, + scopeId: ctx.rampId, + scopeType: "ramp" + }) + : await phase.start(startContext as never); + if (result.metadata !== undefined) { + metadata = { ...metadata, blocks: { ...metadata.blocks, [phase.context.key]: result.metadata } }; + } + if (result.state !== undefined) { + state = { ...state, ...result.state }; + } + if (result.responseArtifacts !== undefined) { + responseArtifacts[phase.context.key] = result.responseArtifacts; + } + } + return { metadata, responseArtifacts, state }; + }, + transitions + }; + } +} diff --git a/apps/api/src/api/services/quote/core/helpers.ts b/apps/api/src/api/services/phases/blocks/core/helpers.ts similarity index 98% rename from apps/api/src/api/services/quote/core/helpers.ts rename to apps/api/src/api/services/phases/blocks/core/helpers.ts index 01a3dd1c5..2a2f86b1c 100644 --- a/apps/api/src/api/services/quote/core/helpers.ts +++ b/apps/api/src/api/services/phases/blocks/core/helpers.ts @@ -1,6 +1,6 @@ import { DestinationType, EPaymentMethod, Networks, RampCurrency, RampDirection } from "@vortexfi/shared"; import httpStatus from "http-status"; -import { APIError } from "../../../errors/api-error"; +import { APIError } from "../../../../errors/api-error"; /** * Supported chains configuration for ramp operations diff --git a/apps/api/src/api/services/phases/blocks/core/identity.ts b/apps/api/src/api/services/phases/blocks/core/identity.ts new file mode 100644 index 000000000..6d8c513c7 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/identity.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; +import type { RampPhase } from "@vortexfi/shared"; + +export const BLOCK_FLOW_CATALOG_VERSION = 1; +export const BLOCK_FLOW_METADATA_SCHEMA_VERSION = 1; +export const BLOCK_FLOW_REGISTRATION_SCHEMA_VERSION = 1; +export const BLOCK_FLOW_STATE_SCHEMA_VERSION = 1; +export const BLOCK_FLOW_TRANSACTION_PLAN_SCHEMA_VERSION = 1; + +export interface FlowIdentity { + id: string; + version: number; + catalogVersion: number; + metadataSchemaVersion: number; + registrationFactsSchemaVersion: number; + stateSchemaVersion: number; + transactionPlanSchemaVersion: number; + topologyHash: string; + blockSchemaVersions: Record; +} + +interface BuildFlowIdentityArgs { + catalogVersion: number; + id: string; + version: number; + phases: readonly RampPhase[]; + contextSchemaVersions: ReadonlyArray; + transitions: Readonly>; +} + +function canonicalize(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => `${JSON.stringify(key)}:${canonicalize(nested)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function buildFlowIdentity({ + id, + catalogVersion, + version, + phases, + contextSchemaVersions, + transitions +}: BuildFlowIdentityArgs): FlowIdentity { + const blockSchemaVersions = Object.fromEntries(contextSchemaVersions); + const topologyHash = createHash("sha256") + .update( + canonicalize({ + blockSchemaVersions, + catalogVersion, + id, + metadataSchemaVersion: BLOCK_FLOW_METADATA_SCHEMA_VERSION, + phases, + registrationFactsSchemaVersion: BLOCK_FLOW_REGISTRATION_SCHEMA_VERSION, + stateSchemaVersion: BLOCK_FLOW_STATE_SCHEMA_VERSION, + transactionPlanSchemaVersion: BLOCK_FLOW_TRANSACTION_PLAN_SCHEMA_VERSION, + transitions, + version + }) + ) + .digest("hex"); + + return { + blockSchemaVersions, + catalogVersion, + id, + metadataSchemaVersion: BLOCK_FLOW_METADATA_SCHEMA_VERSION, + registrationFactsSchemaVersion: BLOCK_FLOW_REGISTRATION_SCHEMA_VERSION, + stateSchemaVersion: BLOCK_FLOW_STATE_SCHEMA_VERSION, + topologyHash, + transactionPlanSchemaVersion: BLOCK_FLOW_TRANSACTION_PLAN_SCHEMA_VERSION, + version + }; +} + +export function assertFlowIdentity(actual: unknown, expected: FlowIdentity): asserts actual is FlowIdentity { + if (!actual || typeof actual !== "object") { + throw new Error(`Missing persisted flow identity for ${expected.id}@${expected.version}`); + } + const value = actual as Partial; + for (const field of [ + "id", + "version", + "catalogVersion", + "metadataSchemaVersion", + "registrationFactsSchemaVersion", + "stateSchemaVersion", + "transactionPlanSchemaVersion", + "topologyHash" + ] as const) { + if (value[field] !== expected[field]) { + throw new Error( + `Persisted flow identity mismatch for ${expected.id}@${expected.version}: ${field}=${String( + value[field] + )}, expected ${String(expected[field])}` + ); + } + } + if (canonicalize(value.blockSchemaVersions) !== canonicalize(expected.blockSchemaVersions)) { + throw new Error(`Persisted block schema versions do not match ${expected.id}@${expected.version}`); + } +} diff --git a/apps/api/src/api/services/phases/blocks/core/initial-executor.ts b/apps/api/src/api/services/phases/blocks/core/initial-executor.ts new file mode 100644 index 000000000..e64f8a42f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/initial-executor.ts @@ -0,0 +1,23 @@ +import type { RampPhase } from "@vortexfi/shared"; +import { config } from "../../../../../config/vars"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import type RampState from "../../../../../models/rampState.model"; +import { BasePhaseHandler } from "../../../phases/base-phase-handler"; + +export class BlockInitialExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "initial"; + } + + protected async executePhase(state: RampState): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + if (config.sandboxEnabled) { + await new Promise(resolve => setTimeout(resolve, 10000)); + return this.transitionToNextPhase(state, "complete"); + } + return state; + } +} diff --git a/apps/api/src/api/services/phases/blocks/core/io.ts b/apps/api/src/api/services/phases/blocks/core/io.ts new file mode 100644 index 000000000..0e8e58b04 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/io.ts @@ -0,0 +1,76 @@ +import { + AssetHubToken, + type EvmNetworks, + EvmToken, + FiatToken, + getOnChainTokenDetails, + isNetworkEVM, + Networks, + type OnChainToken +} from "@vortexfi/shared"; +import Big from "big.js"; +import type { ChainBrand, FlowInputResolver, PhaseCtx, PhaseIO, TokenBrand } from "./types"; + +export function fiatRequestIO(...tokens: Token[]): FlowInputResolver> { + return (ctx: PhaseCtx) => { + if (!tokens.includes(ctx.request.inputCurrency as Token)) { + throw new Error(`Expected fiat flow input ${tokens.join("/")}, received ${ctx.request.inputCurrency}`); + } + const token = ctx.request.inputCurrency as Token; + return { + amount: new Big(ctx.request.inputAmount), + amountRaw: ctx.request.inputAmount, + chain: "fiat", + token + }; + }; +} + +function onChainRequestIO( + token: Token, + chain: Chain +): FlowInputResolver> { + return (ctx: PhaseCtx) => { + if (ctx.request.inputCurrency !== token || ctx.request.network !== chain) { + throw new Error( + `Expected on-chain flow input ${token} on ${chain}, received ${ctx.request.inputCurrency} on ${ctx.request.network}` + ); + } + const tokenDetails = getOnChainTokenDetails(chain, token); + if (!tokenDetails) { + throw new Error(`Token ${token} is not configured on ${chain}`); + } + const amount = new Big(ctx.request.inputAmount); + return { + amount, + amountRaw: amount.mul(new Big(10).pow(tokenDetails.decimals)).toFixed(0, 0), + chain, + token + }; + }; +} + +export function evmRequestIO( + token: Token, + chain: Chain +): FlowInputResolver> { + if (!isNetworkEVM(chain)) { + throw new Error(`Network ${chain} is not EVM`); + } + return onChainRequestIO(token, chain); +} + +export function assetHubRequestIO( + token: Token +): FlowInputResolver> { + return onChainRequestIO(token, Networks.AssetHub); +} + +export function evmIO( + token: Token, + chain: Chain, + amount: Big, + amountRaw: string +): PhaseIO { + return { amount, amountRaw, chain, token }; +} diff --git a/apps/api/src/api/services/phases/blocks/core/metadata.ts b/apps/api/src/api/services/phases/blocks/core/metadata.ts new file mode 100644 index 000000000..8125ca687 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/metadata.ts @@ -0,0 +1,80 @@ +import type { CreateQuoteRequest, QuoteFeeStructure, RampCurrency } from "@vortexfi/shared"; +import type { Big } from "big.js"; +import type { StateMetadata } from "../../../phases/meta-state-types"; +import type { PartnerInfo } from "../../../quote/core/types"; +import type { FlowIdentity } from "./identity"; + +declare const simulationType: unique symbol; + +export type SerializableBig = Big | string; + +export interface ContextMetadata { + readonly key: Key; + readonly schemaVersion: number; + readonly [simulationType]: Simulation; +} + +export type AnyContextMetadata = ContextMetadata; +export type ContextKey = Context["key"]; +export type ContextSimulation = Context[typeof simulationType]; + +export function defineContext() { + return (key: Key, schemaVersion = 1): ContextMetadata => + ({ key, schemaVersion }) as ContextMetadata; +} + +export interface FlowGlobals { + fees: { + displayFiat?: QuoteFeeStructure; + usd: { anchor: string; network: string; partnerMarkup: string; total: string; vortex: string }; + vortexFeePenPercentage?: number; + }; + partner: PartnerInfo | null; + request: CreateQuoteRequest & { userId?: string }; + subsidyDisplay?: { currency: RampCurrency; fiat: string; usd: string }; +} + +export interface FlowMetadata = Record> { + blocks: Blocks; + flow?: FlowIdentity; + globals: FlowGlobals; +} + +export function getFlowMetadata(metadata: unknown): FlowMetadata { + const value = metadata as Partial | null; + if ( + !isRecord(value) || + !isRecord(value.blocks) || + !isRecord(value.globals) || + !isRecord(value.globals.request) || + !isRecord(value.globals.fees) || + !isRecord(value.globals.fees.usd) + ) { + throw new Error("Quote does not contain block flow metadata"); + } + return value as FlowMetadata; +} + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function getBlockMetadata( + metadata: unknown, + context: Context +): ContextSimulation { + const blocks = (metadata as { blocks?: Record } | null)?.blocks; + const value = blocks?.[context.key]; + if (!isRecord(value)) { + throw new Error(`Missing ${context.key} block metadata`); + } + return value as ContextSimulation; +} + +export function getBlockState(state: StateMetadata, context: AnyContextMetadata): State { + const value = state.blockState?.[context.key]; + if (!isRecord(value)) { + throw new Error(`Missing ${context.key} block state`); + } + return value as State; +} diff --git a/apps/api/src/api/services/quote/engines/mykobo-fee.test.ts b/apps/api/src/api/services/phases/blocks/core/mykobo-fee.test.ts similarity index 97% rename from apps/api/src/api/services/quote/engines/mykobo-fee.test.ts rename to apps/api/src/api/services/phases/blocks/core/mykobo-fee.test.ts index c3f4d7b0f..c5118347e 100644 --- a/apps/api/src/api/services/quote/engines/mykobo-fee.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/mykobo-fee.test.ts @@ -1,6 +1,6 @@ import { MykoboApiService } from "@vortexfi/shared"; import { afterEach, describe, expect, it, mock } from "bun:test"; -import { config } from "../../../../config/vars"; +import { config } from "../../../../../config/vars"; import { MykoboFeeUnavailableError, resolveMykoboDepositFee, resolveMykoboWithdrawFee } from "./mykobo-fee"; describe("resolveMykobo*Fee", () => { diff --git a/apps/api/src/api/services/quote/engines/mykobo-fee.ts b/apps/api/src/api/services/phases/blocks/core/mykobo-fee.ts similarity index 95% rename from apps/api/src/api/services/quote/engines/mykobo-fee.ts rename to apps/api/src/api/services/phases/blocks/core/mykobo-fee.ts index 304e98ff1..fc968c5f2 100644 --- a/apps/api/src/api/services/quote/engines/mykobo-fee.ts +++ b/apps/api/src/api/services/phases/blocks/core/mykobo-fee.ts @@ -1,6 +1,6 @@ import { MykoboApiService } from "@vortexfi/shared"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; +import logger from "../../../../../config/logger"; +import { config } from "../../../../../config/vars"; // Thrown when a Mykobo fee lookup fails and no display fallback is configured. // QuoteService maps this to QuoteError.AnchorTemporarilyUnavailable so the failure diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/phases/blocks/core/nabla.ts similarity index 97% rename from apps/api/src/api/services/quote/core/nabla.ts rename to apps/api/src/api/services/phases/blocks/core/nabla.ts index d426a4949..ecea34f52 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/phases/blocks/core/nabla.ts @@ -14,9 +14,9 @@ import { } from "@vortexfi/shared"; import { Big } from "big.js"; import httpStatus from "http-status"; -import logger from "../../../../config/logger"; -import { APIError } from "../../../errors/api-error"; -import { createLowLiquidityQuoteError, isLowLiquidityQuoteError } from "./errors"; +import logger from "../../../../../config/logger"; +import { APIError } from "../../../../errors/api-error"; +import { createLowLiquidityQuoteError, isLowLiquidityQuoteError } from "../../../quote/core/errors"; export interface NablaSwapRequest { inputAmountForSwap: string; diff --git a/apps/api/src/api/services/phases/blocks/core/offramp-validation.ts b/apps/api/src/api/services/phases/blocks/core/offramp-validation.ts new file mode 100644 index 000000000..3d4e25809 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/offramp-validation.ts @@ -0,0 +1,37 @@ +import { + type AccountMeta, + getAnyFiatTokenDetails, + getNetworkFromDestination, + getOnChainTokenDetails, + isFiatToken, + isOnChainToken +} from "@vortexfi/shared"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; + +export function validateOfframpQuote( + quote: QuoteTicketAttributes, + signingAccounts: AccountMeta[], + options: { requireSubstrateEphemeral?: boolean } = {} +) { + const { requireSubstrateEphemeral = true } = options; + const fromNetwork = getNetworkFromDestination(quote.from); + if (!fromNetwork) { + throw new Error(`Invalid network for destination ${quote.from}`); + } + if (!isOnChainToken(quote.inputCurrency)) { + throw new Error(`Input currency must be on-chain token for offramp, got ${quote.inputCurrency}`); + } + const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency); + if (!inputTokenDetails) { + throw new Error(`Input currency must be on-chain token for offramp, got ${quote.inputCurrency}`); + } + if (!isFiatToken(quote.outputCurrency)) { + throw new Error(`Output currency must be fiat token for offramp, got ${quote.outputCurrency}`); + } + const outputTokenDetails = getAnyFiatTokenDetails(quote.outputCurrency); + const substrateEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "Substrate"); + if (requireSubstrateEphemeral && !substrateEphemeralEntry) { + throw new Error("Pendulum ephemeral not found"); + } + return { fromNetwork, inputTokenDetails, outputTokenDetails, substrateEphemeralEntry }; +} diff --git a/apps/api/src/api/services/phases/blocks/core/phase-flow.ts b/apps/api/src/api/services/phases/blocks/core/phase-flow.ts new file mode 100644 index 000000000..fa715ccf1 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/phase-flow.ts @@ -0,0 +1,6 @@ +import type { RampPhase } from "@vortexfi/shared"; +import type { Flow } from "./types"; + +export function assemblePhaseFlow(flow: Flow): RampPhase[] { + return ["initial", ...flow.phases, "complete"]; +} diff --git a/apps/api/src/api/services/phases/blocks/core/prepare.ts b/apps/api/src/api/services/phases/blocks/core/prepare.ts new file mode 100644 index 000000000..7a66344d6 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/prepare.ts @@ -0,0 +1,74 @@ +import type { UnsignedTx } from "@vortexfi/shared"; +import type { TxIntent, TxLane } from "./types"; + +const LANE_ORDER: TxLane[] = ["main", "backup", "cleanup"]; + +function nativePrefundingKey(network: string, signer: string): string { + return `${network}:${signer.toLowerCase()}`; +} + +export function aggregateNativePrefunding(intents: TxIntent[]): Record { + const requirements = new Map(); + for (const intent of intents) { + if (!intent.prefundNativeValueRaw) { + continue; + } + const key = nativePrefundingKey(intent.network, intent.signer); + requirements.set(key, (requirements.get(key) ?? 0n) + BigInt(intent.prefundNativeValueRaw)); + } + return Object.fromEntries([...requirements].map(([key, value]) => [key, value.toString()])); +} + +export function getNativePrefunding( + transactionPlan: { nativePrefunding?: Record } | undefined, + network: string, + signer: string +): bigint { + return BigInt(transactionPlan?.nativePrefunding?.[nativePrefundingKey(network, signer)] ?? "0"); +} + +export function allocateNonces(intents: TxIntent[]): UnsignedTx[] { + const nextNonce = new Map(); + const firstMainNonce = new Map(); + const unsignedTxs: UnsignedTx[] = []; + + for (const lane of LANE_ORDER) { + for (const intent of intents) { + if (intent.lane !== lane) { + continue; + } + const key = `${intent.network}:${intent.signer}`; + const span = intent.nonceSpan ?? 1; + if (!Number.isSafeInteger(span) || span <= 0) { + throw new Error(`Invalid nonce span ${span} for ${intent.phase}`); + } + if (intent.reuseFirstMainNonce && span !== 1) { + throw new Error(`Intent ${intent.phase} cannot combine reuseFirstMainNonce with nonceSpan ${span}`); + } + let nonce: number; + const pinnedNonce = firstMainNonce.get(key); + if (intent.reuseFirstMainNonce && pinnedNonce !== undefined) { + nonce = pinnedNonce; + } else { + nonce = nextNonce.get(key) ?? 0; + if (!Number.isSafeInteger(nonce + span)) { + throw new Error(`Nonce span ${span} for ${intent.phase} exceeds the safe nonce range`); + } + nextNonce.set(key, nonce + span); + } + if (lane === "main" && !firstMainNonce.has(key)) { + firstMainNonce.set(key, nonce); + } + unsignedTxs.push({ + meta: {}, + network: intent.network, + nonce, + phase: intent.phase, + signer: intent.signer, + txData: intent.txData + }); + } + } + + return unsignedTxs; +} diff --git a/apps/api/src/api/services/quote/core/quote-fees.ts b/apps/api/src/api/services/phases/blocks/core/quote-fees.ts similarity index 97% rename from apps/api/src/api/services/quote/core/quote-fees.ts rename to apps/api/src/api/services/phases/blocks/core/quote-fees.ts index 6cad95784..48b0d6333 100644 --- a/apps/api/src/api/services/quote/core/quote-fees.ts +++ b/apps/api/src/api/services/phases/blocks/core/quote-fees.ts @@ -1,11 +1,11 @@ import { DestinationType, QuoteError, RampCurrency, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; -import logger from "../../../../config/logger"; -import Anchor from "../../../../models/anchor.model"; -import { APIError } from "../../../errors/api-error"; -import { findPartnerWithPricing } from "../../partners/partner-pricing.service"; -import { priceFeedService } from "../../priceFeed.service"; +import logger from "../../../../../config/logger"; +import Anchor from "../../../../../models/anchor.model"; +import { APIError } from "../../../../errors/api-error"; +import { findPartnerWithPricing } from "../../../partners/partner-pricing.service"; +import { priceFeedService } from "../../../priceFeed.service"; import { getTargetFiatCurrency, validateChainSupport } from "./helpers"; export interface CalculateFeeComponentsRequest { diff --git a/apps/api/src/api/services/phases/blocks/core/quote-response.ts b/apps/api/src/api/services/phases/blocks/core/quote-response.ts new file mode 100644 index 000000000..a5c7e3c94 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/quote-response.ts @@ -0,0 +1,48 @@ +import type { QuoteResponse } from "@vortexfi/shared"; +import Big from "big.js"; +import type QuoteTicket from "../../../../../models/quoteTicket.model"; +import { trimTrailingZeros } from "./helpers"; +import { getFlowMetadata } from "./metadata"; + +export function buildBlockQuoteResponse(quote: QuoteTicket): QuoteResponse { + const { fees, subsidyDisplay } = getFlowMetadata(quote.metadata).globals; + const fiatFees = fees.displayFiat; + if (!fiatFees) { + throw new Error("Quote does not contain display fee metadata"); + } + + return { + anchorFeeFiat: fiatFees.anchor, + anchorFeeUsd: fees.usd.anchor, + createdAt: quote.createdAt, + expiresAt: quote.expiresAt, + feeCurrency: fiatFees.currency, + from: quote.from, + id: quote.id, + inputAmount: trimTrailingZeros(quote.inputAmount), + inputCurrency: quote.inputCurrency, + network: quote.network, + networkFeeFiat: fiatFees.network, + networkFeeUsd: fees.usd.network, + outputAmount: trimTrailingZeros(quote.outputAmount), + outputCurrency: quote.outputCurrency, + partnerFeeFiat: fiatFees.partnerMarkup, + partnerFeeUsd: fees.usd.partnerMarkup, + paymentMethod: quote.paymentMethod, + processingFeeFiat: new Big(fiatFees.anchor).plus(fiatFees.vortex).toFixed(), + processingFeeUsd: new Big(fees.usd.anchor).plus(fees.usd.vortex).toFixed(), + rampType: quote.rampType, + ...(subsidyDisplay + ? { + discountCurrency: subsidyDisplay.currency, + discountFiat: subsidyDisplay.fiat, + discountUsd: subsidyDisplay.usd + } + : {}), + to: quote.to, + totalFeeFiat: fiatFees.total, + totalFeeUsd: fees.usd.total, + vortexFeeFiat: fiatFees.vortex, + vortexFeeUsd: fees.usd.vortex + }; +} diff --git a/apps/api/src/api/services/phases/blocks/core/quote.ts b/apps/api/src/api/services/phases/blocks/core/quote.ts new file mode 100644 index 000000000..c096cde3b --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/quote.ts @@ -0,0 +1,162 @@ +import { + EvmToken, + FiatToken, + getNetworkFromDestination, + getOnChainTokenDetails, + getPaymentMethodFromDestinations, + OnChainToken, + RampCurrency, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { config } from "../../../../../config/vars"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import { APIError } from "../../../../errors/api-error"; +import { priceFeedService } from "../../../priceFeed.service"; +import type { QuoteContext, QuoteTicketMetadata } from "../../../quote/core/types"; +import { resolveBlockFlow } from "../flows/catalog"; +import { trimTrailingZeros } from "./helpers"; +import type { FlowMetadata } from "./metadata"; +import { buildBlockQuoteResponse } from "./quote-response"; +import type { PhaseCtx, PhaseIO } from "./types"; +import { applyAlfredpayLimits, validateAmountLimits } from "./validation"; + +async function validateOutput(ctx: QuoteContext, output: PhaseIO): Promise { + if (output.amount.lte(0)) { + throw new APIError({ message: "Input amount too low to cover calculated fees", status: httpStatus.BAD_REQUEST }); + } + if (await applyAlfredpayLimits(ctx, ctx.request.inputAmount)) { + return ctx.request.rampType === RampDirection.SELL ? 2 : getOutputDecimals(ctx); + } + if (ctx.request.rampType === RampDirection.BUY) { + validateAmountLimits(ctx.request.inputAmount, ctx.request.inputCurrency as FiatToken, "min", ctx.request.rampType); + validateAmountLimits(ctx.request.inputAmount, ctx.request.inputCurrency as FiatToken, "max", ctx.request.rampType); + return getOutputDecimals(ctx); + } + validateAmountLimits(output.amount, ctx.request.outputCurrency as FiatToken, "min", ctx.request.rampType); + validateAmountLimits(output.amount, ctx.request.outputCurrency as FiatToken, "max", ctx.request.rampType); + return 2; +} + +function getOutputDecimals(ctx: QuoteContext): number { + const network = getNetworkFromDestination(ctx.request.to); + const token = network && getOnChainTokenDetails(network, ctx.request.outputCurrency as OnChainToken); + if (!token) { + throw new APIError({ message: "Block flow output token is not configured", status: httpStatus.INTERNAL_SERVER_ERROR }); + } + return token.decimals; +} + +async function assignSubsidyDisplay(metadata: FlowMetadata, ctx: QuoteContext): Promise { + const subsidy = (metadata.blocks.subsidizePostSwap ?? metadata.blocks.subsidizePreSwap) as + | { applied?: boolean; outputCurrency?: RampCurrency; subsidyAmountInOutputTokenDecimal?: Big | string } + | undefined; + if (!subsidy?.applied || !subsidy.outputCurrency || new Big(subsidy.subsidyAmountInOutputTokenDecimal ?? 0).lte(0)) { + return; + } + + const amount = new Big(subsidy.subsidyAmountInOutputTokenDecimal ?? 0).toString(); + const [fiat, usd] = await Promise.all([ + priceFeedService.convertCurrencyOrNull(amount, subsidy.outputCurrency, ctx.targetFeeFiatCurrency), + priceFeedService.convertCurrencyOrNull(amount, subsidy.outputCurrency, EvmToken.USDC as RampCurrency) + ]); + if (fiat && usd) { + metadata.globals.subsidyDisplay = { + currency: ctx.targetFeeFiatCurrency, + fiat: new Big(fiat).toFixed(2), + usd: new Big(usd).toFixed(6) + }; + } +} + +function buildTemporaryResponse(ctx: QuoteContext, metadata: FlowMetadata, outputAmount: string, expiresAt: Date) { + const fiatFees = metadata.globals.fees.displayFiat; + if (!fiatFees) { + throw new Error("Block flow did not compute display fees"); + } + const paymentMethod = getPaymentMethodFromDestinations(ctx.request.from, ctx.request.to); + return { + anchorFeeFiat: fiatFees.anchor, + anchorFeeUsd: metadata.globals.fees.usd.anchor, + createdAt: new Date(), + expiresAt, + feeCurrency: fiatFees.currency, + from: ctx.request.from, + id: `temp-${Date.now()}`, + inputAmount: trimTrailingZeros(ctx.request.inputAmount), + inputCurrency: ctx.request.inputCurrency, + network: ctx.request.network, + networkFeeFiat: fiatFees.network, + networkFeeUsd: metadata.globals.fees.usd.network, + outputAmount: trimTrailingZeros(outputAmount), + outputCurrency: ctx.request.outputCurrency, + partnerFeeFiat: fiatFees.partnerMarkup, + partnerFeeUsd: metadata.globals.fees.usd.partnerMarkup, + paymentMethod, + processingFeeFiat: new Big(fiatFees.anchor).plus(fiatFees.vortex).toFixed(), + processingFeeUsd: new Big(metadata.globals.fees.usd.anchor).plus(metadata.globals.fees.usd.vortex).toFixed(), + rampType: ctx.request.rampType, + ...(metadata.globals.subsidyDisplay + ? { + discountCurrency: metadata.globals.subsidyDisplay.currency, + discountFiat: metadata.globals.subsidyDisplay.fiat, + discountUsd: metadata.globals.subsidyDisplay.usd + } + : {}), + to: ctx.request.to, + totalFeeFiat: fiatFees.total, + totalFeeUsd: metadata.globals.fees.usd.total, + vortexFeeFiat: fiatFees.vortex, + vortexFeeUsd: metadata.globals.fees.usd.vortex + }; +} + +export function resolveBlockQuoteExpiry(providerExpiresAt: Date | undefined, now = new Date()): Date { + return providerExpiresAt ?? new Date(now.getTime() + 10 * 60 * 1000); +} + +export async function runBlockQuoteFlow(ctx: QuoteContext): Promise { + const phaseCtx: PhaseCtx = { + addNote: note => ctx.addNote?.(note), + notes: ctx.notes ?? [], + now: ctx.now, + partner: ctx.partner, + request: ctx.request, + targetFeeFiatCurrency: ctx.targetFeeFiatCurrency + }; + const { expiresAt: providerExpiresAt, metadata, output } = await resolveBlockFlow(ctx.request).simulate(phaseCtx); + const decimals = await validateOutput(ctx, output); + const outputAmount = output.amount.toFixed(decimals, 0); + const expiresAt = resolveBlockQuoteExpiry(providerExpiresAt); + await assignSubsidyDisplay(metadata, ctx); + ctx.fees = phaseCtx.fees; + + if (ctx.skipPersistence) { + ctx.builtResponse = buildTemporaryResponse(ctx, metadata, outputAmount, expiresAt); + return; + } + + const record = await QuoteTicket.create({ + apiCredentialId: ctx.request.apiCredentialId || null, + apiKey: ctx.request.apiKey || null, + countryCode: ctx.request.countryCode, + expiresAt, + flowVariant: config.flowVariant, + from: ctx.request.from, + inputAmount: ctx.request.inputAmount, + inputCurrency: ctx.request.inputCurrency, + metadata: metadata as unknown as QuoteTicketMetadata, + network: ctx.request.network, + outputAmount, + outputCurrency: ctx.request.outputCurrency, + partnerId: ctx.partnerOwnerId || null, + paymentMethod: getPaymentMethodFromDestinations(ctx.request.from, ctx.request.to), + pricingPartnerId: ctx.pricingPartnerId || null, + rampType: ctx.request.rampType, + status: "pending", + to: ctx.request.to, + userId: ctx.request.userId || null + }); + ctx.builtResponse = buildBlockQuoteResponse(record); +} diff --git a/apps/api/src/api/services/phases/blocks/core/register.ts b/apps/api/src/api/services/phases/blocks/core/register.ts new file mode 100644 index 000000000..7a774d6db --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/register.ts @@ -0,0 +1,37 @@ +import type { AccountMeta } from "@vortexfi/shared"; +import type QuoteTicket from "../../../../../models/quoteTicket.model"; +import { resolvePersistedBlockFlow } from "../flows/catalog"; +import { accountCapabilities } from "./accounts"; +import { getFlowMetadata } from "./metadata"; +import type { PreparedFlowTxs } from "./types"; + +interface PrepareBlockFlowTransactionsArgs { + destinationAddress: string; + quote: QuoteTicket; + signingAccounts: AccountMeta[]; + taxId?: string; + userId?: string; +} + +export function assertBlockFlowMapped(quote: QuoteTicket): void { + resolvePersistedBlockFlow(quote.metadata); +} + +export async function prepareBlockFlowTransactions({ + destinationAddress, + quote, + signingAccounts, + taxId, + userId +}: PrepareBlockFlowTransactionsArgs): Promise { + const metadata = getFlowMetadata(quote.metadata); + const quoteFields = quote.get({ plain: true }); + return resolvePersistedBlockFlow(metadata).prepareTxs({ + accounts: accountCapabilities(signingAccounts), + destinationAddress, + metadata, + quote: quoteFields, + taxId, + userId + }); +} diff --git a/apps/api/src/api/services/phases/blocks/core/settlement.ts b/apps/api/src/api/services/phases/blocks/core/settlement.ts new file mode 100644 index 000000000..d03868668 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/settlement.ts @@ -0,0 +1,20 @@ +import Big from "big.js"; + +export function settlementBalanceKey(network: string, owner: string, token: string): string { + return `${network}:${owner.toLowerCase()}:${token.toLowerCase()}`; +} + +export function calculateSettlementSubsidyRaw( + expectedAmountRaw: Big, + actualBalanceRaw: Big, + baselineRaw: Big, + gasReserveRaw: Big +): Big { + const delivered = actualBalanceRaw.minus(baselineRaw); + const deliveredRaw = delivered.gt(0) ? delivered : new Big(0); + const deliveryGap = expectedAmountRaw.minus(deliveredRaw).plus(gasReserveRaw); + const deliveryGapRaw = deliveryGap.gt(0) ? deliveryGap : new Big(0); + const onChainShortfall = expectedAmountRaw.plus(gasReserveRaw).minus(actualBalanceRaw); + const onChainShortfallRaw = onChainShortfall.gt(0) ? onChainShortfall : new Big(0); + return deliveryGapRaw.lt(onChainShortfallRaw) ? deliveryGapRaw : onChainShortfallRaw; +} diff --git a/apps/api/src/api/services/phases/blocks/core/squidrouter-route.ts b/apps/api/src/api/services/phases/blocks/core/squidrouter-route.ts new file mode 100644 index 000000000..0b0e6855d --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/squidrouter-route.ts @@ -0,0 +1,29 @@ +import { createGenericRouteParams, type Networks, type RouteParams } from "@vortexfi/shared"; +import { generatePrivateKey, privateKeyToAddress } from "viem/accounts"; + +export interface SquidrouterQuoteRouteParams { + amountRaw: string; + fromToken: `0x${string}`; + toToken: `0x${string}`; + fromNetwork: Networks; + toNetwork: Networks; +} + +/** Build a non-executable route request for quote simulation. */ +export function prepareSquidrouterRouteParams(params: SquidrouterQuoteRouteParams): RouteParams { + const { amountRaw, fromToken, toToken, fromNetwork, toNetwork } = params; + const placeholderAddress = privateKeyToAddress(generatePrivateKey()); + + // Quote the same destination that transaction preparation will execute. + // Ramp direction does not determine bridge topology: current EVM offramps + // settle on Base or Polygon rather than routing through legacy Moonbeam hooks. + return createGenericRouteParams({ + amount: amountRaw, + destinationAddress: placeholderAddress, + fromAddress: placeholderAddress, + fromNetwork, + fromToken, + toNetwork, + toToken + }); +} diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts similarity index 82% rename from apps/api/src/api/services/quote/core/squidrouter.ts rename to apps/api/src/api/services/phases/blocks/core/squidrouter.ts index 8cd6fae41..9a4468983 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts @@ -1,7 +1,6 @@ import { - createGenericRouteParams, - createRouteParamsWithMoonbeamPostHook, DestinationType, + EvmToken, EvmTokenDetails, getNetworkFromDestination, getOnChainTokenDetails, @@ -11,7 +10,6 @@ import { OnChainToken, parseContractBalanceResponse, QuoteError, - RampDirection, RouteParams, SquidrouterCachedRoute, SquidrouterCachedRouteResult, @@ -21,12 +19,12 @@ import { } from "@vortexfi/shared"; import { Big } from "big.js"; import httpStatus from "http-status"; -import { generatePrivateKey, privateKeyToAddress } from "viem/accounts"; -import logger from "../../../../config/logger"; -import { APIError } from "../../../errors/api-error"; -import { multiplyByPowerOfTen } from "../../pendulum/helpers"; -import { priceFeedService } from "../../priceFeed.service"; -import { createLowLiquidityQuoteError, isLowLiquidityQuoteError } from "./errors"; +import logger from "../../../../../config/logger"; +import { APIError } from "../../../../errors/api-error"; +import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; +import { priceFeedService } from "../../../priceFeed.service"; +import { createLowLiquidityQuoteError, isLowLiquidityQuoteError } from "../../../quote/core/errors"; +import { prepareSquidrouterRouteParams } from "./squidrouter-route"; export interface EvmBridgeRequest { amountRaw: string; // Raw amount to bridge/swap via Squidrouter @@ -35,11 +33,9 @@ export interface EvmBridgeRequest { fromNetwork: Networks; toNetwork: Networks; originalInputAmountForRateCalc: string; // The inputAmountForSwap that went into Nabla, for final rate calculation - rampType: RampDirection; // Whether this is an onramp or offramp } export interface EvmBridgeQuoteRequest { - rampType: RampDirection; // Whether this is an onramp or offramp amountDecimal: string; // Raw amount inputCurrency: OnChainToken; outputCurrency: OnChainToken; @@ -83,39 +79,11 @@ export function getTokenDetailsForEvmDestination( } /** - * Helper to prepare route parameters for Squidrouter + * Returns the token details that SquidRouter should deliver on the destination + * chain for a given (outputCurrency, toNetwork). */ -function prepareSquidrouterRouteParams(params: { - rampType: RampDirection; - amountRaw: string; - fromToken: `0x${string}`; - toToken: `0x${string}`; - fromNetwork: Networks; - toNetwork: Networks; -}): RouteParams { - const { rampType, amountRaw, fromToken, toToken, fromNetwork, toNetwork } = params; - - const placeholderAddress = privateKeyToAddress(generatePrivateKey()); - const placeholderHash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - return rampType === RampDirection.BUY - ? createGenericRouteParams({ - amount: amountRaw, - destinationAddress: placeholderAddress, - fromAddress: placeholderAddress, - fromNetwork, - fromToken, - toNetwork, - toToken - }) - : createRouteParamsWithMoonbeamPostHook({ - amount: amountRaw, - fromAddress: placeholderAddress, - fromNetwork, - fromToken, - receivingContractAddress: placeholderAddress, - squidRouterReceiverHash: placeholderHash - }); +export function getBridgeTargetTokenDetails(outputCurrency: OnChainToken, toNetwork: Networks): EvmTokenDetails { + return getTokenDetailsForEvmDestination(outputCurrency, toNetwork); } // Squid swap gas is paid in the source chain's native token. The CoinGecko ID @@ -186,14 +154,13 @@ function calculateFinalExchangeRate( function buildRouteRequest(request: EvmBridgeQuoteRequest) { const inputTokenDetails = getTokenDetailsForEvmDestination(request.inputCurrency, request.fromNetwork); - const outputTokenDetails = getTokenDetailsForEvmDestination(request.outputCurrency, request.toNetwork); + const outputTokenDetails = getBridgeTargetTokenDetails(request.outputCurrency, request.toNetwork); const amountRaw = multiplyByPowerOfTen(request.amountDecimal, inputTokenDetails.decimals).toFixed(0, 0); return prepareSquidrouterRouteParams({ amountRaw, fromNetwork: request.fromNetwork, fromToken: inputTokenDetails.erc20AddressSourceChain, - rampType: request.rampType, toNetwork: request.toNetwork, toToken: outputTokenDetails.erc20AddressSourceChain }); @@ -240,7 +207,7 @@ async function getSquidrouterRouteData(routeParams: RouteParams, fromNetwork: Ne * Handles EVM bridging/swapping via Squidrouter and calculates its specific network fee */ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest): Promise { - const { amountRaw, fromNetwork, toNetwork, fromToken, toToken, originalInputAmountForRateCalc, rampType } = request; + const { amountRaw, fromNetwork, toNetwork, fromToken, toToken, originalInputAmountForRateCalc } = request; try { // Prepare route parameters for Squidrouter @@ -248,7 +215,6 @@ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest) amountRaw: amountRaw, fromNetwork, fromToken, - rampType, toNetwork, toToken }); diff --git a/apps/api/src/api/services/phases/blocks/core/types.ts b/apps/api/src/api/services/phases/blocks/core/types.ts new file mode 100644 index 000000000..06ed4e715 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/types.ts @@ -0,0 +1,209 @@ +import type { + AccountMeta, + CleanupPhase, + CreateQuoteRequest, + EphemeralAccountType, + Networks, + QuoteFeeStructure, + RampCurrency, + RampPhase, + UnsignedTx +} from "@vortexfi/shared"; +import type { Big } from "big.js"; +import type { Transaction } from "sequelize"; +import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import type { PhaseHandler } from "../../../phases/base-phase-handler"; +import type { StateMetadata } from "../../../phases/meta-state-types"; +import type { PartnerInfo } from "../../../quote/core/types"; +import type { FlowIdentity } from "./identity"; +import type { AnyContextMetadata, ContextSimulation, FlowMetadata } from "./metadata"; + +export type TokenBrand = string; +export type ChainBrand = string; + +export interface PhaseIO { + amount: Big; + amountRaw: string; + requestInputAmountUsd?: Big; + token: Token; + chain: Chain; +} + +export interface PhaseCtx { + request: CreateQuoteRequest & { userId?: string }; + partner: PartnerInfo | null; + now: Date; + notes: string[]; + addNote(note: string): void; + fees?: { + usd?: { vortex: string; anchor: string; partnerMarkup: string; network: string; total: string }; + displayFiat?: QuoteFeeStructure; + vortexFeePenPercentage?: number; + }; + targetFeeFiatCurrency?: RampCurrency; +} + +export type FlowInputResolver = (ctx: PhaseCtx) => O | Promise; + +export type AccountCapabilities = Readonly<{ + [Type in EphemeralAccountType]?: AccountMeta & { type: Type }; +}>; + +// Nonce lanes. Per (network, signer): "main" intents get sequential nonces in flow order, +// "backup" intents follow after all main nonces, "cleanup" intents come last. An intent with +// reuseFirstMainNonce takes the first main-lane nonce on its (network, signer) instead of the +// next sequential one — a contingency tx that must never be stranded behind an unreachable nonce. +export type TxLane = "main" | "backup" | "cleanup"; + +export interface TxIntent { + phase: RampPhase | CleanupPhase; + network: Networks; + signer: string; + txData: UnsignedTx["txData"]; + lane: TxLane; + prefundNativeValueRaw?: string; + reuseFirstMainNonce?: boolean; + nonceSpan?: number; +} + +export interface PreparedPhaseTxs { + intents: TxIntent[]; + state?: unknown; +} + +export type QuoteFields = Omit; + +export interface PrepareGlobals { + accounts: AccountCapabilities; + quote: Readonly; + destinationAddress?: string; + taxId?: string; + userId?: string; +} + +export interface PrepareCtx extends PrepareGlobals { + globals: FlowMetadata["globals"]; + ownMetadata: Readonly; + ownRegistrationFacts: Readonly | undefined; +} + +export interface FlowPrepareCtx extends PrepareGlobals { + metadata: FlowMetadata; + registrationFacts?: Record; +} + +export interface PreparedFlowTxs { + unsignedTxs: UnsignedTx[]; + stateMeta: Partial; +} + +export interface PhaseResult { + expiresAt?: Date; + fees?: PhaseCtx["fees"]; + metadata: Metadata; + output: O; +} + +export interface RegisterCtx = Record> { + authenticatedUser: Readonly<{ id: string }>; + input: Readonly; + ipAddress?: string; + metadata: Readonly; + quote: Readonly; + signingAccounts: readonly AccountMeta[]; + transaction?: Transaction; +} + +export interface RegistrationResult { + facts: Facts; + metadata?: Metadata; + responseArtifacts?: Readonly>; +} + +export interface StartCtx { + metadata: Readonly; + ownState: Readonly; + quote: Readonly; + rampId?: string; + state: Readonly; + userId?: string; +} + +export interface StartResult { + metadata?: Metadata; + responseArtifacts?: Readonly>; + state?: Partial; +} + +export interface Phase< + Context extends AnyContextMetadata, + I extends PhaseIO, + O extends PhaseIO, + RegistrationFacts = never, + RegistrationInput extends Record = Record +> { + readonly context: Context; + readonly externalOperations?: { + register?: { provider: string; attemptClass?: string }; + start?: { + provider: string; + attemptClass?: string; + request?: (ctx: StartCtx>) => unknown; + }; + }; + readonly name: string; + readonly phases: RampPhase[]; + // Property (not method) so pipe's brand check stays contravariant under strictFunctionTypes. + readonly simulate: (input: I, ctx: PhaseCtx) => Promise>>; + // One executor per entry in `phases`, in the same order. Optional while corridors + // are ported incrementally; a flow whose phases all carry executors is fully + // execution-ready and registerable into the phase registry. + readonly executors?: PhaseHandler[]; + // The unsigned transactions this phase's executors expect the ephemeral/user to presign, + // as nonce-free intents; the flow assembler allocates nonces per (network, signer, lane). + // Optional: phases whose executors sign live (funding account) or need no tx omit it. + readonly prepareTxs?: (ctx: PrepareCtx, RegistrationFacts>) => Promise; + readonly register?: ( + ctx: RegisterCtx, RegistrationInput> + ) => Promise>>; + readonly start?: (ctx: StartCtx>) => Promise>>; +} + +export interface FlowRegisterCtx extends Omit, "metadata"> { + metadata: FlowMetadata; +} + +export interface FlowRegistrationResult { + metadata: FlowMetadata; + registrationFacts: Record; + responseArtifacts: Record; +} + +export interface FlowStartCtx { + metadata: FlowMetadata; + quote: Readonly; + rampId?: string; + state: Readonly; + userId?: string; +} + +export interface FlowStartResult { + metadata: FlowMetadata; + responseArtifacts: Record; + state: StateMetadata; +} + +export interface Flow { + readonly contextKeys: readonly string[]; + readonly name: string; + readonly identity: Readonly; + readonly phases: RampPhase[]; + readonly executors: PhaseHandler[]; + readonly transitions: Readonly>; + assertMetadata(metadata: unknown, options?: { allowLegacy?: boolean }): void; + assertState(state: unknown): void; + register(ctx: FlowRegisterCtx): Promise; + simulate(ctx: PhaseCtx): Promise<{ expiresAt?: Date; metadata: FlowMetadata; output: O }>; + start(ctx: FlowStartCtx): Promise; + prepareTxs(ctx: FlowPrepareCtx): Promise; +} diff --git a/apps/api/src/api/services/quote/core/validation-helpers.ts b/apps/api/src/api/services/phases/blocks/core/validation.ts similarity index 95% rename from apps/api/src/api/services/quote/core/validation-helpers.ts rename to apps/api/src/api/services/phases/blocks/core/validation.ts index 97bc584e9..c04f07e96 100644 --- a/apps/api/src/api/services/quote/core/validation-helpers.ts +++ b/apps/api/src/api/services/phases/blocks/core/validation.ts @@ -1,14 +1,14 @@ import { FiatToken, getAnyFiatTokenDetails, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; -import { APIError } from "../../../errors/api-error"; +import { APIError } from "../../../../errors/api-error"; import { getAlfredpayMonthlyUsage, ResolvedAlfredpayLimits, resolveAlfredpayQuoteLimits -} from "../../alfredpay/alfredpay.helpers"; -import { multiplyByPowerOfTen } from "../../pendulum/helpers"; -import { QuoteContext } from "./types"; +} from "../../../alfredpay/alfredpay.helpers"; +import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; +import { QuoteContext } from "../../../quote/core/types"; /** * Get token limit units for a given fiat token, limit type, and operation type diff --git a/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts new file mode 100644 index 000000000..c49c2db88 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts @@ -0,0 +1,12 @@ +import { type EvmNetworks, EvmToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { evmRequestIO } from "../core/io"; +import { AlfredpayOfframp } from "../phases/alfredpay-offramp"; + +export function makeAlfredpayOfframpFlow(fromToken: EvmToken, fromNetwork: EvmNetworks) { + return FlowBuilder.start(evmRequestIO(fromToken, fromNetwork), AlfredpayOfframp(fromToken, fromNetwork)).build( + "AlfredpayOfframp" + ); +} + +export const alfredpayOfframpFlow = makeAlfredpayOfframpFlow(EvmToken.USDC, Networks.Base); diff --git a/apps/api/src/api/services/phases/blocks/flows/alfredpay-onramp-cross-chain.ts b/apps/api/src/api/services/phases/blocks/flows/alfredpay-onramp-cross-chain.ts new file mode 100644 index 000000000..03359da0a --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/alfredpay-onramp-cross-chain.ts @@ -0,0 +1,27 @@ +import { ALFREDPAY_EVM_TOKEN, EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { ChainBrand, TokenBrand } from "../core/types"; +import { AlfredpayMint } from "../phases/alfredpay-mint"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { FinalSettlementSubsidy } from "../phases/final-settlement-subsidy"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { SquidRouterSwap } from "../phases/squid-router-swap"; +import { AlfredpaySubsidizePre } from "../phases/subsidize-pre"; + +export function makeAlfredpayOnrampCrossChainFlow( + toChain: ToChain, + toToken: ToToken +) { + return FlowBuilder.start(fiatRequestIO(FiatToken.ARS, FiatToken.COP, FiatToken.MXN, FiatToken.USD), AlfredpayMint) + .pipe(FundEphemeral(ALFREDPAY_EVM_TOKEN, Networks.Polygon)) + .pipe(AlfredpaySubsidizePre()) + .pipe(SquidRouterSwap(Networks.Polygon, toChain, ALFREDPAY_EVM_TOKEN, toToken)) + .pipe(FinalSettlementSubsidy()) + .pipe(DestinationTransfer()) + .build("AlfredpayOnrampCrossChain", { isDirectTransfer: false }); +} + +export const alfredpayOnrampCrossChainFlow = makeAlfredpayOnrampCrossChainFlow(Networks.Arbitrum, EvmToken.USDC); +export const alfredpayOnrampCrossChainPhaseFlow = assemblePhaseFlow(alfredpayOnrampCrossChainFlow); diff --git a/apps/api/src/api/services/phases/blocks/flows/alfredpay-onramp-direct.ts b/apps/api/src/api/services/phases/blocks/flows/alfredpay-onramp-direct.ts new file mode 100644 index 000000000..325111954 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/alfredpay-onramp-direct.ts @@ -0,0 +1,34 @@ +import { ALFREDPAY_EVM_TOKEN, EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { TokenBrand } from "../core/types"; +import { AlfredpayMint } from "../phases/alfredpay-mint"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { FinalSettlementSubsidy } from "../phases/final-settlement-subsidy"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { SameChainSquidRouterSwap, SquidRouterPassthrough } from "../phases/squid-router-swap"; +import { AlfredpaySubsidizePre } from "../phases/subsidize-pre"; + +export function makeAlfredpayOnrampDirectFlow(toToken: ToToken) { + const start = FlowBuilder.start(fiatRequestIO(FiatToken.ARS, FiatToken.COP, FiatToken.MXN, FiatToken.USD), AlfredpayMint) + .pipe(FundEphemeral(ALFREDPAY_EVM_TOKEN, Networks.Polygon)) + .pipe(AlfredpaySubsidizePre()); + + if (toToken === ALFREDPAY_EVM_TOKEN) { + return start + .pipe(SquidRouterPassthrough(ALFREDPAY_EVM_TOKEN, Networks.Polygon)) + .pipe(FinalSettlementSubsidy()) + .pipe(DestinationTransfer()) + .build("AlfredpayOnrampDirect"); + } + + return start + .pipe(SameChainSquidRouterSwap(Networks.Polygon, ALFREDPAY_EVM_TOKEN, toToken)) + .pipe(FinalSettlementSubsidy()) + .pipe(DestinationTransfer()) + .build("AlfredpayOnrampDirect"); +} + +export const alfredpayOnrampDirectFlow = makeAlfredpayOnrampDirectFlow(EvmToken.USDC); +export const alfredpayOnrampDirectPhaseFlow = assemblePhaseFlow(alfredpayOnrampDirectFlow); diff --git a/apps/api/src/api/services/phases/blocks/flows/brl-offramp-assethub-usdc.ts b/apps/api/src/api/services/phases/blocks/flows/brl-offramp-assethub-usdc.ts new file mode 100644 index 000000000..53dba0860 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/brl-offramp-assethub-usdc.ts @@ -0,0 +1,21 @@ +import { AssetHubToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { assetHubRequestIO } from "../core/io"; +import { AssethubOfframpSource } from "../phases/assethub-offramp-source"; +import { AveniaOfframpFee } from "../phases/avenia-offramp-fee"; +import { AveniaPendulumOfframp } from "../phases/avenia-pendulum-offramp"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { PendulumAssethubDistributeFees } from "../phases/pendulum-distribute-fees"; +import { PendulumOfframpNablaSwap } from "../phases/pendulum-offramp-nabla-swap"; +import { PendulumOfframpSubsidizePost } from "../phases/pendulum-offramp-subsidize-post"; +import { PendulumOfframpSubsidizePre } from "../phases/pendulum-offramp-subsidize-pre"; + +export const brlOfframpAssethubUsdcFlow = FlowBuilder.start(assetHubRequestIO(AssetHubToken.USDC), AssethubOfframpSource) + .pipe(FundEphemeral(AssetHubToken.USDC, Networks.Pendulum)) + .pipe(PendulumAssethubDistributeFees) + .pipe(PendulumOfframpSubsidizePre) + .pipe(PendulumOfframpNablaSwap) + .pipe(AveniaOfframpFee()) + .pipe(PendulumOfframpSubsidizePost) + .pipe(AveniaPendulumOfframp) + .build("BrlOfframpAssethubUsdc"); diff --git a/apps/api/src/api/services/phases/blocks/flows/brl-offramp-base.ts b/apps/api/src/api/services/phases/blocks/flows/brl-offramp-base.ts new file mode 100644 index 000000000..acec98639 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/brl-offramp-base.ts @@ -0,0 +1,25 @@ +import { EvmNetworks, EvmToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { evmRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { AveniaOfframpFee } from "../phases/avenia-offramp-fee"; +import { AveniaOfframpPayout } from "../phases/avenia-offramp-payout"; +import { DistributeFees } from "../phases/distribute-fees"; +import { EvmOfframpSource } from "../phases/evm-offramp-source"; +import { NablaSwap } from "../phases/nabla-swap"; +import { OfframpSubsidizePost } from "../phases/subsidize-post"; +import { SubsidizePre } from "../phases/subsidize-pre"; + +export function makeBrlOfframpBaseFlow(fromToken: EvmToken, fromNetwork: EvmNetworks) { + return FlowBuilder.start(evmRequestIO(fromToken, fromNetwork), EvmOfframpSource()) + .pipe(DistributeFees()) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.USDC, EvmToken.BRLA, { cleanup: false })) + .pipe(AveniaOfframpFee()) + .pipe(OfframpSubsidizePost()) + .pipe(AveniaOfframpPayout) + .build("BrlOfframpBase"); +} + +export const brlOfframpBaseFlow = makeBrlOfframpBaseFlow(EvmToken.USDC, Networks.Base); +export const brlOfframpBasePhaseFlow = assemblePhaseFlow(brlOfframpBaseFlow); diff --git a/apps/api/src/api/services/phases/blocks/flows/brl-onramp-assethub-usdc.ts b/apps/api/src/api/services/phases/blocks/flows/brl-onramp-assethub-usdc.ts new file mode 100644 index 000000000..fe5b61af0 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/brl-onramp-assethub-usdc.ts @@ -0,0 +1,21 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { AveniaMoonbeamMint } from "../phases/avenia-moonbeam-mint"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { MoonbeamToPendulumXcm } from "../phases/moonbeam-to-pendulum-xcm"; +import { PendulumDistributeFees } from "../phases/pendulum-distribute-fees"; +import { PendulumNablaSwap } from "../phases/pendulum-nabla-swap"; +import { PendulumSubsidizePost } from "../phases/pendulum-subsidize-post"; +import { PendulumSubsidizePre } from "../phases/pendulum-subsidize-pre"; +import { PendulumToAssethubXcm } from "../phases/pendulum-to-assethub-xcm"; + +export const brlOnrampAssethubUsdcFlow = FlowBuilder.start(fiatRequestIO(FiatToken.BRL), AveniaMoonbeamMint) + .pipe(FundEphemeral(EvmToken.BRLA, Networks.Moonbeam)) + .pipe(MoonbeamToPendulumXcm) + .pipe(PendulumSubsidizePre) + .pipe(PendulumNablaSwap) + .pipe(PendulumDistributeFees) + .pipe(PendulumSubsidizePost) + .pipe(PendulumToAssethubXcm) + .build("BrlOnrampAssethubUsdc"); diff --git a/apps/api/src/api/services/phases/blocks/flows/brl-onramp-base-cross-chain.ts b/apps/api/src/api/services/phases/blocks/flows/brl-onramp-base-cross-chain.ts new file mode 100644 index 000000000..320a327e0 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/brl-onramp-base-cross-chain.ts @@ -0,0 +1,36 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { ChainBrand, TokenBrand } from "../core/types"; +import { AveniaMint } from "../phases/avenia-mint"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { DistributeFees } from "../phases/distribute-fees"; +import { FinalSettlementSubsidy } from "../phases/final-settlement-subsidy"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { NablaSwap } from "../phases/nabla-swap"; +import { SquidRouterSwap } from "../phases/squid-router-swap"; +import { SubsidizePost } from "../phases/subsidize-post"; +import { SubsidizePre } from "../phases/subsidize-pre"; + +// The destination chain/token vary per request (quote.to / quote.outputCurrency), so the corridor +// is a flow family: one factory, one flow instance per destination. The RampPhase[] shape is +// identical for every destination. +export function makeBrlOnrampBaseCrossChainFlow( + toChain: ToChain, + toToken: ToToken +) { + return FlowBuilder.start(fiatRequestIO(FiatToken.BRL), AveniaMint) + .pipe(FundEphemeral(EvmToken.BRLA, Networks.Base)) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.BRLA, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(SquidRouterSwap(Networks.Base, toChain, EvmToken.USDC, toToken)) + .pipe(FinalSettlementSubsidy()) + .pipe(DestinationTransfer()) + .build("BrlOnrampBaseCrossChain", { isDirectTransfer: false }); +} + +export const brlOnrampBaseCrossChainFlow = makeBrlOnrampBaseCrossChainFlow(Networks.Arbitrum, EvmToken.USDC); +export const brlOnrampBaseCrossChainPhaseFlow = assemblePhaseFlow(brlOnrampBaseCrossChainFlow); diff --git a/apps/api/src/api/services/phases/blocks/flows/brl-onramp-base-direct.ts b/apps/api/src/api/services/phases/blocks/flows/brl-onramp-base-direct.ts new file mode 100644 index 000000000..0de168b66 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/brl-onramp-base-direct.ts @@ -0,0 +1,14 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { AveniaDirectMint } from "../phases/avenia-direct-mint"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { FundEphemeral } from "../phases/fund-ephemeral"; + +export const brlOnrampBaseDirectFlow = FlowBuilder.start(fiatRequestIO(FiatToken.BRL), AveniaDirectMint) + .pipe(FundEphemeral(EvmToken.BRLA, Networks.Base)) + .pipe(DestinationTransfer()) + .build("BrlOnrampBaseDirect", { isDirectTransfer: true }); + +export const brlOnrampBaseDirectPhaseFlow = assemblePhaseFlow(brlOnrampBaseDirectFlow); diff --git a/apps/api/src/api/services/phases/blocks/flows/brl-onramp-base-same-chain.ts b/apps/api/src/api/services/phases/blocks/flows/brl-onramp-base-same-chain.ts new file mode 100644 index 000000000..f930b10ae --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/brl-onramp-base-same-chain.ts @@ -0,0 +1,37 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { TokenBrand } from "../core/types"; +import { AveniaMint } from "../phases/avenia-mint"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { DistributeFees } from "../phases/distribute-fees"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { NablaSwap } from "../phases/nabla-swap"; +import { SameChainSquidRouterSwap } from "../phases/squid-router-swap"; +import { SubsidizePost } from "../phases/subsidize-post"; +import { SubsidizePre } from "../phases/subsidize-pre"; + +function baseSwapFlow() { + return FlowBuilder.start(fiatRequestIO(FiatToken.BRL), AveniaMint) + .pipe(FundEphemeral(EvmToken.BRLA, Networks.Base)) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.BRLA, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()); +} + +export const brlOnrampBaseSameChainFlow = baseSwapFlow() + .pipe(DestinationTransfer()) + .build("BrlOnrampBaseSameChain", { isDirectTransfer: false }); + +export function makeBrlOnrampBaseSameChainSwapFlow(toToken: ToToken) { + return baseSwapFlow() + .pipe(SameChainSquidRouterSwap(Networks.Base, EvmToken.USDC, toToken)) + .pipe(DestinationTransfer()) + .build("BrlOnrampBaseSameChainSwap", { isDirectTransfer: false }); +} + +export const brlOnrampBaseSameChainSwapFlow = makeBrlOnrampBaseSameChainSwapFlow(EvmToken.USDT); +export const brlOnrampBaseSameChainPhaseFlow = assemblePhaseFlow(brlOnrampBaseSameChainFlow); +export const brlOnrampBaseSameChainSwapPhaseFlow = assemblePhaseFlow(brlOnrampBaseSameChainSwapFlow); diff --git a/apps/api/src/api/services/phases/blocks/flows/catalog.ts b/apps/api/src/api/services/phases/blocks/flows/catalog.ts new file mode 100644 index 000000000..d01af94f2 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/catalog.ts @@ -0,0 +1,381 @@ +import { + AssetHubToken, + EPaymentMethod, + EvmToken, + evmTokenConfig, + FiatToken, + getNetworkFromDestination, + isAlfredpayToken, + isEvmToken, + isNetworkEVM, + mapFiatToDestination, + Networks, + RampDirection +} from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { APIError } from "../../../../errors/api-error"; +import type { FlowIdentity } from "../core/identity"; +import { assertFlowIdentity } from "../core/identity"; +import type { FlowMetadata } from "../core/metadata"; +import { getFlowMetadata } from "../core/metadata"; +import type { Flow } from "../core/types"; +import { alfredpayOfframpFlow, makeAlfredpayOfframpFlow } from "./alfredpay-offramp"; +import { alfredpayOnrampCrossChainFlow, makeAlfredpayOnrampCrossChainFlow } from "./alfredpay-onramp-cross-chain"; +import { alfredpayOnrampDirectFlow, makeAlfredpayOnrampDirectFlow } from "./alfredpay-onramp-direct"; +import { brlOfframpAssethubUsdcFlow } from "./brl-offramp-assethub-usdc"; +import { brlOfframpBaseFlow, makeBrlOfframpBaseFlow } from "./brl-offramp-base"; +import { brlOnrampAssethubUsdcFlow } from "./brl-onramp-assethub-usdc"; +import { brlOnrampBaseCrossChainFlow, makeBrlOnrampBaseCrossChainFlow } from "./brl-onramp-base-cross-chain"; +import { brlOnrampBaseDirectFlow } from "./brl-onramp-base-direct"; +import { + brlOnrampBaseSameChainFlow, + brlOnrampBaseSameChainSwapFlow, + makeBrlOnrampBaseSameChainSwapFlow +} from "./brl-onramp-base-same-chain"; +import { eurOfframpBaseFlow, makeEurOfframpBaseFlow } from "./eur-offramp-base"; +import { eurOnrampBaseCrossChainFlow, makeEurOnrampBaseCrossChainFlow } from "./eur-onramp-base-cross-chain"; +import { eurOnrampBaseDirectFlow } from "./eur-onramp-base-direct"; +import { + eurOnrampBaseSameChainFlow, + eurOnrampBaseSameChainSwapFlow, + makeEurOnrampBaseSameChainSwapFlow +} from "./eur-onramp-base-same-chain"; + +type FlowRequest = FlowMetadata["globals"]["request"]; + +interface FlowDefinition { + create(request: FlowRequest): Flow; + executorFlow: Flow; + matches(request: FlowRequest): boolean; +} + +const flowDefinitions: FlowDefinition[] = [ + { + create() { + return brlOfframpAssethubUsdcFlow; + }, + executorFlow: brlOfframpAssethubUsdcFlow, + matches(request) { + return ( + request.rampType === RampDirection.SELL && + request.from === Networks.AssetHub && + request.network === Networks.AssetHub && + request.inputCurrency === AssetHubToken.USDC && + request.outputCurrency === FiatToken.BRL && + request.to === EPaymentMethod.PIX + ); + } + }, + { + create() { + return brlOnrampAssethubUsdcFlow; + }, + executorFlow: brlOnrampAssethubUsdcFlow, + matches(request) { + return ( + request.rampType === RampDirection.BUY && + request.from === EPaymentMethod.PIX && + request.inputCurrency === FiatToken.BRL && + request.outputCurrency === AssetHubToken.USDC && + getNetworkFromDestination(request.to) === Networks.AssetHub + ); + } + }, + { + create(request) { + const network = getNetworkFromDestination(request.from); + if (!network || !isNetworkEVM(network) || !isEvmToken(request.inputCurrency)) { + throw new APIError({ message: "Unsupported EVM source for EUR offramp", status: httpStatus.BAD_REQUEST }); + } + return makeEurOfframpBaseFlow(request.inputCurrency, network); + }, + executorFlow: eurOfframpBaseFlow, + matches(request) { + const network = getNetworkFromDestination(request.from); + return ( + request.rampType === RampDirection.SELL && + request.outputCurrency === FiatToken.EURC && + request.to === EPaymentMethod.SEPA && + network !== undefined && + isNetworkEVM(network) && + isEvmToken(request.inputCurrency) && + evmTokenConfig[network][request.inputCurrency] !== undefined + ); + } + }, + { + create(request) { + const network = getNetworkFromDestination(request.from); + if (!network || !isNetworkEVM(network) || !isEvmToken(request.inputCurrency)) { + throw new APIError({ message: "Unsupported EVM source for BRL offramp", status: httpStatus.BAD_REQUEST }); + } + return makeBrlOfframpBaseFlow(request.inputCurrency, network); + }, + executorFlow: brlOfframpBaseFlow, + matches(request) { + const network = getNetworkFromDestination(request.from); + return ( + request.rampType === RampDirection.SELL && + request.outputCurrency === FiatToken.BRL && + network !== undefined && + isNetworkEVM(network) && + isEvmToken(request.inputCurrency) && + evmTokenConfig[network][request.inputCurrency] !== undefined + ); + } + }, + { + create(request) { + const network = getNetworkFromDestination(request.from); + if (!network || !isNetworkEVM(network)) { + throw new APIError({ message: `Unsupported Alfredpay source: ${request.from}`, status: httpStatus.BAD_REQUEST }); + } + return makeAlfredpayOfframpFlow(request.inputCurrency as EvmToken, network); + }, + executorFlow: alfredpayOfframpFlow, + matches(request) { + const network = getNetworkFromDestination(request.from); + return ( + request.rampType === RampDirection.SELL && + isAlfredpayToken(request.outputCurrency) && + request.to === mapFiatToDestination(request.outputCurrency as FiatToken) && + network !== undefined && + isNetworkEVM(network) && + evmTokenConfig[network][request.inputCurrency as EvmToken] !== undefined + ); + } + }, + { + create() { + return eurOnrampBaseDirectFlow; + }, + executorFlow: eurOnrampBaseDirectFlow, + matches(request) { + return ( + request.rampType === RampDirection.BUY && + request.from === EPaymentMethod.SEPA && + request.inputCurrency === FiatToken.EURC && + request.outputCurrency === EvmToken.EURC && + getNetworkFromDestination(request.to) === Networks.Base + ); + } + }, + { + create() { + return eurOnrampBaseSameChainFlow; + }, + executorFlow: eurOnrampBaseSameChainFlow, + matches(request) { + return ( + request.rampType === RampDirection.BUY && + request.from === EPaymentMethod.SEPA && + request.inputCurrency === FiatToken.EURC && + request.outputCurrency === EvmToken.USDC && + getNetworkFromDestination(request.to) === Networks.Base + ); + } + }, + { + create(request) { + return makeEurOnrampBaseSameChainSwapFlow(request.outputCurrency); + }, + executorFlow: eurOnrampBaseSameChainSwapFlow, + matches(request) { + return ( + request.rampType === RampDirection.BUY && + request.from === EPaymentMethod.SEPA && + request.inputCurrency === FiatToken.EURC && + request.outputCurrency !== EvmToken.EURC && + request.outputCurrency !== EvmToken.USDC && + evmTokenConfig[Networks.Base][request.outputCurrency as EvmToken] !== undefined && + getNetworkFromDestination(request.to) === Networks.Base + ); + } + }, + { + create(request) { + const network = getNetworkFromDestination(request.to); + if (!network) { + throw new APIError({ message: `Unsupported destination: ${request.to}`, status: httpStatus.BAD_REQUEST }); + } + return makeEurOnrampBaseCrossChainFlow(network, request.outputCurrency); + }, + executorFlow: eurOnrampBaseCrossChainFlow, + matches(request) { + const network = getNetworkFromDestination(request.to); + return ( + request.rampType === RampDirection.BUY && + request.from === EPaymentMethod.SEPA && + request.inputCurrency === FiatToken.EURC && + network !== undefined && + network !== Networks.Base && + isNetworkEVM(network) + ); + } + }, + { + create(request) { + return makeAlfredpayOnrampDirectFlow(request.outputCurrency); + }, + executorFlow: alfredpayOnrampDirectFlow, + matches(request) { + return ( + request.rampType === RampDirection.BUY && + isAlfredpayToken(request.inputCurrency) && + request.from === mapFiatToDestination(request.inputCurrency) && + evmTokenConfig[Networks.Polygon][request.outputCurrency as EvmToken] !== undefined && + getNetworkFromDestination(request.to) === Networks.Polygon + ); + } + }, + { + create() { + return brlOnrampBaseSameChainFlow; + }, + executorFlow: brlOnrampBaseSameChainFlow, + matches(request) { + return ( + request.rampType === RampDirection.BUY && + request.inputCurrency === FiatToken.BRL && + request.from === mapFiatToDestination(FiatToken.BRL) && + request.outputCurrency === EvmToken.USDC && + getNetworkFromDestination(request.to) === Networks.Base + ); + } + }, + { + create(request) { + return makeBrlOnrampBaseSameChainSwapFlow(request.outputCurrency); + }, + executorFlow: brlOnrampBaseSameChainSwapFlow, + matches(request) { + return ( + request.rampType === RampDirection.BUY && + request.inputCurrency === FiatToken.BRL && + request.from === mapFiatToDestination(FiatToken.BRL) && + request.outputCurrency !== EvmToken.BRLA && + request.outputCurrency !== EvmToken.USDC && + evmTokenConfig[Networks.Base][request.outputCurrency as EvmToken] !== undefined && + getNetworkFromDestination(request.to) === Networks.Base + ); + } + }, + { + create() { + return brlOnrampBaseDirectFlow; + }, + executorFlow: brlOnrampBaseDirectFlow, + matches(request) { + return ( + request.rampType === RampDirection.BUY && + request.inputCurrency === FiatToken.BRL && + request.from === mapFiatToDestination(FiatToken.BRL) && + request.outputCurrency === EvmToken.BRLA && + getNetworkFromDestination(request.to) === Networks.Base + ); + } + }, + { + create(request) { + const network = getNetworkFromDestination(request.to); + if (!network) { + throw new APIError({ message: `Unsupported destination: ${request.to}`, status: httpStatus.BAD_REQUEST }); + } + return makeAlfredpayOnrampCrossChainFlow(network, request.outputCurrency); + }, + executorFlow: alfredpayOnrampCrossChainFlow, + matches(request) { + const network = getNetworkFromDestination(request.to); + return ( + request.rampType === RampDirection.BUY && + isAlfredpayToken(request.inputCurrency) && + network !== undefined && + network !== Networks.Polygon && + isNetworkEVM(network) + ); + } + }, + { + create(request) { + const network = getNetworkFromDestination(request.to); + if (!network) { + throw new APIError({ message: `Unsupported destination: ${request.to}`, status: httpStatus.BAD_REQUEST }); + } + return makeBrlOnrampBaseCrossChainFlow(network, request.outputCurrency); + }, + executorFlow: brlOnrampBaseCrossChainFlow, + matches(request) { + const network = getNetworkFromDestination(request.to); + return ( + request.rampType === RampDirection.BUY && + request.inputCurrency === FiatToken.BRL && + network !== undefined && + network !== Networks.Base && + isNetworkEVM(network) + ); + } + } +]; + +export function resolveBlockFlow(request: FlowRequest): Flow { + const definitions = flowDefinitions.filter(candidate => candidate.matches(request)); + if (definitions.length === 0) { + throw new APIError({ + message: `No block flow mapped for ${request.rampType} ${request.from}/${request.inputCurrency} -> ${request.to}/${request.outputCurrency}`, + status: httpStatus.BAD_REQUEST + }); + } + if (definitions.length > 1) { + throw new APIError({ + message: `Ambiguous block flow mapping for ${request.rampType} ${request.from}/${request.inputCurrency} -> ${request.to}/${request.outputCurrency}: ${definitions + .map(definition => definition.executorFlow.identity.id) + .join(", ")}`, + status: httpStatus.INTERNAL_SERVER_ERROR + }); + } + return definitions[0].create(request); +} + +export function resolvePersistedBlockFlow(metadataValue: unknown): Flow { + const metadata = getFlowMetadata(metadataValue); + if (!metadata.flow) { + const legacyFlow = resolveBlockFlow(metadata.globals.request); + legacyFlow.assertMetadata(metadata, { allowLegacy: true }); + return legacyFlow; + } + + const candidates = flowDefinitions.filter(definition => { + const identity = definition.executorFlow.identity; + return ( + identity.id === metadata.flow?.id && + identity.version === metadata.flow.version && + identity.catalogVersion === metadata.flow.catalogVersion && + definition.matches(metadata.globals.request) + ); + }); + if (candidates.length !== 1) { + throw new Error( + `Unsupported or ambiguous persisted flow ${metadata.flow.id}@${metadata.flow.version} for catalog ${metadata.flow.catalogVersion}` + ); + } + const flow = candidates[0].create(metadata.globals.request); + assertFlowIdentity(metadata.flow, flow.identity); + flow.assertMetadata(metadata); + return flow; +} + +export function getBlockFlowByIdentity(identity: FlowIdentity): Flow { + const candidates = getBlockExecutorFlows().filter( + flow => flow.identity.id === identity.id && flow.identity.version === identity.version + ); + const unique = [...new Map(candidates.map(flow => [`${flow.identity.id}@${flow.identity.version}`, flow])).values()]; + if (unique.length !== 1) { + throw new Error(`Unsupported persisted flow ${identity.id}@${identity.version}`); + } + assertFlowIdentity(identity, unique[0].identity); + return unique[0]; +} + +export function getBlockExecutorFlows(): Flow[] { + return flowDefinitions.map(definition => definition.executorFlow); +} diff --git a/apps/api/src/api/services/phases/blocks/flows/eur-offramp-base.ts b/apps/api/src/api/services/phases/blocks/flows/eur-offramp-base.ts new file mode 100644 index 000000000..359a08575 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/eur-offramp-base.ts @@ -0,0 +1,25 @@ +import { type EvmNetworks, EvmToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { evmRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { DistributeFees } from "../phases/distribute-fees"; +import { EvmOfframpSource } from "../phases/evm-offramp-source"; +import { MykoboOfframpFee } from "../phases/mykobo-offramp-fee"; +import { MykoboOfframpPayout } from "../phases/mykobo-offramp-payout"; +import { NablaSwap } from "../phases/nabla-swap"; +import { OfframpSubsidizePost } from "../phases/subsidize-post"; +import { SubsidizePre } from "../phases/subsidize-pre"; + +export function makeEurOfframpBaseFlow(fromToken: EvmToken, fromNetwork: EvmNetworks) { + return FlowBuilder.start(evmRequestIO(fromToken, fromNetwork), EvmOfframpSource()) + .pipe(DistributeFees()) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.USDC, EvmToken.EURC, { cleanup: false })) + .pipe(MykoboOfframpFee()) + .pipe(OfframpSubsidizePost()) + .pipe(MykoboOfframpPayout) + .build("EurOfframpBase"); +} + +export const eurOfframpBaseFlow = makeEurOfframpBaseFlow(EvmToken.USDC, Networks.Base); +export const eurOfframpBasePhaseFlow = assemblePhaseFlow(eurOfframpBaseFlow); diff --git a/apps/api/src/api/services/phases/blocks/flows/eur-onramp-base-cross-chain.ts b/apps/api/src/api/services/phases/blocks/flows/eur-onramp-base-cross-chain.ts new file mode 100644 index 000000000..38dabd6da --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/eur-onramp-base-cross-chain.ts @@ -0,0 +1,33 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { ChainBrand, TokenBrand } from "../core/types"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { DistributeFees } from "../phases/distribute-fees"; +import { FinalSettlementSubsidy } from "../phases/final-settlement-subsidy"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { MykoboMint } from "../phases/mykobo-mint"; +import { NablaSwap } from "../phases/nabla-swap"; +import { SquidRouterSwap } from "../phases/squid-router-swap"; +import { SubsidizePost } from "../phases/subsidize-post"; +import { SubsidizePre } from "../phases/subsidize-pre"; + +export function makeEurOnrampBaseCrossChainFlow( + toChain: ToChain, + toToken: ToToken +) { + return FlowBuilder.start(fiatRequestIO(FiatToken.EURC), MykoboMint) + .pipe(FundEphemeral(EvmToken.EURC, Networks.Base)) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()) + .pipe(SquidRouterSwap(Networks.Base, toChain, EvmToken.USDC, toToken)) + .pipe(FinalSettlementSubsidy()) + .pipe(DestinationTransfer()) + .build("EurOnrampBaseCrossChain", { isDirectTransfer: false }); +} + +export const eurOnrampBaseCrossChainFlow = makeEurOnrampBaseCrossChainFlow(Networks.Arbitrum, EvmToken.USDC); +export const eurOnrampBaseCrossChainPhaseFlow = assemblePhaseFlow(eurOnrampBaseCrossChainFlow); diff --git a/apps/api/src/api/services/phases/blocks/flows/eur-onramp-base-direct.ts b/apps/api/src/api/services/phases/blocks/flows/eur-onramp-base-direct.ts new file mode 100644 index 000000000..48bbdea7f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/eur-onramp-base-direct.ts @@ -0,0 +1,14 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { MykoboMint } from "../phases/mykobo-mint"; + +export const eurOnrampBaseDirectFlow = FlowBuilder.start(fiatRequestIO(FiatToken.EURC), MykoboMint) + .pipe(FundEphemeral(EvmToken.EURC, Networks.Base)) + .pipe(DestinationTransfer()) + .build("EurOnrampBaseDirect", { isDirectTransfer: true }); + +export const eurOnrampBaseDirectPhaseFlow = assemblePhaseFlow(eurOnrampBaseDirectFlow); diff --git a/apps/api/src/api/services/phases/blocks/flows/eur-onramp-base-same-chain.ts b/apps/api/src/api/services/phases/blocks/flows/eur-onramp-base-same-chain.ts new file mode 100644 index 000000000..01dd30819 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/eur-onramp-base-same-chain.ts @@ -0,0 +1,37 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { FlowBuilder } from "../core/flow"; +import { fiatRequestIO } from "../core/io"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import type { TokenBrand } from "../core/types"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { DistributeFees } from "../phases/distribute-fees"; +import { FundEphemeral } from "../phases/fund-ephemeral"; +import { MykoboMint } from "../phases/mykobo-mint"; +import { NablaSwap } from "../phases/nabla-swap"; +import { SameChainSquidRouterSwap } from "../phases/squid-router-swap"; +import { SubsidizePost } from "../phases/subsidize-post"; +import { SubsidizePre } from "../phases/subsidize-pre"; + +function baseSwapFlow() { + return FlowBuilder.start(fiatRequestIO(FiatToken.EURC), MykoboMint) + .pipe(FundEphemeral(EvmToken.EURC, Networks.Base)) + .pipe(SubsidizePre()) + .pipe(NablaSwap(Networks.Base, EvmToken.EURC, EvmToken.USDC)) + .pipe(DistributeFees()) + .pipe(SubsidizePost()); +} + +export const eurOnrampBaseSameChainFlow = baseSwapFlow() + .pipe(DestinationTransfer()) + .build("EurOnrampBaseSameChain", { isDirectTransfer: false }); + +export function makeEurOnrampBaseSameChainSwapFlow(toToken: ToToken) { + return baseSwapFlow() + .pipe(SameChainSquidRouterSwap(Networks.Base, EvmToken.USDC, toToken)) + .pipe(DestinationTransfer()) + .build("EurOnrampBaseSameChainSwap", { isDirectTransfer: false }); +} + +export const eurOnrampBaseSameChainSwapFlow = makeEurOnrampBaseSameChainSwapFlow(EvmToken.USDT); +export const eurOnrampBaseSameChainPhaseFlow = assemblePhaseFlow(eurOnrampBaseSameChainFlow); +export const eurOnrampBaseSameChainSwapPhaseFlow = assemblePhaseFlow(eurOnrampBaseSameChainSwapFlow); diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/execution.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/execution.ts new file mode 100644 index 000000000..108be1254 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/execution.ts @@ -0,0 +1,126 @@ +import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ERC20_TOKEN, + AlfredpayApiService, + AlfredpayOnrampStatus, + BalanceCheckError, + BalanceCheckErrorType, + checkEvmBalancePeriodically, + Networks, + RampPhase, + sleep +} from "@vortexfi/shared"; +import logger from "../../../../../../config/logger"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { StateMetadata } from "../../../../phases/meta-state-types"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { getBlockMetadata } from "../../core/metadata"; +import { isAnchorMockingEnabled } from "../anchor-test-mode"; +import { AlfredpayMintContext } from "./simulation"; + +const MINT_TIMEOUT_MS = 5 * 60 * 1000; +const POLL_INTERVAL_MS = 5000; + +type AlfredpayFailedStatusError = { failureReason?: string; kind: "failed" }; + +function isAlfredpayFailedStatusError(error: unknown): error is AlfredpayFailedStatusError { + return !!error && typeof error === "object" && "kind" in error && error.kind === "failed"; +} + +export class AlfredpayOnrampMintExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "alfredpayOnrampMint"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const { evmEphemeralAddress, alfredpayTransactionId } = state.state as StateMetadata; + if (!evmEphemeralAddress || !alfredpayTransactionId) { + throw new Error("AlfredpayOnrampMintExecutor: Missing ephemeral address or Alfredpay transaction ID"); + } + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("AlfredpayOnrampMintExecutor: Quote not found"); + } + const metadata = getBlockMetadata(quote.metadata, AlfredpayMintContext); + if (isAnchorMockingEnabled()) { + logger.warn( + `AlfredpayOnrampMintExecutor: Mocking AlfredPay mint; send ${metadata.outputAmountRaw} raw tokens on ${Networks.Polygon} to ${evmEphemeralAddress}` + ); + try { + await checkEvmBalancePeriodically( + ALFREDPAY_ERC20_TOKEN, + evmEphemeralAddress, + metadata.outputAmountRaw, + POLL_INTERVAL_MS, + MINT_TIMEOUT_MS, + Networks.Polygon, + signal + ); + } catch (error) { + if (error instanceof BalanceCheckError && error.type === BalanceCheckErrorType.Timeout) { + throw this.createRecoverableError(`AlfredpayOnrampMintExecutor: Mock mint balance check timed out: ${error}`); + } + throw error; + } + return state; + } + + const abortController = new AbortController(); + const pollingSignal = signal ? AbortSignal.any([signal, abortController.signal]) : abortController.signal; + try { + await Promise.race([ + checkEvmBalancePeriodically( + ALFREDPAY_ERC20_TOKEN, + evmEphemeralAddress, + metadata.outputAmountRaw, + POLL_INTERVAL_MS, + MINT_TIMEOUT_MS, + Networks.Polygon, + pollingSignal + ), + this.pollStatus(alfredpayTransactionId, state, POLL_INTERVAL_MS, pollingSignal) + ]); + } catch (error) { + if (isAlfredpayFailedStatusError(error)) { + logger.error(`AlfredpayOnrampMintExecutor: Alfredpay onramp failed: ${error.failureReason ?? "unknown"}`); + return this.transitionToNextPhase(state, "failed"); + } + if (error instanceof BalanceCheckError && error.type === BalanceCheckErrorType.Timeout) { + throw this.createRecoverableError(`AlfredpayOnrampMintExecutor: Balance check timed out after ${MINT_TIMEOUT_MS}ms`); + } + throw this.createRecoverableError( + `AlfredpayOnrampMintExecutor: Failed to check ${ALFREDPAY_ERC20_DECIMALS}-decimal mint balance or status: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + abortController.abort(); + } + return state; + } + + private async pollStatus(transactionId: string, state: RampState, intervalMs: number, signal: AbortSignal): Promise { + while (true) { + throwIfAborted(signal); + try { + const { status, metadata } = await abortableCall(signal, () => + AlfredpayApiService.getInstance().getOnrampTransaction(transactionId) + ); + if (status === AlfredpayOnrampStatus.FAILED) { + throw { failureReason: metadata?.failureReason, kind: "failed" as const }; + } + if (status === AlfredpayOnrampStatus.ON_CHAIN_COMPLETED) { + const currentState = state.state as StateMetadata; + if (metadata?.txHash && !currentState.alfredpayOnrampMintTxHash) { + await state.update({ state: { ...currentState, alfredpayOnrampMintTxHash: metadata.txHash } }); + } + } + } catch (error) { + if (isAlfredpayFailedStatusError(error)) throw error; + throwIfAborted(signal); + logger.warn(`AlfredpayOnrampMintExecutor: Error polling Alfredpay status: ${error}`); + } + await sleep(intervalMs, signal); + } + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/index.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/index.ts new file mode 100644 index 000000000..2bb7b062b --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/index.ts @@ -0,0 +1,42 @@ +import { ALFREDPAY_EVM_TOKEN, Networks } from "@vortexfi/shared"; +import type { Phase, PhaseIO } from "../../core/types"; +import { AlfredpayOnrampMintExecutor } from "./execution"; +import { startAlfredpayMint } from "./lifecycle"; +import { type AlfredpayMintRegistrationFacts, registerAlfredpayMint } from "./registration"; +import { AlfredpayMintContext, type AlfredpayOnrampFiat, simulateAlfredpayMint } from "./simulation"; +import { prepareAlfredpayMintTxs } from "./transactions"; + +export const AlfredpayMint: Phase< + typeof AlfredpayMintContext, + PhaseIO, + PhaseIO, + AlfredpayMintRegistrationFacts +> = { + context: AlfredpayMintContext, + executors: [new AlfredpayOnrampMintExecutor()], + externalOperations: { + start: { + provider: "alfredpay", + // The start call refreshes provider quoteId/expiration and persists them + // in its own result. Fingerprint only invariant financial inputs so a + // confirmed replay returns that result instead of conflicting with the + // metadata mutation caused by the first call. + request: ctx => ({ + destinationAddress: ctx.state.destinationAddress, + fee: ctx.metadata.fee, + inputAmount: ctx.quote.inputAmount, + inputAmountRaw: ctx.metadata.inputAmountRaw, + inputCurrency: ctx.quote.inputCurrency, + outputAmountRaw: ctx.metadata.outputAmountRaw, + userId: ctx.userId, + userState: ctx.ownState + }) + } + }, + name: "AlfredpayMint", + phases: ["alfredpayOnrampMint"], + prepareTxs: prepareAlfredpayMintTxs, + register: registerAlfredpayMint, + simulate: simulateAlfredpayMint, + start: startAlfredpayMint +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/lifecycle.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/lifecycle.ts new file mode 100644 index 000000000..12437bbf0 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/lifecycle.ts @@ -0,0 +1,105 @@ +import { + ALFREDPAY_ONCHAIN_CURRENCY, + AlfredpayApiService, + AlfredpayChain, + type AlfredpayFiatCurrency, + AlfredpayPaymentMethodType, + type CreateAlfredpayOnrampRequest +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import logger from "../../../../../../config/logger"; +import { APIError } from "../../../../../errors/api-error"; +import { resolveAlfredpayCustomerId } from "../../../../quote/alfredpay-customer"; +import type { StartCtx, StartResult } from "../../core/types"; +import type { AlfredpayMintMetadata } from "./simulation"; +import type { AlfredpayMintPreparation } from "./transactions"; + +interface AlfredpayMintStartDependencies { + resolveCustomerId?: typeof resolveAlfredpayCustomerId; + service?: Pick; + sumFees?: typeof AlfredpayApiService.sumFeesByCurrency; +} + +export async function startAlfredpayMint( + ctx: StartCtx, + dependencies: AlfredpayMintStartDependencies = {} +): Promise> { + if (ctx.state.alfredpayTransactionId) { + return {}; + } + if (!ctx.metadata?.quoteId) { + throw new APIError({ message: "Missing Alfredpay quote ID in metadata", status: httpStatus.BAD_REQUEST }); + } + if (!ctx.userId) { + throw new APIError({ message: "Missing user ID in ramp state", status: httpStatus.BAD_REQUEST }); + } + if (!ctx.state.destinationAddress) { + throw new APIError({ message: "Destination address not found in ramp state", status: httpStatus.BAD_REQUEST }); + } + const preparation = ctx.ownState as AlfredpayMintPreparation | undefined; + if (!preparation?.userId) { + throw new APIError({ message: "Missing Alfredpay user ID in ramp state", status: httpStatus.BAD_REQUEST }); + } + + const service = dependencies.service ?? AlfredpayApiService.getInstance(); + const fromCurrency = ctx.quote.inputCurrency as unknown as AlfredpayFiatCurrency; + const originalQuoteId = ctx.metadata.quoteId; + let effectiveQuoteId = originalQuoteId; + let metadata: AlfredpayMintMetadata | undefined; + const customerId = await (dependencies.resolveCustomerId ?? resolveAlfredpayCustomerId)(fromCurrency, ctx.userId); + + try { + const freshQuote = await service.createOnrampQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: new Big(ctx.quote.inputAmount).toString(), + fromCurrency, + metadata: { businessId: "vortex", customerId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY + }); + const originalToAmount = new Big(ctx.metadata.outputAmountDecimal as unknown as string); + const freshToAmount = new Big(freshQuote.toAmount); + const originalFee = new Big(ctx.metadata.fee as unknown as string); + const freshFee = (dependencies.sumFees ?? AlfredpayApiService.sumFeesByCurrency)(freshQuote.fees, fromCurrency); + if (!freshToAmount.eq(originalToAmount) || !freshFee.eq(originalFee)) { + logger.warn( + `[startAlfredpayMint] Quote ${ctx.quote.id}: refreshed Alfredpay quote drifted. ` + + `toAmount original=${originalToAmount.toString()} fresh=${freshToAmount.toString()}, ` + + `fee original=${originalFee.toString()} fresh=${freshFee.toString()}. ` + + `Falling back to original quoteId ${originalQuoteId}.` + ); + } else { + effectiveQuoteId = freshQuote.quoteId; + metadata = { ...ctx.metadata, expirationDate: new Date(freshQuote.expiration), quoteId: freshQuote.quoteId }; + logger.info( + `[startAlfredpayMint] Quote ${ctx.quote.id}: swapped Alfredpay quote ${originalQuoteId} -> ${freshQuote.quoteId}.` + ); + } + } catch (error) { + logger.warn( + `[startAlfredpayMint] Quote ${ctx.quote.id}: refresh failed (${error instanceof Error ? error.message : String(error)}). ` + + `Falling back to original quoteId ${originalQuoteId}.` + ); + } + + const orderRequest: CreateAlfredpayOnrampRequest = { + amount: ctx.quote.inputAmount, + chain: AlfredpayChain.MATIC, + customerId: preparation.userId, + depositAddress: ctx.state.evmEphemeralAddress, + fromCurrency, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + quoteId: effectiveQuoteId, + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY + }; + const order = await service.createOnramp(orderRequest); + return { + metadata, + responseArtifacts: { achPaymentData: order.fiatPaymentInstructions }, + state: { + alfredpayTransactionId: order.transaction.transactionId, + fiatPaymentInstructions: order.fiatPaymentInstructions + } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/registration.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/registration.ts new file mode 100644 index 000000000..f8d06f5c7 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/registration.ts @@ -0,0 +1,17 @@ +import { type AlfredpayFiatCurrency } from "@vortexfi/shared"; +import { resolveAlfredpayCustomerId } from "../../../../quote/alfredpay-customer"; +import type { RegisterCtx, RegistrationResult } from "../../core/types"; +import type { AlfredpayMintMetadata } from "./simulation"; + +export type AlfredpayMintRegistrationFacts = { userId: string }; + +export async function registerAlfredpayMint( + ctx: RegisterCtx, + dependencies: { resolveCustomerId?: typeof resolveAlfredpayCustomerId } = {} +): Promise> { + const userId = await (dependencies.resolveCustomerId ?? resolveAlfredpayCustomerId)( + ctx.metadata.currency as unknown as AlfredpayFiatCurrency, + ctx.authenticatedUser.id + ); + return { facts: { userId } }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts new file mode 100644 index 000000000..3598892d2 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts @@ -0,0 +1,77 @@ +import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_EVM_TOKEN, + ALFREDPAY_ONCHAIN_CURRENCY, + AlfredpayApiService, + AlfredpayChain, + AlfredpayFiatCurrency, + AlfredpayPaymentMethodType, + CreateAlfredpayOnrampQuoteRequest, + FiatToken, + multiplyByPowerOfTen, + Networks, + RampCurrency +} from "@vortexfi/shared"; +import Big from "big.js"; +import { resolveAlfredpayQuoteCustomerId } from "../../../../quote/alfredpay-customer"; +import { calculateFees } from "../../core/fees"; +import { evmIO } from "../../core/io"; +import { defineContext, type SerializableBig } from "../../core/metadata"; +import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; + +export type AlfredpayOnrampFiat = typeof FiatToken.USD | typeof FiatToken.MXN | typeof FiatToken.COP | typeof FiatToken.ARS; + +export interface AlfredpayMintMetadata { + currency: AlfredpayOnrampFiat; + expirationDate: Date; + fee: SerializableBig; + inputAmountDecimal: SerializableBig; + inputAmountRaw: string; + outputAmountDecimal: SerializableBig; + outputAmountRaw: string; + quoteId: string; +} + +export const AlfredpayMintContext = defineContext()("alfredpayMint"); + +export async function simulateAlfredpayMint( + input: PhaseIO, + ctx: PhaseCtx +): Promise, AlfredpayMintMetadata>> { + const customerId = await resolveAlfredpayQuoteCustomerId(input.token, ctx.request.userId); + const quoteRequest: CreateAlfredpayOnrampQuoteRequest = { + chain: AlfredpayChain.MATIC, + fromAmount: input.amount.toString(), + fromCurrency: input.token as unknown as AlfredpayFiatCurrency, + metadata: { businessId: "vortex", customerId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY + }; + const quote = await AlfredpayApiService.getInstance().createOnrampQuote(quoteRequest); + const inputAmountDecimal = new Big(quote.fromAmount); + const outputAmountDecimal = new Big(quote.toAmount); + const fee = AlfredpayApiService.sumFeesByCurrency(quote.fees, input.token as unknown as AlfredpayFiatCurrency); + const outputAmountRaw = multiplyByPowerOfTen(outputAmountDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0); + const fees = await calculateFees(ctx, { + anchor: { amount: fee.toString(), currency: input.token as RampCurrency }, + network: { amount: "0", currency: ALFREDPAY_EVM_TOKEN as RampCurrency } + }); + + ctx.addNote(`AlfredpayMint: ${input.amount.toFixed()} ${input.token} -> ${outputAmountDecimal.toFixed()} USDT on Polygon`); + const expiresAt = new Date(quote.expiration); + return { + expiresAt, + fees, + metadata: { + currency: input.token, + expirationDate: expiresAt, + fee, + inputAmountDecimal, + inputAmountRaw: multiplyByPowerOfTen(inputAmountDecimal, 2).toFixed(0, 0), + outputAmountDecimal, + outputAmountRaw, + quoteId: quote.quoteId + }, + output: evmIO(ALFREDPAY_EVM_TOKEN, Networks.Polygon, outputAmountDecimal, outputAmountRaw) + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/transactions.ts new file mode 100644 index 000000000..40998d58a --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/transactions.ts @@ -0,0 +1,61 @@ +import { + ALFREDPAY_ERC20_TOKEN, + EphemeralAccountType, + ERC20_USDC_POLYGON, + EvmNetworks, + EvmTransactionData, + Networks +} from "@vortexfi/shared"; +import { preparePolygonCleanupApproval } from "../../../../transactions/polygon/cleanup"; +import { requireAccount } from "../../core/accounts"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { createDestinationTransferTransaction, encodeEvmTransactionData } from "../../core/evm-transactions"; +import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { AlfredpayMintRegistrationFacts } from "./registration"; +import type { AlfredpayMintMetadata } from "./simulation"; + +export interface AlfredpayMintPreparation { + userId: string; +} + +export async function prepareAlfredpayMintTxs( + ctx: PrepareCtx +): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + if (!ctx.ownRegistrationFacts) throw new Error("prepareAlfredpayMintTxs: Missing Alfredpay registration facts"); + const fundingAccount = getEvmFundingAccount(Networks.Polygon); + const cleanup = await preparePolygonCleanupApproval(ERC20_USDC_POLYGON, fundingAccount.address, Networks.Polygon); + const intents: PreparedPhaseTxs["intents"] = [ + { + lane: "cleanup", + network: Networks.Polygon, + phase: "polygonCleanup", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(cleanup) as EvmTransactionData + } + ]; + + if (ctx.globals.request.to !== Networks.Polygon) { + if (!ctx.destinationAddress) { + throw new Error("prepareAlfredpayMintTxs: Destination address is required"); + } + const fallback = await createDestinationTransferTransaction({ + amountRaw: ctx.ownMetadata.outputAmountRaw, + destinationNetwork: Networks.Polygon as EvmNetworks, + toAddress: ctx.destinationAddress, + toToken: ALFREDPAY_ERC20_TOKEN + }); + intents.push({ + lane: "cleanup", + network: Networks.Polygon, + phase: "alfredOnrampMintFallback", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(fallback) as EvmTransactionData + }); + } + + return { + intents, + state: { ...ctx.ownRegistrationFacts } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts new file mode 100644 index 000000000..30feecbbe --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts @@ -0,0 +1,560 @@ +import { + ALFREDPAY_ONCHAIN_CURRENCY, + AlfredpayApiService, + AlfredpayChain, + AlfredpayFiatCurrency, + AlfredpayOfframpStatus, + AlfredpayPaymentMethodType, + EvmClientManager, + EvmNetworks, + getNetworkFromDestination, + isNetworkEVM, + isSignedTypedDataArray, + Networks, + RampPhase, + SignedTypedData, + sleep +} from "@vortexfi/shared"; +import { erc20Abi, keccak256 } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../../../../config/logger"; +import { config } from "../../../../../../config/vars"; +import { tokenRelayerAbi } from "../../../../../../contracts/TokenRelayer"; +import RampState from "../../../../../../models/rampState.model"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { verifyUserSubmittedTxByHash } from "../../../../phases/helpers/user-tx-verifier"; +import { StateMetadata } from "../../../../phases/meta-state-types"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { ensurePresignedTransferFunded } from "../../core/destination-funding"; +import { FinancialOperationRejectedError } from "../../core/financial-operation"; +import { getAnchorPayoutMaxRetries, isAnchorMockingEnabled } from "../anchor-test-mode"; +import { FinalSettlementSubsidyExecutor } from "../final-settlement-subsidy/execution"; +import { FundEphemeralExecutor } from "../fund-ephemeral/execution"; +import { getAlfredpayRelayerAddress } from "./permit"; + +type VrsSignature = { v: number; r: `0x${string}`; s: `0x${string}` }; + +const permitAbi = [ + { + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "v", type: "uint8" }, + { name: "r", type: "bytes32" }, + { name: "s", type: "bytes32" } + ], + name: "permit", + outputs: [], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +const transferFromAbi = [ + { + inputs: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" } + ], + name: "transferFrom", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +function extractPermitFields(permitTypedData: SignedTypedData) { + const permitMessage = permitTypedData.message; + return { + deadline: BigInt(permitMessage.deadline as string), + owner: permitMessage.owner as `0x${string}`, + spender: permitMessage.spender as `0x${string}`, + token: permitTypedData.domain.verifyingContract as `0x${string}`, + value: BigInt(permitMessage.value as string) + }; +} + +export class AlfredpayOfframpPermitExecutor extends BasePhaseHandler { + private evmClientManager: EvmClientManager; + + constructor() { + super(); + this.evmClientManager = EvmClientManager.getInstance(); + } + + public getPhaseName(): RampPhase { + return "squidRouterPermitExecute"; + } + + public getMaxRetries(): number { + return 20; + } + + private async assertOwnerHasBalance( + fromNetwork: EvmNetworks, + token: `0x${string}`, + owner: `0x${string}`, + value: bigint, + signal?: AbortSignal + ): Promise { + const publicClient = this.evmClientManager.getClient(fromNetwork); + const balance = await abortableCall(signal, () => + 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 { + publicClient: this.evmClientManager.getClient(fromNetwork), + walletClient: this.evmClientManager.getWalletClient(fromNetwork, executorAccount) + }; + } + + private extractSignature(typedData: SignedTypedData, label: string): VrsSignature { + const sig = typedData.signature as VrsSignature | undefined; + if (!sig) throw this.createUnrecoverableError(`${label} signature not found`); + return sig; + } + + private async saveHashAndAwaitReceipt( + state: RampState, + hash: `0x${string}`, + fromNetwork: EvmNetworks, + label: string, + signal?: AbortSignal + ): Promise { + logger.info(`${label} tx sent: ${hash}`); + const updatedState = await state.update({ + state: { ...state.state, squidRouterPermitExecutionHash: hash } + }); + const { publicClient } = this.getExecutorClients(fromNetwork); + const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); + if (!receipt || receipt.status !== "success") throw this.createRecoverableError(`${label} tx failed: ${hash}`); + logger.info(`${label} tx confirmed: ${hash}`); + return updatedState; + } + + private async waitForUserHash( + state: RampState, + hash: `0x${string}` | undefined, + fromNetwork: EvmNetworks, + label: string, + presignedPhase: RampPhase, + signal?: AbortSignal + ): Promise { + await verifyUserSubmittedTxByHash({ fromNetwork, hash, label, presignedPhase, signal, state }); + logger.info(`${label} tx confirmed: ${hash}`); + } + + private async executeNoPermitFallback(state: RampState, fromNetwork: EvmNetworks, signal?: AbortSignal): Promise { + if (state.state.isDirectTransfer) { + await this.waitForUserHash( + state, + state.state.squidRouterNoPermitTransferHash as `0x${string}` | undefined, + fromNetwork, + "No-permit direct transfer", + "squidRouterNoPermitTransfer", + signal + ); + } else { + const hasApproveBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterNoPermitApprove"); + if (hasApproveBlueprint) { + await this.waitForUserHash( + state, + state.state.squidRouterNoPermitApproveHash as `0x${string}` | undefined, + fromNetwork, + "No-permit approve", + "squidRouterNoPermitApprove", + signal + ); + } + await this.waitForUserHash( + state, + state.state.squidRouterNoPermitSwapHash as `0x${string}` | undefined, + fromNetwork, + "No-permit swap", + "squidRouterNoPermitSwap", + signal + ); + } + return state; + } + + private async executeDirectTransfer( + state: RampState, + signedTypedDataArray: SignedTypedData[], + fromNetwork: EvmNetworks, + signal?: AbortSignal + ): Promise { + if (!isSignedTypedDataArray(signedTypedDataArray) || signedTypedDataArray.length !== 1) { + throw this.createUnrecoverableError("Invalid txData format for direct transfer: expected array of 1 SignedTypedData"); + } + + const [permitTypedData] = signedTypedDataArray; + const permitSig = this.extractSignature(permitTypedData, "Permit"); + const { token, owner, spender, value, deadline } = extractPermitFields(permitTypedData); + const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; + const { walletClient, publicClient } = this.getExecutorClients(fromNetwork); + + await this.assertOwnerHasBalance(fromNetwork, token, owner, value, signal); + const allowance = await abortableCall(signal, () => + publicClient.readContract({ + abi: erc20Abi, + address: token, + args: [owner, spender], + functionName: "allowance" + }) + ); + + if (allowance >= value) { + logger.info(`Existing allowance ${allowance} covers required ${value}, skipping permit for ramp ${state.id}`); + } else { + throwIfAborted(signal); + const permitHash = await abortableCall(signal, () => + 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 abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash: permitHash })); + if (!permitReceipt || permitReceipt.status !== "success") { + throw this.createRecoverableError(`Direct transfer permit tx failed: ${permitHash}`); + } + } + + throwIfAborted(signal); + const transferHash = await abortableCall(signal, () => + walletClient.writeContract({ + abi: transferFromAbi, + address: token, + args: [owner, ephemeralAddress, value], + functionName: "transferFrom" + }) + ); + return this.saveHashAndAwaitReceipt(state, transferHash, fromNetwork, "Direct transfer", signal); + } + + private async executeRelayerTransfer( + state: RampState, + signedTypedDataArray: SignedTypedData[], + fromNetwork: EvmNetworks, + signal?: AbortSignal + ): Promise { + if (!isSignedTypedDataArray(signedTypedDataArray) || signedTypedDataArray.length !== 2) { + throw this.createUnrecoverableError("Invalid txData format: expected array of 2 SignedTypedData objects"); + } + + const [permitTypedData, payloadTypedData] = signedTypedDataArray; + const permitSig = this.extractSignature(permitTypedData, "Permit"); + const payloadSig = this.extractSignature(payloadTypedData, "Payload"); + const { token, owner, value, deadline } = extractPermitFields(permitTypedData); + const payloadMessage = payloadTypedData.message; + const executionValue = state.state.squidRouterPermitExecutionValue; + if (executionValue === undefined || executionValue === null) { + throw this.createUnrecoverableError("Missing squidRouterPermitExecutionValue in ramp state"); + } + + await this.assertOwnerHasBalance(fromNetwork, token, owner, value, signal); + const { walletClient } = this.getExecutorClients(fromNetwork); + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + walletClient.writeContract({ + abi: tokenRelayerAbi, + address: getAlfredpayRelayerAddress(fromNetwork), + args: [ + { + deadline, + owner, + payloadData: payloadMessage.data as `0x${string}`, + payloadDeadline: BigInt(payloadMessage.deadline as string), + payloadNonce: BigInt(payloadMessage.nonce as string), + payloadR: payloadSig.r, + payloadS: payloadSig.s, + payloadV: payloadSig.v, + payloadValue: executionValue, + permitR: permitSig.r, + permitS: permitSig.s, + permitV: permitSig.v, + token, + value + } + ], + functionName: "execute", + value: BigInt(executionValue) + }) + ); + return this.saveHashAndAwaitReceipt(state, hash, fromNetwork, "Relayer execute", signal); + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + logger.info(`Executing squidRouterPermitExecute phase for ramp ${state.id}`); + const fromNetwork = getNetworkFromDestination(state.from); + if (!fromNetwork || !isNetworkEVM(fromNetwork)) { + throw this.createUnrecoverableError(`Unsupported network for squidRouterPermitExecute phase: ${state.from}`); + } + + try { + if (state.state.isNoPermitFallback) return await this.executeNoPermitFallback(state, fromNetwork, signal); + + const existingHash = state.state.squidRouterPermitExecutionHash || null; + if (existingHash) { + try { + const receipt = await abortableCall(signal, () => + this.evmClientManager.getClient(fromNetwork).waitForTransactionReceipt({ + hash: existingHash as `0x${string}` + }) + ); + if (receipt?.status === "success") return state; + } catch (error) { + throwIfAborted(signal); + logger.info(`Could not verify existing transaction status: ${error}, will retry`); + } + } + + const permitExecuteTransaction = this.getPresignedTransaction(state, "squidRouterPermitExecute"); + if (!permitExecuteTransaction) { + throw this.createUnrecoverableError("Missing presigned transaction for squidRouterPermitExecute phase"); + } + + const signedTypedDataArray = permitExecuteTransaction.txData as SignedTypedData[]; + if (state.state.isDirectTransfer) { + return await this.executeDirectTransfer(state, signedTypedDataArray, fromNetwork, signal); + } + + const executionValue = state.state.squidRouterPermitExecutionValue; + if (executionValue === undefined || executionValue === null) { + throw this.createUnrecoverableError("Missing squidRouterPermitExecutionValue in ramp state"); + } + const executionValueBigInt = BigInt(executionValue); + const maxAllowedValue = 1000000000000000000n; + if (executionValueBigInt > maxAllowedValue) { + throw this.createUnrecoverableError( + `squidRouterPermitExecutionValue ${executionValueBigInt} exceeds maximum allowed ${maxAllowedValue}` + ); + } + return await this.executeRelayerTransfer(state, signedTypedDataArray, fromNetwork, signal); + } catch (error) { + logger.error(`Error in squidRouterPermitExecute phase for ramp ${state.id}:`, error); + if (error instanceof PhaseError) throw error; + throw this.createRecoverableError( + `AlfredpayOfframpPermitExecutor: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } +} + +const ALFREDPAY_POLL_INTERVAL_MS = 30000; +const ALFREDPAY_OFFRAMP_TIMEOUT_MS = 10 * 60 * 1000; + +type AlfredpayFailedStatusError = { failureReason?: string; kind: "failed" }; + +function isAlfredpayFailedStatusError(error: unknown): error is AlfredpayFailedStatusError { + return !!error && typeof error === "object" && "kind" in error && error.kind === "failed"; +} + +function getErrorName(error: unknown): string | undefined { + return error && typeof error === "object" && "name" in error ? String(error.name) : undefined; +} + +export class AlfredpayOfframpTransferExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "alfredpayOfframpTransfer"; + } + + public getMaxRetries(): number { + return getAnchorPayoutMaxRetries(); + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + if (isAnchorMockingEnabled()) { + throw this.createRecoverableError("AlfredPay payout paused by MOCK_ANCHOR_OPERATIONS"); + } + + const { alfredpayTransactionId, alfredpayOfframpTransferTxHash } = state.state as StateMetadata; + if (!alfredpayTransactionId) throw new Error("AlfredpayOfframpTransferExecutor: Missing alfredpayTransactionId in state."); + + const alfredpayApiService = AlfredpayApiService.getInstance(); + const evmClientManager = EvmClientManager.getInstance(); + let alfredpayTx = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(alfredpayTransactionId)); + if (!alfredpayTx) { + throw new Error(`AlfredpayOfframpTransferExecutor: Transaction ${alfredpayTransactionId} not found in Alfredpay.`); + } + + if (!alfredpayOfframpTransferTxHash && new Date(alfredpayTx.expiration) < new Date()) { + const recovered = await this.recreateAlfredpayOfframp(state, alfredpayTx, signal); + if (!recovered) return this.transitionToNextPhase(state, "failed"); + alfredpayTx = recovered.alfredpayTx; + state = recovered.state; + } + + if (!alfredpayOfframpTransferTxHash) { + const { txData: offrampTransfer } = this.getPresignedTransaction(state, "alfredpayOfframpTransfer"); + try { + await ensurePresignedTransferFunded( + offrampTransfer as `0x${string}`, + Networks.Polygon as EvmNetworks, + this.getPhaseName(), + signal + ); + } catch (error) { + if (error instanceof PhaseError) throw error; + throw this.createRecoverableError( + `AlfredpayOfframpTransferExecutor: ephemeral balance does not cover the presigned final transfer: ${error instanceof Error ? error.message : String(error)}` + ); + } + const network = Networks.Polygon as EvmNetworks; + const signedTransaction = offrampTransfer as `0x${string}`; + const deterministicHash = keccak256(signedTransaction); + const networkClient = evmClientManager.getClient(network); + const { hash: txHash } = await this.runFinancialOperation(state, { + attemptClass: "alfredpay-final-transfer", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + evmClientManager.sendRawTransactionWithRetry(network, signedTransaction) + ); + return { hash }; + }, + provider: "polygon", + reconcile: async () => { + try { + const receipt = await abortableCall(signal, () => networkClient.getTransactionReceipt({ hash: deterministicHash })); + if (receipt.status !== "success") { + throw new FinancialOperationRejectedError(`Alfredpay final transfer ${deterministicHash} failed`); + } + await abortableCall(signal, () => networkClient.getTransaction({ hash: deterministicHash })); + return { hash: deterministicHash }; + } catch (error) { + throwIfAborted(signal); + if (error instanceof FinancialOperationRejectedError) throw error; + return null; + } + }, + request: { network, signedTransaction }, + signal + }); + await state.update({ state: { ...state.state, alfredpayOfframpTransferTxHash: txHash } }); + logger.info(`AlfredpayOfframpTransferExecutor: Final transfer sent. Hash: ${txHash}`); + } else { + try { + const client = evmClientManager.getClient(Networks.Polygon as EvmNetworks); + const receipt = await abortableCall(signal, () => + client.getTransactionReceipt({ hash: alfredpayOfframpTransferTxHash as `0x${string}` }) + ); + if (receipt.status !== "success") { + throw new Error( + `AlfredpayOfframpTransferExecutor: Final transfer transaction ${alfredpayOfframpTransferTxHash} failed on chain.` + ); + } + } catch (error) { + if (getErrorName(error) !== "TransactionReceiptNotFoundError") throw error; + } + } + + try { + await this.pollAlfredpayOfframpStatus(alfredpayTx.transactionId, ALFREDPAY_POLL_INTERVAL_MS, signal); + } catch (error) { + if (isAlfredpayFailedStatusError(error)) return this.transitionToNextPhase(state, "failed"); + throw this.createRecoverableError( + `AlfredpayOfframpTransferExecutor: Error polling Alfredpay status: ${error instanceof Error ? error.message : String(error)}` + ); + } + return state; + } + + private async recreateAlfredpayOfframp( + state: RampState, + expiredTx: Awaited>, + signal?: AbortSignal + ): Promise<{ state: RampState; alfredpayTx: Awaited> } | null> { + const { alfredpayUserId, fiatAccountId, walletAddress } = state.state as StateMetadata; + if (!alfredpayUserId || !fiatAccountId || !walletAddress) return null; + + const alfredpayApiService = AlfredpayApiService.getInstance(); + try { + const toCurrency = expiredTx.toCurrency as AlfredpayFiatCurrency; + const freshQuote = await abortableCall(signal, () => + alfredpayApiService.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: expiredTx.fromAmount, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + metadata: { businessId: "vortex", customerId: alfredpayUserId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency + }) + ); + throwIfAborted(signal); + const newOrder = await abortableCall(signal, () => + alfredpayApiService.createOfframp({ + amount: expiredTx.fromAmount, + chain: AlfredpayChain.MATIC, + customerId: alfredpayUserId, + fiatAccountId, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + originAddress: walletAddress, + quoteId: freshQuote.quoteId, + toCurrency + }) + ); + if (newOrder.depositAddress.toLowerCase() !== expiredTx.depositAddress.toLowerCase()) return null; + await state.update({ state: { ...state.state, alfredpayTransactionId: newOrder.transactionId } }); + const refreshedTx = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(newOrder.transactionId)); + return { alfredpayTx: refreshedTx, state }; + } catch (error) { + throwIfAborted(signal); + logger.error( + `AlfredpayOfframpTransferExecutor: Error during recovery: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + + private async pollAlfredpayOfframpStatus(transactionId: string, intervalMs: number, signal?: AbortSignal): Promise { + const alfredpayApiService = AlfredpayApiService.getInstance(); + const startTime = Date.now(); + while (Date.now() - startTime <= ALFREDPAY_OFFRAMP_TIMEOUT_MS) { + throwIfAborted(signal); + try { + const response = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(transactionId)); + if (response.status === AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED) return; + if (response.status === AlfredpayOfframpStatus.FAILED) { + throw { failureReason: "Alfredpay reported FAILED status", kind: "failed" as const }; + } + } catch (error) { + if (isAlfredpayFailedStatusError(error)) throw error; + throwIfAborted(signal); + logger.warn( + `AlfredpayOfframpTransferExecutor: Error polling Alfredpay status for ${transactionId}: ${error instanceof Error ? error.message : String(error)}` + ); + } + await sleep(intervalMs, signal); + } + throw new Error(`AlfredpayOfframpTransferExecutor: Polling timed out after ${ALFREDPAY_OFFRAMP_TIMEOUT_MS}ms`); + } +} + +export { FinalSettlementSubsidyExecutor as AlfredpayOfframpSettlementExecutor }; +export { FundEphemeralExecutor as AlfredpayOfframpFundExecutor }; diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/index.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/index.ts new file mode 100644 index 000000000..aa48fe1fc --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/index.ts @@ -0,0 +1,44 @@ +import { type EvmNetworks, type EvmToken, type FiatToken } from "@vortexfi/shared"; +import type { Phase, PhaseIO } from "../../core/types"; +import { + AlfredpayOfframpFundExecutor, + AlfredpayOfframpPermitExecutor, + AlfredpayOfframpSettlementExecutor, + AlfredpayOfframpTransferExecutor +} from "./execution"; +import { startAlfredpayOfframp } from "./lifecycle"; +import { + type AlfredpayOfframpRegistrationFacts, + type AlfredpayOfframpRegistrationInput, + registerAlfredpayOfframp +} from "./registration"; +import { AlfredpayOfframpContext, simulateAlfredpayOfframp } from "./simulation"; +import { prepareAlfredpayOfframpTxs } from "./transactions"; + +export function AlfredpayOfframp( + fromToken: FromToken, + fromNetwork: FromNetwork +): Phase< + typeof AlfredpayOfframpContext, + PhaseIO, + PhaseIO, + AlfredpayOfframpRegistrationFacts, + AlfredpayOfframpRegistrationInput +> { + return { + context: AlfredpayOfframpContext, + executors: [ + new AlfredpayOfframpPermitExecutor(), + new AlfredpayOfframpFundExecutor(), + new AlfredpayOfframpSettlementExecutor(), + new AlfredpayOfframpTransferExecutor() + ], + externalOperations: { register: { provider: "alfredpay" } }, + name: "AlfredpayOfframp", + phases: ["squidRouterPermitExecute", "fundEphemeral", "finalSettlementSubsidy", "alfredpayOfframpTransfer"], + prepareTxs: prepareAlfredpayOfframpTxs, + register: registerAlfredpayOfframp, + simulate: simulateAlfredpayOfframp(fromToken, fromNetwork), + start: startAlfredpayOfframp + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/lifecycle.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/lifecycle.ts new file mode 100644 index 000000000..9d2e1aa16 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/lifecycle.ts @@ -0,0 +1,25 @@ +import httpStatus from "http-status"; +import { APIError } from "../../../../../errors/api-error"; +import type { StartCtx, StartResult } from "../../core/types"; +import type { AlfredpayOfframpMetadata } from "./simulation"; + +export async function startAlfredpayOfframp( + ctx: StartCtx +): Promise> { + if (ctx.state.alfredpayTransactionId) { + return {}; + } + if (!ctx.metadata?.quoteId) { + throw new APIError({ message: "Missing Alfredpay quote ID in metadata", status: httpStatus.BAD_REQUEST }); + } + if (!ctx.state.alfredpayUserId) { + throw new APIError({ message: "Missing Alfredpay user ID in ramp state", status: httpStatus.BAD_REQUEST }); + } + if (!ctx.state.fiatAccountId) { + throw new APIError({ message: "Missing fiatAccountId in ramp state", status: httpStatus.BAD_REQUEST }); + } + if (!ctx.state.walletAddress) { + throw new APIError({ message: "Wallet address not found in ramp state", status: httpStatus.BAD_REQUEST }); + } + return {}; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/permit.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/permit.ts new file mode 100644 index 000000000..e9e7ab91c --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/permit.ts @@ -0,0 +1,79 @@ +import { type EvmNetworks, Networks, type TypedDataDomain } from "@vortexfi/shared"; +import { encodeAbiParameters, keccak256, type PublicClient, pad, parseAbiParameters, toHex } from "viem"; + +export const ALFREDPAY_RELAYER_ADDRESSES: Partial> = { + [Networks.Arbitrum]: "0xC9ECD03c89349B3EAe4613c7091c6c3029413785", + [Networks.Base]: "0xDbece5cE27984FC64688bcC57f75b96a28e8c68c", + [Networks.Polygon]: "0xC9ECD03c89349B3EAe4613c7091c6c3029413785", + [Networks.Avalanche]: "0x11871C77Aa0170ae13864E4E82cFa471720e045e", + [Networks.Ethereum]: "0x522A51f9c5B1683F0F15910075487c4D162A8b83", + [Networks.BSC]: "0x2d657ac14088fED401b58FEd377988ed3F875220" +}; + +export function getAlfredpayRelayerAddress(network: EvmNetworks): `0x${string}` { + const address = ALFREDPAY_RELAYER_ADDRESSES[network]; + if (!address) throw new Error(`No TokenRelayer deployed on ${network}`); + return address; +} + +export async function resolveAlfredpayPermitDomain( + publicClient: PublicClient, + tokenAddress: `0x${string}`, + chainId: number, + tokenName: string +): Promise { + let version = "1"; + try { + version = (await publicClient.readContract({ + abi: [{ inputs: [], name: "version", outputs: [{ type: "string" }], stateMutability: "view", type: "function" }], + address: tokenAddress, + functionName: "version" + })) as string; + } catch { + // Tokens without version() conventionally use EIP-2612 version 1. + } + const standardHash = keccak256( + encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, uint256, address"), [ + keccak256(toHex("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")), + keccak256(toHex(tokenName)), + keccak256(toHex(version)), + BigInt(chainId), + tokenAddress + ]) + ); + const minimalHash = keccak256( + encodeAbiParameters(parseAbiParameters("bytes32, uint256, address"), [ + keccak256(toHex("EIP712Domain(uint256 chainId,address verifyingContract)")), + BigInt(chainId), + tokenAddress + ]) + ); + let separator: `0x${string}` | undefined; + try { + separator = (await publicClient.readContract({ + abi: [ + { inputs: [], name: "DOMAIN_SEPARATOR", outputs: [{ type: "bytes32" }], stateMutability: "view", type: "function" } + ], + address: tokenAddress, + functionName: "DOMAIN_SEPARATOR" + })) as `0x${string}`; + } catch { + // Without an on-chain separator, use the standard EIP-2612 domain. + } + if (!separator || separator === standardHash) return { chainId, name: tokenName, verifyingContract: tokenAddress, version }; + if (separator === minimalHash) return { chainId, verifyingContract: tokenAddress } as TypedDataDomain; + const salt = pad(toHex(chainId), { size: 32 }); + const saltHash = keccak256( + encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, address, bytes32"), [ + keccak256(toHex("EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)")), + keccak256(toHex(tokenName)), + keccak256(toHex(version)), + tokenAddress, + salt + ]) + ); + if (separator === saltHash) return { name: tokenName, salt, verifyingContract: tokenAddress, version }; + throw new Error( + `Token ${tokenName} has unexpected DOMAIN_SEPARATOR. Expected standard: ${standardHash}, minimal: ${minimalHash} or salt: ${saltHash}, got: ${separator}` + ); +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts new file mode 100644 index 000000000..4a69d510a --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts @@ -0,0 +1,99 @@ +import { + ALFREDPAY_ONCHAIN_CURRENCY, + AlfredpayApiService, + AlfredpayChain, + type AlfredpayFiatCurrency, + AlfredpayPaymentMethodType, + type CreateAlfredpayOfframpQuoteRequest, + EphemeralAccountType +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { APIError } from "../../../../../errors/api-error"; +import { resolveAlfredpayCustomerId } from "../../../../quote/alfredpay-customer"; +import { requireAccount } from "../../core/accounts"; +import type { RegisterCtx, RegistrationResult } from "../../core/types"; +import type { AlfredpayOfframpMetadata } from "./simulation"; + +export interface AlfredpayOfframpRegistrationInput extends Record { + fiatAccountId?: string; + walletAddress?: string; +} + +export interface AlfredpayOfframpRegistrationFacts { + alfredpayTransactionId: string; + alfredpayUserId: string; + depositAddress: string; + fiatAccountId: string; + walletAddress: string; +} + +export async function registerAlfredpayOfframp( + ctx: RegisterCtx, + dependencies: { + resolveCustomerId?: typeof resolveAlfredpayCustomerId; + service?: Pick; + sumFees?: typeof AlfredpayApiService.sumFeesByCurrency; + } = {} +): Promise> { + if (!ctx.input.fiatAccountId) { + throw new APIError({ message: "fiatAccountId is required for Alfredpay offramp", status: httpStatus.BAD_REQUEST }); + } + if (!ctx.input.walletAddress) { + throw new APIError({ message: "Wallet address is required for Alfredpay offramp", status: httpStatus.BAD_REQUEST }); + } + const evmEphemeral = requireAccount( + Object.fromEntries(ctx.signingAccounts.map(account => [account.type, account])), + EphemeralAccountType.EVM + ); + const customerId = await (dependencies.resolveCustomerId ?? resolveAlfredpayCustomerId)( + ctx.metadata.currency, + ctx.authenticatedUser.id + ); + const service = dependencies.service ?? AlfredpayApiService.getInstance(); + const toCurrency = ctx.metadata.currency as unknown as AlfredpayFiatCurrency; + const freshQuote = await service.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: new Big(ctx.metadata.inputAmountDecimal as unknown as string).toString(), + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + metadata: { businessId: "vortex", customerId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency + } satisfies CreateAlfredpayOfframpQuoteRequest); + const originalOutput = new Big(ctx.metadata.outputAmountDecimal as unknown as string); + const freshOutput = new Big(freshQuote.toAmount); + const originalFee = new Big(ctx.metadata.fee as unknown as string); + const freshFee = (dependencies.sumFees ?? AlfredpayApiService.sumFeesByCurrency)(freshQuote.fees, toCurrency); + if (!freshOutput.eq(originalOutput) || !freshFee.eq(originalFee)) { + throw new APIError({ + message: + `Refreshed Alfredpay offramp quote drifted: toAmount original=${originalOutput.toString()} fresh=${freshOutput.toString()}, ` + + `fee original=${originalFee.toString()} fresh=${freshFee.toString()}. Cannot proceed with offramp order.`, + status: httpStatus.INTERNAL_SERVER_ERROR + }); + } + const order = await service.createOfframp({ + amount: new Big(ctx.metadata.inputAmountDecimal as unknown as string).toString(), + chain: AlfredpayChain.MATIC, + customerId, + fiatAccountId: ctx.input.fiatAccountId, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + originAddress: evmEphemeral.address, + quoteId: freshQuote.quoteId, + toCurrency + }); + return { + facts: { + alfredpayTransactionId: order.transactionId, + alfredpayUserId: customerId, + depositAddress: order.depositAddress, + fiatAccountId: ctx.input.fiatAccountId, + walletAddress: ctx.input.walletAddress + }, + metadata: { + ...ctx.metadata, + expirationDate: new Date(freshQuote.expiration), + quoteId: freshQuote.quoteId + } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts new file mode 100644 index 000000000..027103837 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts @@ -0,0 +1,196 @@ +import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ERC20_TOKEN, + ALFREDPAY_EVM_TOKEN, + ALFREDPAY_ONCHAIN_CURRENCY, + AlfredpayApiService, + AlfredpayChain, + type AlfredpayFiatCurrency, + AlfredpayPaymentMethodType, + type EvmNetworks, + type EvmToken, + type FiatToken, + multiplyByPowerOfTen, + Networks, + type OnChainToken, + type RampCurrency, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { resolveAlfredpayQuoteCustomerId } from "../../../../quote/alfredpay-customer"; +import { + calculateExpectedOutput, + calculateSubsidyAmount, + getUsdDenominatedInputAmount, + resolveDiscountPartner +} from "../../core/discount"; +import { calculateFees } from "../../core/fees"; +import { evmIO } from "../../core/io"; +import { defineContext, type SerializableBig } from "../../core/metadata"; +import { calculatePreNablaDeductibleFees } from "../../core/quote-fees"; +import { getEvmBridgeQuote } from "../../core/squidrouter"; +import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; + +export interface AlfredpayOfframpMetadata { + adjustedDifference: SerializableBig; + adjustedTargetDiscount: SerializableBig; + bridgeInputAmountRaw: string; + bridgeOutputAmountDecimal: SerializableBig; + bridgeOutputAmountRaw: string; + currency: FiatToken; + expirationDate: Date; + fee: SerializableBig; + fromNetwork: EvmNetworks; + fromToken: `0x${string}`; + inputAmountDecimal: SerializableBig; + inputAmountRaw: string; + network: typeof Networks.Polygon; + outputAmountDecimal: SerializableBig; + outputAmountRaw: string; + quoteId: string; + subsidyAmountDecimal: SerializableBig; + subsidyAmountRaw: string; + token: typeof ALFREDPAY_EVM_TOKEN; + toToken: `0x${string}`; +} + +export const AlfredpayOfframpContext = defineContext()("alfredpayOfframp"); + +function directAlfredpaySettlementQuote(amountDecimal: string) { + const outputAmountDecimal = new Big(amountDecimal); + const amountRaw = multiplyByPowerOfTen(outputAmountDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, Big.roundDown); + + return { + fromToken: ALFREDPAY_ERC20_TOKEN, + inputAmountRaw: amountRaw, + outputAmountDecimal, + outputAmountRaw: amountRaw, + toToken: ALFREDPAY_ERC20_TOKEN + }; +} + +export function simulateAlfredpayOfframp( + fromToken: FromToken, + fromNetwork: FromNetwork +) { + return async ( + input: PhaseIO, + ctx: PhaseCtx + ): Promise, AlfredpayOfframpMetadata>> => { + const bridge = + fromNetwork === Networks.Polygon && fromToken === ALFREDPAY_EVM_TOKEN + ? directAlfredpaySettlementQuote(ctx.request.inputAmount) + : await getEvmBridgeQuote({ + amountDecimal: ctx.request.inputAmount, + fromNetwork, + inputCurrency: fromToken as OnChainToken, + outputCurrency: ALFREDPAY_EVM_TOKEN, + toNetwork: Networks.Polygon + }); + const { preNablaDeductibleFeeAmount, feeCurrency } = await calculatePreNablaDeductibleFees( + ctx.request.inputAmount, + ctx.request.inputCurrency, + ctx.request.outputCurrency, + ctx.request.rampType, + ctx.request.from, + ctx.request.to, + ctx.partner?.id || undefined + ); + const deductibleUsd = new Big( + await priceFeedService.convertCurrency( + preNablaDeductibleFeeAmount.toString(), + feeCurrency, + ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency + ) + ); + const oneUnitInFiat = new Big( + await priceFeedService.convertCurrency( + "1", + ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency, + ctx.request.outputCurrency as RampCurrency + ) + ); + const fiatToUsd = new Big(1).div(oneUnitInFiat); + const partner = await resolveDiscountPartner(ctx as never, RampDirection.SELL); + const targetDiscount = partner?.targetDiscount ?? 0; + const maxSubsidy = partner?.maxSubsidy ?? 0; + const actualFiat = bridge.outputAmountDecimal.mul(oneUnitInFiat); + const inputAmountUsd = await getUsdDenominatedInputAmount( + Object.assign({}, ctx, { evmToEvm: { outputAmountDecimal: bridge.outputAmountDecimal } }) as never + ); + if (!inputAmountUsd.eq(ctx.request.inputAmount)) { + ctx.addNote( + `AlfredpayOfframp: valued input ${ctx.request.inputAmount} ${ctx.request.inputCurrency} at ${inputAmountUsd.toFixed(6)} USD for discount calculation` + ); + } + const { expectedOutput, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput( + inputAmountUsd.toString(), + fiatToUsd, + targetDiscount, + true, + partner + ); + const subsidyFiat = targetDiscount !== 0 ? calculateSubsidyAmount(expectedOutput, actualFiat, maxSubsidy) : new Big(0); + const providerInput = actualFiat + .plus(subsidyFiat) + .div(oneUnitInFiat) + .minus(deductibleUsd) + .round(ALFREDPAY_ERC20_DECIMALS, Big.roundDown); + const customerId = await resolveAlfredpayQuoteCustomerId(ctx.request.outputCurrency, ctx.request.userId); + const providerQuote = await AlfredpayApiService.getInstance().createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: providerInput.toString(), + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + metadata: { businessId: "vortex", customerId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: ctx.request.outputCurrency as unknown as AlfredpayFiatCurrency + }); + const outputAmount = new Big(providerQuote.toAmount); + const providerFee = AlfredpayApiService.sumFeesByCurrency( + providerQuote.fees, + ctx.request.outputCurrency as unknown as AlfredpayFiatCurrency + ); + const inputAmountRaw = multiplyByPowerOfTen(providerInput, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0); + const fees = await calculateFees(ctx, { + anchor: { amount: providerFee.toString(), currency: ctx.request.outputCurrency as RampCurrency }, + network: { amount: "0", currency: ALFREDPAY_EVM_TOKEN as RampCurrency } + }); + const expirationDate = new Date(providerQuote.expiration); + ctx.addNote( + `AlfredpayOfframp: ${input.amount.toString()} ${fromToken} -> ${outputAmount.toString()} ${ctx.request.outputCurrency}` + ); + return { + expiresAt: expirationDate, + fees, + metadata: { + adjustedDifference, + adjustedTargetDiscount, + bridgeInputAmountRaw: bridge.inputAmountRaw, + bridgeOutputAmountDecimal: bridge.outputAmountDecimal, + bridgeOutputAmountRaw: bridge.outputAmountRaw, + currency: ctx.request.outputCurrency as FiatToken, + expirationDate, + fee: providerFee, + fromNetwork, + fromToken: bridge.fromToken, + inputAmountDecimal: providerInput, + inputAmountRaw, + network: Networks.Polygon, + outputAmountDecimal: outputAmount, + outputAmountRaw: multiplyByPowerOfTen(outputAmount, 2).toFixed(0, 0), + quoteId: providerQuote.quoteId, + subsidyAmountDecimal: subsidyFiat.div(oneUnitInFiat), + subsidyAmountRaw: multiplyByPowerOfTen(subsidyFiat.div(oneUnitInFiat), ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), + token: ALFREDPAY_EVM_TOKEN, + toToken: bridge.toToken + }, + output: evmIO( + ctx.request.outputCurrency as FiatToken, + "fiat", + outputAmount, + multiplyByPowerOfTen(outputAmount, 2).toFixed(0, 0) + ) + }; + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/transactions.ts new file mode 100644 index 000000000..0d5a6bcf5 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/transactions.ts @@ -0,0 +1,313 @@ +import { + ALFREDPAY_ERC20_TOKEN, + createOfframpSquidrouterTransactionsToEvm, + EphemeralAccountType, + EvmClientManager, + type EvmNetworks, + EvmToken, + type EvmTokenDetails, + evmTokenConfig, + getNetworkId, + getOnChainTokenDetails, + Networks, + type SignedTypedData +} from "@vortexfi/shared"; +import Big from "big.js"; +import { ContractFunctionExecutionError, encodeFunctionData } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { config } from "../../../../../../config/vars"; +import erc20ABI from "../../../../../../contracts/ERC20"; +import { preparePolygonCleanupApproval } from "../../../../transactions/polygon/cleanup"; +import { requireAccount } from "../../core/accounts"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { createDestinationTransferTransaction, encodeEvmTransactionData } from "../../core/evm-transactions"; +import type { PrepareCtx, PreparedPhaseTxs, TxIntent } from "../../core/types"; +import { ALFREDPAY_RELAYER_ADDRESSES, resolveAlfredpayPermitDomain } from "./permit"; +import type { AlfredpayOfframpRegistrationFacts } from "./registration"; +import type { AlfredpayOfframpMetadata } from "./simulation"; + +const permitProbeAbi = [ + { + inputs: [{ name: "owner", type: "address" }], + name: "nonces", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { inputs: [], name: "name", outputs: [{ name: "", type: "string" }], stateMutability: "view", type: "function" } +] as const; + +export interface AlfredpayOfframpPreparation extends AlfredpayOfframpRegistrationFacts { + isDirectTransfer: boolean; + isNoPermitFallback: boolean; + squidRouterPermitExecutionValue?: string; + variant: AlfredpayOfframpSourceVariant; +} + +export interface AlfredpayOfframpTransactionDependencies { + createBridge?: typeof createOfframpSquidrouterTransactionsToEvm; + executorAddress?: `0x${string}`; + now?: () => number; + probePermit?: () => Promise<{ + domain: Awaited>; + nonce: bigint; + } | null>; +} + +export type AlfredpayOfframpSourceVariant = + | "direct-permit" + | "direct-no-permit" + | "same-chain-squid-permit" + | "same-chain-squid-no-permit" + | "cross-chain-squid-permit" + | "cross-chain-squid-no-permit"; + +export function classifyAlfredpayOfframpSource( + fromNetwork: EvmNetworks, + direct: boolean, + supportsPermit: boolean +): AlfredpayOfframpSourceVariant { + if (direct) return supportsPermit ? "direct-permit" : "direct-no-permit"; + const prefix = fromNetwork === Networks.Polygon ? "same-chain-squid" : "cross-chain-squid"; + return `${prefix}-${supportsPermit ? "permit" : "no-permit"}`; +} + +function permitTypedData( + domain: Awaited>, + owner: string, + spender: string, + value: string, + nonce: bigint, + deadline: bigint +): SignedTypedData { + return { + domain, + message: { + deadline: deadline.toString(), + nonce: nonce.toString(), + owner, + spender, + value + }, + primaryType: "Permit", + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; +} + +export async function prepareAlfredpayOfframpTxs( + ctx: PrepareCtx, + dependencies: AlfredpayOfframpTransactionDependencies = {} +): Promise { + const facts = ctx.ownRegistrationFacts; + if (!facts) throw new Error("Alfredpay offramp registration facts are required"); + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const fromNetwork = ctx.ownMetadata.fromNetwork; + const inputDetails = getOnChainTokenDetails(fromNetwork, ctx.globals.request.inputCurrency) as EvmTokenDetails | undefined; + if (!inputDetails) throw new Error(`Missing input token details on ${fromNetwork}`); + const inputToken = inputDetails.erc20AddressSourceChain as `0x${string}`; + const inputAmountRaw = new Big(ctx.quote.inputAmount).mul(new Big(10).pow(inputDetails.decimals)).toFixed(0, 0); + const direct = fromNetwork === Networks.Polygon && inputToken.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase(); + const publicClient = EvmClientManager.getInstance().getClient(fromNetwork); + const chainId = getNetworkId(fromNetwork); + if (chainId === undefined) throw new Error(`Unsupported EVM network ${fromNetwork}`); + const permit = dependencies.probePermit + ? await dependencies.probePermit() + : await (async () => { + try { + const nonce = (await publicClient.readContract({ + abi: permitProbeAbi, + address: inputToken, + args: [facts.walletAddress as `0x${string}`], + functionName: "nonces" + })) as bigint; + const tokenName = (await publicClient.readContract({ + abi: permitProbeAbi, + address: inputToken, + functionName: "name" + })) as string; + return { domain: await resolveAlfredpayPermitDomain(publicClient, inputToken, chainId, tokenName), nonce }; + } catch (error) { + if (error instanceof ContractFunctionExecutionError) return null; + throw error; + } + })(); + + const intents: TxIntent[] = []; + let squidRouterPermitExecutionValue: string | undefined; + const now = dependencies.now?.() ?? Date.now(); + const createBridge = dependencies.createBridge ?? createOfframpSquidrouterTransactionsToEvm; + const variant = classifyAlfredpayOfframpSource(fromNetwork, direct, permit !== null); + if (permit) { + const permitDeadline = BigInt(Math.floor(now / 1000) + 24 * 60 * 60); + if (direct) { + const executorAddress = + dependencies.executorAddress ?? privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`).address; + intents.push({ + lane: "main", + network: fromNetwork, + phase: "squidRouterPermitExecute", + signer: facts.walletAddress, + txData: [ + permitTypedData(permit.domain, facts.walletAddress, executorAddress, inputAmountRaw, permit.nonce, permitDeadline) + ] + }); + } else { + const bridge = await createBridge({ + destinationAddress: evmEphemeral.address, + fromAddress: facts.walletAddress, + fromNetwork, + fromToken: inputToken, + rawAmount: inputAmountRaw, + toNetwork: Networks.Polygon, + toToken: ALFREDPAY_ERC20_TOKEN + }); + const relayer = ALFREDPAY_RELAYER_ADDRESSES[fromNetwork]; + if (!relayer) throw new Error(`Alfredpay offramp permit flow is not supported on ${fromNetwork}`); + const payloadNonce = BigInt(Math.floor(now / 1000)); + const payloadDeadline = BigInt(Math.floor(now / 1000) + 3600); + const payload: SignedTypedData = { + domain: { chainId, name: "TokenRelayer", verifyingContract: relayer, version: "1" }, + message: { + data: bridge.swapData.data, + deadline: payloadDeadline.toString(), + destination: bridge.swapData.to, + ethValue: bridge.swapData.value, + nonce: payloadNonce.toString(), + owner: facts.walletAddress, + token: inputToken, + value: inputAmountRaw + }, + primaryType: "Payload", + types: { + Payload: [ + { name: "destination", type: "address" }, + { name: "owner", type: "address" }, + { name: "token", type: "address" }, + { name: "value", type: "uint256" }, + { name: "data", type: "bytes" }, + { name: "ethValue", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + squidRouterPermitExecutionValue = bridge.swapData.value; + intents.push({ + lane: "main", + network: fromNetwork, + phase: "squidRouterPermitExecute", + signer: facts.walletAddress, + txData: [ + permitTypedData(permit.domain, facts.walletAddress, relayer, inputAmountRaw, permit.nonce, permitDeadline), + payload + ] + }); + } + } else if (direct) { + intents.push({ + lane: "main", + network: fromNetwork, + phase: "squidRouterNoPermitTransfer", + signer: facts.walletAddress, + txData: { + data: encodeFunctionData({ + abi: erc20ABI, + args: [evmEphemeral.address as `0x${string}`, BigInt(inputAmountRaw)], + functionName: "transfer" + }), + gas: "0", + to: inputToken, + value: "0" + } + }); + } else { + const bridge = await createBridge({ + destinationAddress: evmEphemeral.address, + fromAddress: facts.walletAddress, + fromNetwork, + fromToken: inputToken, + rawAmount: inputAmountRaw, + toNetwork: Networks.Polygon, + toToken: ALFREDPAY_ERC20_TOKEN + }); + squidRouterPermitExecutionValue = bridge.swapData.value; + intents.push( + { + lane: "main", + network: fromNetwork, + phase: "squidRouterNoPermitApprove", + signer: facts.walletAddress, + txData: bridge.approveData as TxIntent["txData"] + }, + { + lane: "main", + network: fromNetwork, + phase: "squidRouterNoPermitSwap", + signer: facts.walletAddress, + txData: bridge.swapData + } + ); + } + + const finalTransfer = await createDestinationTransferTransaction({ + amountRaw: ctx.ownMetadata.inputAmountRaw, + destinationNetwork: Networks.Polygon, + toAddress: facts.depositAddress as `0x${string}`, + toToken: ALFREDPAY_ERC20_TOKEN + }); + const fallbackTransfer = await createDestinationTransferTransaction({ + amountRaw: ctx.ownMetadata.inputAmountRaw, + destinationNetwork: Networks.Polygon, + toAddress: facts.walletAddress, + toToken: ALFREDPAY_ERC20_TOKEN + }); + intents.push( + { + lane: "main", + network: Networks.Polygon, + phase: "alfredpayOfframpTransfer", + signer: evmEphemeral.address, + txData: finalTransfer + }, + { + lane: "backup", + network: Networks.Polygon, + phase: "alfredpayOfframpTransferFallback", + reuseFirstMainNonce: true, + signer: evmEphemeral.address, + txData: fallbackTransfer + } + ); + const axlUsdc = evmTokenConfig[Networks.Polygon][EvmToken.AXLUSDC]?.erc20AddressSourceChain; + if (!axlUsdc) throw new Error("Invalid Polygon AXLUSDC configuration"); + const cleanup = await preparePolygonCleanupApproval( + axlUsdc as `0x${string}`, + getEvmFundingAccount(Networks.Polygon).address, + Networks.Polygon + ); + intents.push({ + lane: "cleanup", + network: Networks.Polygon, + phase: "polygonCleanupAxlUsdc", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(cleanup) as TxIntent["txData"] + }); + return { + intents, + state: { + ...facts, + isDirectTransfer: direct, + isNoPermitFallback: permit === null, + squidRouterPermitExecutionValue, + variant + } satisfies AlfredpayOfframpPreparation + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/anchor-test-mode.ts b/apps/api/src/api/services/phases/blocks/phases/anchor-test-mode.ts new file mode 100644 index 000000000..e5adb7884 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/anchor-test-mode.ts @@ -0,0 +1,9 @@ +const DEFAULT_PHASE_RETRIES = 8; + +export function isAnchorMockingEnabled(): boolean { + return process.env.NODE_ENV === "development" && process.env.MOCK_ANCHOR_OPERATIONS === "true"; +} + +export function getAnchorPayoutMaxRetries(): number { + return isAnchorMockingEnabled() ? 0 : DEFAULT_PHASE_RETRIES; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/assethub-offramp-source/index.ts b/apps/api/src/api/services/phases/blocks/phases/assethub-offramp-source/index.ts new file mode 100644 index 000000000..fc35b32d7 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/assethub-offramp-source/index.ts @@ -0,0 +1,91 @@ +import { + AssetHubToken, + createAssethubToPendulumXCM, + createPaseoToPendulumXCM, + EPaymentMethod, + encodeSubmittableExtrinsic, + Networks, + PENDULUM_USDC_ASSETHUB +} from "@vortexfi/shared"; +import Big from "big.js"; +import { config } from "../../../../../../config/vars"; +import { defineContext } from "../../core/metadata"; +import type { Phase, PhaseIO, RegisterCtx } from "../../core/types"; + +export interface AssethubOfframpSourceMetadata { + inputAmountDecimal: string; + inputAmountRaw: string; + outputAmountDecimal: string; + outputAmountRaw: string; + xcmFees: { + destination: { amount: string; amountRaw: string; currency: string }; + origin: { amount: string; amountRaw: string; currency: string }; + }; +} + +export interface AssethubOfframpSourceRegistrationInput extends Record { + walletAddress?: string; +} + +export interface AssethubOfframpSourceRegistrationFacts { + userAddress: string; +} + +export const AssethubOfframpSourceContext = defineContext()("assethubOfframpSource"); + +export const AssethubOfframpSource: Phase< + typeof AssethubOfframpSourceContext, + PhaseIO, + PhaseIO, + AssethubOfframpSourceRegistrationFacts, + AssethubOfframpSourceRegistrationInput +> = { + context: AssethubOfframpSourceContext, + name: "AssethubOfframpSource", + phases: [], + async prepareTxs(ctx) { + const facts = ctx.ownRegistrationFacts; + const substrate = ctx.accounts.Substrate; + if (!facts || !substrate) throw new Error("AssethubOfframpSource requires user and Substrate accounts"); + const transaction = config.sandboxEnabled + ? await createPaseoToPendulumXCM(substrate.address, "usdc", ctx.ownMetadata.inputAmountRaw) + : await createAssethubToPendulumXCM(substrate.address, "usdc", ctx.ownMetadata.inputAmountRaw); + return { + intents: [ + { + lane: "main", + network: config.sandboxEnabled ? Networks.Paseo : Networks.AssetHub, + phase: "assethubToPendulum", + signer: facts.userAddress, + txData: encodeSubmittableExtrinsic(transaction) + } + ], + state: facts + }; + }, + async register(ctx: RegisterCtx) { + if (!ctx.input.walletAddress) throw new Error("User address must be provided for offramping."); + return { facts: { userAddress: ctx.input.walletAddress } }; + }, + async simulate(input, ctx) { + if (ctx.request.from !== Networks.AssetHub || ctx.request.to !== EPaymentMethod.PIX) { + throw new Error("AssethubOfframpSource received an invalid corridor"); + } + const feeRaw = new Big(20_000); + const outputAmountRaw = new Big(input.amountRaw).minus(feeRaw).toFixed(0, 0); + const outputAmount = new Big(outputAmountRaw).div(new Big(10).pow(PENDULUM_USDC_ASSETHUB.decimals)); + return { + metadata: { + inputAmountDecimal: input.amount.toString(), + inputAmountRaw: input.amountRaw, + outputAmountDecimal: outputAmount.toString(), + outputAmountRaw, + xcmFees: { + destination: { amount: "0.01", amountRaw: "10000", currency: "USDC" }, + origin: { amount: "0.01", amountRaw: "10000", currency: "USDC" } + } + }, + output: { amount: outputAmount, amountRaw: outputAmountRaw, chain: Networks.Pendulum, token: AssetHubToken.USDC } + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-direct-mint/index.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-direct-mint/index.ts new file mode 100644 index 000000000..6e3e35e01 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-direct-mint/index.ts @@ -0,0 +1,27 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import type { Phase, PhaseIO } from "../../core/types"; +import { BrlaOnrampMintExecutor } from "../avenia-mint/execution"; +import { + type AveniaMintRegistrationFacts, + type AveniaMintRegistrationInput, + registerAveniaMint +} from "../avenia-mint/registration"; +import { AveniaMintContext, simulateAveniaDirectMint } from "./simulation"; +import { prepareAveniaDirectMintTxs } from "./transactions"; + +export const AveniaDirectMint: Phase< + typeof AveniaMintContext, + PhaseIO, + PhaseIO, + AveniaMintRegistrationFacts, + AveniaMintRegistrationInput +> = { + context: AveniaMintContext, + executors: [new BrlaOnrampMintExecutor()], + externalOperations: { register: { provider: "avenia" } }, + name: "AveniaDirectMint", + phases: ["brlaOnrampMint"], + prepareTxs: prepareAveniaDirectMintTxs, + register: registerAveniaMint, + simulate: simulateAveniaDirectMint +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-direct-mint/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-direct-mint/simulation.ts new file mode 100644 index 000000000..1d5034d87 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-direct-mint/simulation.ts @@ -0,0 +1,23 @@ +import { EvmToken, FiatToken, Networks, RampCurrency } from "@vortexfi/shared"; +import Big from "big.js"; +import { calculateFees } from "../../core/fees"; +import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; +import { AveniaMintContext, type AveniaMintMetadata, simulateAveniaMint } from "../avenia-mint/simulation"; + +export { AveniaMintContext }; + +export async function simulateAveniaDirectMint( + input: PhaseIO, + ctx: PhaseCtx +): Promise, AveniaMintMetadata>> { + const result = await simulateAveniaMint(input, ctx); + const anchorFee = new Big(result.metadata.mint.fee).plus(result.metadata.transfer.fee).toString(); + + return { + ...result, + fees: await calculateFees(ctx, { + anchor: { amount: anchorFee, currency: FiatToken.BRL as RampCurrency }, + network: { amount: "0", currency: EvmToken.USDC as RampCurrency } + }) + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-direct-mint/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-direct-mint/transactions.ts new file mode 100644 index 000000000..c4396f3a3 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-direct-mint/transactions.ts @@ -0,0 +1,10 @@ +import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { AveniaMintRegistrationFacts } from "../avenia-mint/registration"; +import type { AveniaMintMetadata } from "../avenia-mint/simulation"; + +export async function prepareAveniaDirectMintTxs( + ctx: PrepareCtx +): Promise { + if (!ctx.ownRegistrationFacts) throw new Error("AveniaDirectMint requires registered Avenia facts"); + return { intents: [], state: { ...ctx.ownRegistrationFacts } }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-mint/execution.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/execution.ts new file mode 100644 index 000000000..be7022e89 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/execution.ts @@ -0,0 +1,287 @@ +import { + AveniaPaymentMethod, + BalanceCheckError, + BalanceCheckErrorType, + BlockchainSendMethod, + BrlaApiService, + BrlaCurrency, + checkEvmBalancePeriodically, + EvmAddress, + EvmToken, + evmTokenConfig, + FiatToken, + getAnyFiatTokenDetailsMoonbeam, + getEvmTokenBalance, + multiplyByPowerOfTen, + Networks, + RampPhase, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import logger from "../../../../../../config/logger"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { APIError } from "../../../../../errors/api-error"; +import { findAveniaCustomerByTaxId } from "../../../../avenia/avenia-customer.service"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { StateMetadata } from "../../../../phases/meta-state-types"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { getBlockMetadata, getBlockState } from "../../core/metadata"; +import { isAnchorMockingEnabled } from "../anchor-test-mode"; +import { syncAveniaOnHoldState } from "./on-hold"; +import { AveniaMintContext } from "./simulation"; +import type { AveniaMintPreparation } from "./transactions"; + +const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +const AVENIA_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const AVENIA_HOLD_STATUS_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute + +// The pre-computed expected amount stored at quote-creation time can be slightly higher than the +// amount actually transferred due to fee differences at execution time. We allow a 5% tolerance +// in the recovery shortcut so that an already-funded ephemeral is not missed. +const EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95; + +export class BrlaOnrampMintExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "brlaOnrampMint"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const { evmEphemeralAddress } = state.state as StateMetadata; + + if (!evmEphemeralAddress) { + throw new Error("BrlaOnrampMintExecutor: State metadata corrupted. This is a bug."); + } + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const metadata = getBlockMetadata(quote.metadata, AveniaMintContext); + + const isMoonbeam = metadata.network === Networks.Moonbeam; + const baseToken = evmTokenConfig[Networks.Base][EvmToken.BRLA]; + const moonbeamToken = getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL); + let tokenAddress: string; + let tokenDecimals: number; + if (isMoonbeam) { + tokenAddress = moonbeamToken.moonbeamErc20Address; + tokenDecimals = moonbeamToken.decimals; + } else { + if (!baseToken) throw new Error("BRLA token details not found for Base network"); + tokenAddress = baseToken.erc20AddressSourceChain; + tokenDecimals = baseToken.decimals; + } + const network = isMoonbeam ? Networks.Moonbeam : Networks.Base; + const paymentMethod = isMoonbeam ? AveniaPaymentMethod.MOONBEAM : AveniaPaymentMethod.BASE; + + const preComputedExpectedAmountRaw = metadata.transfer.outputAmountRaw; + + if (isAnchorMockingEnabled()) { + logger.warn( + `BrlaOnrampMintExecutor: Mocking Avenia mint; send ${preComputedExpectedAmountRaw} raw BRLA on ${network} to ${evmEphemeralAddress}` + ); + try { + await checkEvmBalancePeriodically( + tokenAddress, + evmEphemeralAddress, + preComputedExpectedAmountRaw, + 1000, + EVM_BALANCE_CHECK_TIMEOUT_MS, + network, + signal + ); + } catch (error) { + if (error instanceof BalanceCheckError && error.type === BalanceCheckErrorType.Timeout) { + throw this.createRecoverableError(`BrlaOnrampMintExecutor: Mock mint balance check timed out: ${error}`); + } + throw error; + } + return state; + } + + const preparation = getBlockState(state.state, AveniaMintContext); + if (!preparation.taxId) { + throw new Error("BrlaOnrampMintExecutor: Missing Avenia tax ID in block state"); + } + const aveniaCustomer = await findAveniaCustomerByTaxId(preparation.taxId); + if (!aveniaCustomer) { + throw new APIError({ + message: "Subaccount not found", + status: httpStatus.BAD_REQUEST + }); + } + const aveniaSubAccountId = aveniaCustomer.providerSubaccountId ?? ""; + + // Recovery shortcut: a previous run may have already minted on Avenia and transferred to the + // ephemeral. Accept a balance of at least 95% of the pre-computed expected amount. + const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw).times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0); + + if (await this.ephemeralAlreadyFunded(tokenAddress, evmEphemeralAddress, recoveryThresholdRaw, network, signal)) { + logger.info( + `BrlaOnrampMintExecutor: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${preComputedExpectedAmountRaw} BRLA (threshold: ${recoveryThresholdRaw}). Skipping mint flow.` + ); + return state; + } + + const brlaApiService = BrlaApiService.getInstance(); + let lastAveniaHoldStatusCheckAt = 0; + try { + logger.info( + `BrlaOnrampMintExecutor: Waiting for Avenia balance to have at least ${metadata.mint.outputAmountDecimal} BRL` + ); + await waitUntilTrueWithTimeout( + async () => { + const now = Date.now(); + if (now - lastAveniaHoldStatusCheckAt >= AVENIA_HOLD_STATUS_CHECK_INTERVAL_MS) { + lastAveniaHoldStatusCheckAt = now; + await abortableCall(signal, () => + syncAveniaOnHoldState( + state.state, + updatedState => state.update({ state: { ...state.state, ...updatedState } }), + brlaApiService, + aveniaSubAccountId + ) + ); + } + const { balances } = await abortableCall(signal, () => brlaApiService.getAccountBalance(aveniaSubAccountId)); + if (!balances || balances.BRLA === undefined || balances.BRLA === null) { + return false; + } + return Number(balances.BRLA) >= Number(Big(metadata.mint.outputAmountDecimal).toFixed(2, 0)); + }, + 5000, + AVENIA_BALANCE_CHECK_TIMEOUT_MS, + signal + ); + } catch (error) { + const isCheckTimeout = error instanceof Error && error.message.includes("Timeout"); + if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { + logger.error("Payment timeout. Cancelling ramp."); + return this.transitionToNextPhase(state, "failed"); + } + + throw isCheckTimeout + ? this.createRecoverableError( + `BrlaOnrampMintExecutor: phase timeout reached waiting for Avenia balance with error: ${error}` + ) + : new Error(`Error checking Avenia balance: ${error}`); + } + + const operationResult = await this.runFinancialOperation(state, { + attemptClass: "provider-mint-ticket", + externalId: result => result.ticketId, + perform: async () => { + const aveniaQuote = await abortableCall(signal, () => + brlaApiService.createPayInQuote({ + blockchainSendMethod: BlockchainSendMethod.PERMIT, + inputAmount: Big(metadata.mint.outputAmountDecimal).toFixed(2, 0), + inputCurrency: BrlaCurrency.BRLA, + inputPaymentMethod: AveniaPaymentMethod.INTERNAL, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: paymentMethod, + outputThirdParty: false, + subAccountId: aveniaSubAccountId + }) + ); + const expectedAmountReceived = multiplyByPowerOfTen(new Big(aveniaQuote.outputAmount), tokenDecimals).toFixed(0, 0); + throwIfAborted(signal); + const aveniaTicket = await abortableCall(signal, () => + brlaApiService.createPixOutputTicket( + { + quoteToken: aveniaQuote.quoteToken, + ticketBlockchainOutput: { + walletAddress: state.state.evmEphemeralAddress, + walletChain: paymentMethod + } + }, + aveniaSubAccountId + ) + ); + return { expectedAmountReceived, outputAmount: aveniaQuote.outputAmount, ticketId: aveniaTicket.id }; + }, + provider: "avenia", + request: { + amount: Big(metadata.mint.outputAmountDecimal).toFixed(2, 0), + destination: evmEphemeralAddress, + network, + subAccountId: aveniaSubAccountId + }, + signal + }); + const { expectedAmountReceived, outputAmount, ticketId } = operationResult; + + logger.info( + `BrlaOnrampMintExecutor: Avenia transfer ticket ${ticketId} will transfer ${outputAmount} BRLA to ${network} address ${state.state.evmEphemeralAddress}. Expected raw amount ${expectedAmountReceived}; quote-time amount was ${preComputedExpectedAmountRaw}.` + ); + + try { + const pollingTimeMs = 1000; + + await checkEvmBalancePeriodically( + tokenAddress, + evmEphemeralAddress, + expectedAmountReceived, + pollingTimeMs, + EVM_BALANCE_CHECK_TIMEOUT_MS, + network, + signal + ); + } catch (error) { + if (!(error instanceof BalanceCheckError)) throw error; + + const isCheckTimeout = error.type === BalanceCheckErrorType.Timeout; + if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { + logger.error("Payment timeout. Cancelling ramp."); + return this.transitionToNextPhase(state, "failed"); + } + + throw isCheckTimeout + ? this.createRecoverableError(`BrlaOnrampMintExecutor: phase timeout reached with error: ${error}`) + : new Error(`Error checking Base balance: ${error}`); + } + + return state; + } + + private async ephemeralAlreadyFunded( + tokenAddress: string, + ownerAddress: string, + expectedAmountRaw: string, + chain: typeof Networks.Base | typeof Networks.Moonbeam, + signal?: AbortSignal + ): Promise { + try { + const balance = await abortableCall(signal, () => + getEvmTokenBalance({ + chain, + ownerAddress: ownerAddress as EvmAddress, + tokenAddress: tokenAddress as EvmAddress + }) + ); + return balance.gte(new Big(expectedAmountRaw)); + } catch (error) { + throwIfAborted(signal); + // Treat read failures as "not funded" so we fall through to the regular flow rather than + // aborting the phase on a transient RPC error. + logger.warn( + `BrlaOnrampMintExecutor: ephemeral balance pre-check failed for ${ownerAddress}, falling back to Avenia flow: ${error}` + ); + return false; + } + } + + protected isPaymentTimeoutReached(state: RampState): boolean { + const thisPhaseEntry = state.phaseHistory.find(phaseHistoryEntry => phaseHistoryEntry.phase === this.getPhaseName()); + if (!thisPhaseEntry) { + throw new Error("BrlaOnrampMintExecutor: Phase not found in history. This is a bug."); + } + + const initialTimestamp = new Date(thisPhaseEntry.timestamp); + return initialTimestamp.getTime() + PAYMENT_TIMEOUT_MS < Date.now(); + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-mint/index.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/index.ts new file mode 100644 index 000000000..ff42670c9 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/index.ts @@ -0,0 +1,54 @@ +import { EvmToken, FiatToken, getNetworkFromDestination, Networks, type OnChainToken } from "@vortexfi/shared"; +import Big from "big.js"; +import { overrideFees } from "../../core/fees"; +import { getEvmBridgeQuote } from "../../core/squidrouter"; +import type { Phase, PhaseIO } from "../../core/types"; +import { BrlaOnrampMintExecutor } from "./execution"; +import { type AveniaMintRegistrationFacts, type AveniaMintRegistrationInput, registerAveniaMint } from "./registration"; +import { AveniaMintContext, simulateAveniaMint } from "./simulation"; +import { prepareAveniaMintTxs } from "./transactions"; + +export const AveniaMint: Phase< + typeof AveniaMintContext, + PhaseIO, + PhaseIO, + AveniaMintRegistrationFacts, + AveniaMintRegistrationInput +> = { + context: AveniaMintContext, + executors: [new BrlaOnrampMintExecutor()], + externalOperations: { register: { provider: "avenia" } }, + name: "AveniaMint", + phases: ["brlaOnrampMint"], + prepareTxs: prepareAveniaMintTxs, + register: registerAveniaMint, + async simulate(input, ctx) { + const result = await simulateAveniaMint(input, ctx); + const toNetwork = getNetworkFromDestination(ctx.request.to); + if (!toNetwork) { + throw new Error(`AveniaMint: invalid network for destination: ${ctx.request.to}`); + } + const networkFeeUSD = + toNetwork === Networks.Base && ctx.request.outputCurrency === EvmToken.USDC + ? "0" + : ( + await getEvmBridgeQuote({ + amountDecimal: ctx.request.inputAmount, + fromNetwork: Networks.Base, + inputCurrency: EvmToken.USDC, + outputCurrency: ctx.request.outputCurrency as OnChainToken, + toNetwork + }) + ).networkFeeUSD; + return { + ...result, + fees: await overrideFees(ctx, { + anchor: { + amount: new Big(result.metadata.mint.fee).plus(result.metadata.transfer.fee).toString(), + currency: FiatToken.BRL + }, + network: { amount: networkFeeUSD, currency: EvmToken.USDC } + }) + }; + } +}; diff --git a/apps/api/src/api/services/phases/helpers/brla-onramp-hold.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/on-hold.ts similarity index 82% rename from apps/api/src/api/services/phases/helpers/brla-onramp-hold.ts rename to apps/api/src/api/services/phases/blocks/phases/avenia-mint/on-hold.ts index c21fa8076..9e029d4dc 100644 --- a/apps/api/src/api/services/phases/helpers/brla-onramp-hold.ts +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/on-hold.ts @@ -19,18 +19,11 @@ export async function syncAveniaOnHoldState( aveniaTicket => aveniaTicket.id === state.aveniaTicketId ); - if (!ticket) { - return false; - } + if (!ticket) return false; const isOnHold = ticket.status.trim().toUpperCase() === AveniaTicketStatus.ON_HOLD; - if (state.onHold === isOnHold) { - return true; - } + if (state.onHold === isOnHold) return true; - await updateState({ - ...state, - onHold: isOnHold - }); + await updateState({ ...state, onHold: isOnHold }); return true; } diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-mint/registration.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/registration.ts new file mode 100644 index 000000000..e1fd6e408 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/registration.ts @@ -0,0 +1,42 @@ +import { resolveAveniaAccountForRamp } from "../../../../avenia-account"; +import { createAveniaOnrampTicket } from "../../core/avenia-registration"; +import type { RegisterCtx, RegistrationResult } from "../../core/types"; +import type { AveniaMintMetadata } from "./simulation"; + +export interface AveniaMintRegistrationInput extends Record { + taxId?: string; +} + +export interface AveniaMintRegistrationFacts { + aveniaTicketId: string; + taxId: string; +} + +export interface AveniaMintResponseArtifacts extends Record { + depositQrCode: string; +} + +interface AveniaMintRegistrationDependencies { + createTicket: typeof createAveniaOnrampTicket; + resolveAccount: typeof resolveAveniaAccountForRamp; +} + +export function createRegisterAveniaMint( + dependencies: AveniaMintRegistrationDependencies = { + createTicket: createAveniaOnrampTicket, + resolveAccount: resolveAveniaAccountForRamp + } +) { + return async function registerAveniaMint( + ctx: RegisterCtx + ): Promise> { + const aveniaAccount = await dependencies.resolveAccount(ctx.authenticatedUser.id, ctx.input.taxId); + const ticket = await dependencies.createTicket(aveniaAccount.taxId, ctx.quote, ctx.quote.inputAmount); + return { + facts: { aveniaTicketId: ticket.aveniaTicketId, taxId: aveniaAccount.taxId }, + responseArtifacts: { depositQrCode: ticket.brCode } satisfies AveniaMintResponseArtifacts + }; + }; +} + +export const registerAveniaMint = createRegisterAveniaMint(); diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-mint/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/simulation.ts new file mode 100644 index 000000000..2147a166a --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/simulation.ts @@ -0,0 +1,109 @@ +import { + AveniaPaymentMethod, + BlockchainSendMethod, + BrlaApiService, + BrlaCurrency, + EvmToken, + FiatToken, + getAnyFiatTokenDetailsMoonbeam, + multiplyByPowerOfTen, + Networks, + RampCurrency +} from "@vortexfi/shared"; +import Big from "big.js"; +import { evmIO } from "../../core/io"; +import { defineContext, type SerializableBig } from "../../core/metadata"; +import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; + +export interface AnchorOperationMetadata { + currency: RampCurrency; + fee: SerializableBig; + inputAmountDecimal: SerializableBig; + inputAmountRaw: string; + outputAmountDecimal: SerializableBig; + outputAmountRaw: string; +} + +export interface AveniaMintMetadata { + mint: AnchorOperationMetadata; + network?: Networks; + transfer: AnchorOperationMetadata; +} + +export const AveniaMintContext = defineContext()("aveniaMint"); + +export async function simulateAveniaMint( + input: PhaseIO, + ctx: PhaseCtx +): Promise, AveniaMintMetadata>> { + const brlaTokenDetails = getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL); + const inputAmountDecimal = new Big(input.amount); + const inputAmountRaw = multiplyByPowerOfTen(inputAmountDecimal, brlaTokenDetails.decimals).toFixed(0, 0); + + const brlaApiService = BrlaApiService.getInstance(); + const aveniaPayInToInternalQuote = await brlaApiService.createPayInQuote( + { + inputAmount: inputAmountDecimal.toString(), + inputCurrency: BrlaCurrency.BRL, + inputPaymentMethod: AveniaPaymentMethod.PIX, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.INTERNAL, + outputThirdParty: false + }, + { useCache: true } + ); + + const aveniaTransferQuote = await brlaApiService.createPayInQuote( + { + blockchainSendMethod: BlockchainSendMethod.PERMIT, + inputAmount: aveniaPayInToInternalQuote.outputAmount.toString(), + inputCurrency: BrlaCurrency.BRLA, + inputPaymentMethod: AveniaPaymentMethod.INTERNAL, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.MOONBEAM, + outputThirdParty: false + }, + { useCache: true } + ); + + const gasFeePayIn = aveniaPayInToInternalQuote.appliedFees.find(fee => fee.type === "Gas Fee"); + const receivedBrlaDecimal = new Big(aveniaPayInToInternalQuote.outputAmount).minus(gasFeePayIn?.amount || 0); + const receivedBrlaRaw = multiplyByPowerOfTen(receivedBrlaDecimal, brlaTokenDetails.decimals).toFixed(0, 0); + + const gasFeeTransfer = aveniaTransferQuote.appliedFees.find(fee => fee.type === "Gas Fee"); + let gasFeeBuffer = new Big(0.1); + if (gasFeePayIn || gasFeeTransfer) { + const gasFeeAmount = new Big(gasFeePayIn?.amount || 0).plus(gasFeeTransfer?.amount || 0); + gasFeeBuffer = gasFeeAmount.mul(0.5); + } + + const mintedBrlaDecimal = new Big(aveniaTransferQuote.outputAmount).minus(gasFeeBuffer); + const mintedBrlaRaw = multiplyByPowerOfTen(mintedBrlaDecimal, brlaTokenDetails.decimals).toFixed(0, 0); + const transferFee = receivedBrlaDecimal.minus(mintedBrlaDecimal); + + ctx.addNote(`AveniaMint: assuming ${mintedBrlaDecimal.toFixed()} BRLA minted on the Base ephemeral account`); + + return { + metadata: { + mint: { + currency: FiatToken.BRL, + fee: inputAmountDecimal.minus(aveniaPayInToInternalQuote.outputAmount), + inputAmountDecimal, + inputAmountRaw, + outputAmountDecimal: receivedBrlaDecimal, + outputAmountRaw: receivedBrlaRaw + }, + transfer: { + currency: FiatToken.BRL, + fee: transferFee, + inputAmountDecimal: receivedBrlaDecimal, + inputAmountRaw: receivedBrlaRaw, + outputAmountDecimal: mintedBrlaDecimal, + outputAmountRaw: mintedBrlaRaw + } + }, + output: evmIO(EvmToken.BRLA, Networks.Base, mintedBrlaDecimal, mintedBrlaRaw) + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-mint/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/transactions.ts new file mode 100644 index 000000000..7aca86322 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-mint/transactions.ts @@ -0,0 +1,44 @@ +import { EphemeralAccountType, EvmToken, EvmTransactionData, evmTokenConfig, Networks } from "@vortexfi/shared"; +import { requireAccount } from "../../core/accounts"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { encodeEvmTransactionData, prepareBaseCleanupApproval } from "../../core/evm-transactions"; +import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { AveniaMintRegistrationFacts } from "./registration"; +import type { AveniaMintMetadata } from "./simulation"; + +export interface AveniaMintPreparation { + taxId?: string; +} + +// AveniaMint mints BRLA onto the Base ephemeral server-side, so it needs no presigned main-lane +// tx — only the cleanup approval that lets the funding account sweep leftover BRLA dust. +export async function prepareAveniaMintTxs( + ctx: PrepareCtx +): Promise { + if (!ctx.ownRegistrationFacts) throw new Error("prepareAveniaMintTxs: Missing Avenia registration facts"); + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const brlaTokenDetails = evmTokenConfig[Networks.Base][EvmToken.BRLA]; + if (!brlaTokenDetails) { + throw new Error("prepareAveniaMintTxs: BRLA token details not found for Base"); + } + + const fundingAccountAddress = getEvmFundingAccount(Networks.Base).address; + const brlaCleanupApproval = await prepareBaseCleanupApproval( + brlaTokenDetails.erc20AddressSourceChain as `0x${string}`, + fundingAccountAddress, + Networks.Base + ); + + return { + intents: [ + { + lane: "cleanup", + network: Networks.Base, + phase: "baseCleanupBrla", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(brlaCleanupApproval) as EvmTransactionData + } + ], + state: { ...ctx.ownRegistrationFacts } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-moonbeam-mint/index.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-moonbeam-mint/index.ts new file mode 100644 index 000000000..5e681e280 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-moonbeam-mint/index.ts @@ -0,0 +1,43 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import Big from "big.js"; +import { calculateFees } from "../../core/fees"; +import type { Phase, PhaseIO } from "../../core/types"; +import { BrlaOnrampMintExecutor } from "../avenia-mint/execution"; +import { + type AveniaMintRegistrationFacts, + type AveniaMintRegistrationInput, + registerAveniaMint +} from "../avenia-mint/registration"; +import { AveniaMintContext, simulateAveniaMint } from "../avenia-mint/simulation"; +import { prepareAveniaMoonbeamMintTxs } from "./transactions"; + +export const AveniaMoonbeamMint: Phase< + typeof AveniaMintContext, + PhaseIO, + PhaseIO, + AveniaMintRegistrationFacts, + AveniaMintRegistrationInput +> = { + context: AveniaMintContext, + executors: [new BrlaOnrampMintExecutor()], + externalOperations: { register: { provider: "avenia" } }, + name: "AveniaMoonbeamMint", + phases: ["brlaOnrampMint"], + prepareTxs: prepareAveniaMoonbeamMintTxs, + register: registerAveniaMint, + async simulate(input, ctx) { + const result = await simulateAveniaMint(input, ctx); + return { + ...result, + fees: await calculateFees(ctx, { + anchor: { + amount: new Big(result.metadata.mint.fee).plus(result.metadata.transfer.fee).toString(), + currency: FiatToken.BRL + }, + network: { amount: "0.03", currency: EvmToken.USDC } + }), + metadata: { ...result.metadata, network: Networks.Moonbeam }, + output: { ...result.output, chain: Networks.Moonbeam } + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-moonbeam-mint/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-moonbeam-mint/transactions.ts new file mode 100644 index 000000000..0628bdd76 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-moonbeam-mint/transactions.ts @@ -0,0 +1,11 @@ +import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { AveniaMintRegistrationFacts } from "../avenia-mint/registration"; +import type { AveniaMintMetadata } from "../avenia-mint/simulation"; + +export async function prepareAveniaMoonbeamMintTxs( + ctx: PrepareCtx +): Promise { + const taxId = ctx.ownRegistrationFacts?.taxId; + if (!taxId) throw new Error("AveniaMoonbeamMint requires registered Avenia facts"); + return { intents: [], state: { taxId } }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-fee/index.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-fee/index.ts new file mode 100644 index 000000000..290220d2d --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-fee/index.ts @@ -0,0 +1,15 @@ +import type { ChainBrand, Phase, PhaseIO, TokenBrand } from "../../core/types"; +import { AveniaOfframpFeeContext, simulateAveniaOfframpFee } from "./simulation"; + +export function AveniaOfframpFee(): Phase< + typeof AveniaOfframpFeeContext, + PhaseIO, + PhaseIO +> { + return { + context: AveniaOfframpFeeContext, + name: "AveniaOfframpFee", + phases: [], + simulate: simulateAveniaOfframpFee + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-fee/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-fee/simulation.ts new file mode 100644 index 000000000..672e36dfb --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-fee/simulation.ts @@ -0,0 +1,32 @@ +import { BrlaApiService, FiatToken, RampCurrency } from "@vortexfi/shared"; +import Big from "big.js"; +import { overrideFees } from "../../core/fees"; +import { defineContext } from "../../core/metadata"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; + +export interface AveniaOfframpFeeMetadata { + anchorFeeBrl: string; + grossAmountBrl: string; +} + +export const AveniaOfframpFeeContext = defineContext()("aveniaOfframpFee"); + +export async function simulateAveniaOfframpFee( + input: PhaseIO, + ctx: PhaseCtx +): Promise, AveniaOfframpFeeMetadata>> { + const grossAmountBrl = input.amount.toFixed(2, 0); + const quote = await BrlaApiService.getInstance().createPayOutQuote( + { outputAmount: grossAmountBrl, outputThirdParty: false }, + { useCache: true } + ); + const anchorFeeBrl = new Big(quote.inputAmount).minus(quote.outputAmount).toString(); + const fees = await overrideFees(ctx, { + anchor: { amount: anchorFeeBrl, currency: FiatToken.BRL as RampCurrency } + }); + return { + fees, + metadata: { anchorFeeBrl, grossAmountBrl }, + output: input + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/execution.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/execution.ts new file mode 100644 index 000000000..015242089 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/execution.ts @@ -0,0 +1,180 @@ +import { + AveniaTicketStatus, + BrlaApiService, + EvmClientManager, + Networks, + PixOutputTicketPayload, + RampPhase, + sleep +} from "@vortexfi/shared"; +import Big from "big.js"; +import logger from "../../../../../../config/logger"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { findAveniaCustomerByTaxId } from "../../../../avenia/avenia-customer.service"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { ensurePresignedTransferFunded } from "../../core/destination-funding"; +import { getBlockMetadata, getBlockState, getFlowMetadata } from "../../core/metadata"; +import { getAnchorPayoutMaxRetries, isAnchorMockingEnabled } from "../anchor-test-mode"; +import { AveniaPendulumOfframpContext } from "../avenia-pendulum-offramp/simulation"; +import type { AveniaOfframpPayoutRegistrationFacts } from "./registration"; +import { AveniaOfframpPayoutContext } from "./simulation"; + +const POLL_INTERVAL_MS = 5_000; +const POLL_TIMEOUT_MS = 5 * 60 * 1_000; + +export class AveniaOfframpPayoutExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "brlaPayoutOnBase"; + } + + public getMaxRetries(): number { + return getAnchorPayoutMaxRetries(); + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + if (isAnchorMockingEnabled()) { + logger.warn(`AveniaOfframpPayoutExecutor: Pausing test ramp ${state.id} before the anchor payout`); + throw this.createRecoverableError("Avenia payout paused by MOCK_ANCHOR_OPERATIONS"); + } + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) throw new Error("AveniaOfframpPayoutExecutor: Quote not found"); + const isPendulumPayout = Boolean(getFlowMetadata(quote.metadata).blocks[AveniaPendulumOfframpContext.key]); + const metadata = isPendulumPayout + ? getBlockMetadata(quote.metadata, AveniaPendulumOfframpContext) + : getBlockMetadata(quote.metadata, AveniaOfframpPayoutContext); + const facts = getBlockState( + state.state, + isPendulumPayout ? AveniaPendulumOfframpContext : AveniaOfframpPayoutContext + ); + const customer = await findAveniaCustomerByTaxId(facts.taxId); + if (!customer) throw new Error("AveniaOfframpPayoutExecutor: Avenia customer not found"); + const subAccountId = customer.providerSubaccountId ?? ""; + if (state.state.payOutTicketId) { + await this.waitForPaid(state.state.payOutTicketId, subAccountId, signal); + return state; + } + if (!isPendulumPayout) await this.sendPayoutTransfer(state, signal); + const api = BrlaApiService.getInstance(); + await this.poll( + async () => { + const balance = await abortableCall(signal, () => api.getAccountBalance(subAccountId)); + return new Big(balance?.balances?.BRLA ?? 0).gte(new Big(metadata.transferAmountDecimal).round(2, 0)); + }, + "Avenia BRLA balance", + signal + ); + try { + const ticket = await this.runFinancialOperation(state, { + attemptClass: "provider-payout-ticket", + externalId: result => result.id, + perform: async () => { + const payoutQuote = await abortableCall(signal, () => + api.createPayOutQuote({ + outputAmount: new Big(quote.outputAmount).round(2, 0).toString(), + outputThirdParty: false, + subAccountId + }) + ); + throwIfAborted(signal); + const payload: PixOutputTicketPayload = { + quoteToken: payoutQuote.quoteToken, + ticketBlockchainInput: { walletAddress: facts.brlaEvmAddress }, + ticketBrlPixOutput: { pixKey: facts.pixDestination } + }; + const created = await abortableCall(signal, () => api.createPixOutputTicket(payload, subAccountId)); + return { id: created.id }; + }, + provider: "avenia", + request: { + brlaEvmAddress: facts.brlaEvmAddress, + outputAmount: new Big(quote.outputAmount).round(2, 0).toString(), + pixDestination: facts.pixDestination, + subAccountId + }, + signal + }); + await state.update({ state: { ...state.state, payOutTicketId: ticket.id } }); + await this.waitForPaid(ticket.id, subAccountId, signal); + return state; + } catch (error) { + if (error instanceof PhaseError) throw error; + logger.error("AveniaOfframpPayoutExecutor: Failed to trigger PIX payout", error); + throw this.createUnrecoverableError("AveniaOfframpPayoutExecutor: Failed to trigger BRLA offramp"); + } + } + + private async sendPayoutTransfer(state: RampState, signal?: AbortSignal): Promise { + try { + const client = EvmClientManager.getInstance(); + const base = client.getClient(Networks.Base); + const transaction = this.getPresignedTransaction(state, "brlaPayoutOnBase"); + if (!transaction || typeof transaction.txData !== "string") { + throw new Error("AveniaOfframpPayoutExecutor: Missing presigned payout transaction"); + } + if (state.state.brlaPayoutTxHash) { + const receipt = await abortableCall(signal, () => + base.waitForTransactionReceipt({ hash: state.state.brlaPayoutTxHash as `0x${string}` }) + ); + if (receipt.status === "success") return; + throw this.createUnrecoverableError(`Payout transfer ${state.state.brlaPayoutTxHash} failed`); + } else { + await ensurePresignedTransferFunded(transaction.txData as `0x${string}`, Networks.Base, this.getPhaseName(), signal); + } + const { hash } = await this.runFinancialOperation(state, { + attemptClass: "presigned-payout-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await client.sendRawTransactionWithRetry(Networks.Base, transaction.txData as `0x${string}`); + const receipt = await abortableCall(signal, () => base.waitForTransactionReceipt({ hash: hash as `0x${string}` })); + if (receipt.status !== "success") throw new Error(`Payout transfer ${hash} failed`); + return { hash: hash as `0x${string}` }; + }, + provider: Networks.Base, + request: { network: Networks.Base, signedTransaction: transaction.txData }, + signal + }); + await state.update({ state: { ...state.state, brlaPayoutTxHash: hash as `0x${string}` } }); + } catch (error) { + if (error instanceof PhaseError) throw error; + logger.error("AveniaOfframpPayoutExecutor: Failed to send BRLA payout transaction", error); + throw this.createRecoverableError("Failed to send BRLA payout transaction"); + } + } + + private async waitForPaid(ticketId: string, subAccountId: string, signal?: AbortSignal): Promise { + const api = BrlaApiService.getInstance(); + await this.poll( + async () => { + const ticket = await abortableCall(signal, () => api.getAveniaPayoutTicket(ticketId, subAccountId)); + if (ticket.status === AveniaTicketStatus.FAILED) { + throw this.createUnrecoverableError("AveniaOfframpPayoutExecutor: Ticket status is FAILED"); + } + return ticket.status === AveniaTicketStatus.PAID; + }, + `Avenia payout ticket ${ticketId}`, + signal + ); + } + + private async poll(check: () => Promise, label: string, signal?: AbortSignal): Promise { + const start = Date.now(); + let lastError: unknown; + while (Date.now() - start < POLL_TIMEOUT_MS) { + throwIfAborted(signal); + try { + if (await check()) return; + } catch (error) { + if (error instanceof PhaseError) throw error; + lastError = error; + } + await sleep(POLL_INTERVAL_MS, signal); + } + if (lastError) throw this.createUnrecoverableError(`${label} polling failed: ${lastError}`); + throw this.createRecoverableError(`${label} polling timed out`); + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/index.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/index.ts new file mode 100644 index 000000000..95f277fa7 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/index.ts @@ -0,0 +1,26 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import type { Phase, PhaseIO } from "../../core/types"; +import { AveniaOfframpPayoutExecutor } from "./execution"; +import { + type AveniaOfframpPayoutRegistrationFacts, + type AveniaOfframpPayoutRegistrationInput, + registerAveniaOfframpPayout +} from "./registration"; +import { AveniaOfframpPayoutContext, simulateAveniaOfframpPayout } from "./simulation"; +import { prepareAveniaOfframpPayoutTxs } from "./transactions"; + +export const AveniaOfframpPayout: Phase< + typeof AveniaOfframpPayoutContext, + PhaseIO, + PhaseIO, + AveniaOfframpPayoutRegistrationFacts, + AveniaOfframpPayoutRegistrationInput +> = { + context: AveniaOfframpPayoutContext, + executors: [new AveniaOfframpPayoutExecutor()], + name: "AveniaOfframpPayout", + phases: ["brlaPayoutOnBase"], + prepareTxs: prepareAveniaOfframpPayoutTxs, + register: registerAveniaOfframpPayout, + simulate: simulateAveniaOfframpPayout +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/registration.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/registration.ts new file mode 100644 index 000000000..1c8a8c802 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/registration.ts @@ -0,0 +1,64 @@ +import { normalizeTaxId } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { APIError } from "../../../../../errors/api-error"; +import { resolveAveniaAccountForRamp } from "../../../../avenia-account"; +import { validateAveniaOfframpRecipient } from "../../core/avenia-registration"; +import type { RegisterCtx, RegistrationResult } from "../../core/types"; +import type { AveniaOfframpPayoutMetadata } from "./simulation"; + +export interface AveniaOfframpPayoutRegistrationInput extends Record { + pixDestination?: string; + receiverTaxId?: string; + taxId?: string; +} + +export interface AveniaOfframpPayoutRegistrationFacts { + brlaEvmAddress: string; + pixDestination: string; + receiverTaxId: string; + taxId: string; +} + +export interface AveniaOfframpPayoutResponseArtifacts extends Record { + depositQrCode: string; +} + +interface AveniaOfframpRegistrationDependencies { + resolveAccount: typeof resolveAveniaAccountForRamp; + validateRecipient: typeof validateAveniaOfframpRecipient; +} + +export function createRegisterAveniaOfframpPayout( + dependencies: AveniaOfframpRegistrationDependencies = { + resolveAccount: resolveAveniaAccountForRamp, + validateRecipient: validateAveniaOfframpRecipient + } +): ( + ctx: RegisterCtx +) => Promise> { + return async ctx => { + if (!ctx.input.pixDestination) { + throw new APIError({ message: "pixDestination is required for offramp to BRL", status: httpStatus.BAD_REQUEST }); + } + const aveniaAccount = await dependencies.resolveAccount(ctx.authenticatedUser.id, ctx.input.taxId); + const taxId = aveniaAccount.taxId; + const receiverTaxId = normalizeTaxId(ctx.input.receiverTaxId || taxId); + const subaccount = await dependencies.validateRecipient( + taxId, + ctx.input.pixDestination, + receiverTaxId, + ctx.quote.outputAmount + ); + return { + facts: { + brlaEvmAddress: subaccount.wallets.evm, + pixDestination: ctx.input.pixDestination, + receiverTaxId, + taxId + }, + responseArtifacts: { depositQrCode: subaccount.brCode } satisfies AveniaOfframpPayoutResponseArtifacts + }; + }; +} + +export const registerAveniaOfframpPayout = createRegisterAveniaOfframpPayout(); diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/simulation.ts new file mode 100644 index 000000000..eb1df3ce7 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/simulation.ts @@ -0,0 +1,31 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import Big from "big.js"; +import { defineContext, SerializableBig } from "../../core/metadata"; +import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; + +export interface AveniaOfframpPayoutMetadata { + payoutAmountDecimal: SerializableBig; + payoutAmountRaw: string; + transferAmountDecimal: SerializableBig; + transferAmountRaw: string; +} + +export const AveniaOfframpPayoutContext = defineContext()("aveniaOfframpPayout"); + +export async function simulateAveniaOfframpPayout( + input: PhaseIO, + ctx: PhaseCtx +): Promise, AveniaOfframpPayoutMetadata>> { + const anchorFee = new Big(ctx.fees?.displayFiat?.anchor ?? 0); + const payoutAmount = input.amount.minus(anchorFee); + const payoutAmountRaw = payoutAmount.times(100).toFixed(0, 0); + return { + metadata: { + payoutAmountDecimal: payoutAmount, + payoutAmountRaw, + transferAmountDecimal: input.amount, + transferAmountRaw: input.amountRaw + }, + output: { amount: payoutAmount, amountRaw: payoutAmountRaw, chain: "fiat", token: FiatToken.BRL } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/transactions.ts new file mode 100644 index 000000000..2c376d499 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-offramp-payout/transactions.ts @@ -0,0 +1,70 @@ +import { EphemeralAccountType, EvmToken, EvmTransactionData, evmTokenConfig, Networks } from "@vortexfi/shared"; +import { requireAccount } from "../../core/accounts"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { + createDestinationTransferTransaction, + encodeEvmTransactionData, + prepareBaseCleanupApproval +} from "../../core/evm-transactions"; +import type { PrepareCtx, PreparedPhaseTxs, TxIntent } from "../../core/types"; +import type { AveniaOfframpPayoutRegistrationFacts } from "./registration"; +import type { AveniaOfframpPayoutMetadata } from "./simulation"; + +export async function prepareAveniaOfframpPayoutTxs( + ctx: PrepareCtx +): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const facts = ctx.ownRegistrationFacts; + if (!facts) { + throw new Error("prepareAveniaOfframpPayoutTxs: Missing Avenia registration facts"); + } + const brla = evmTokenConfig[Networks.Base][EvmToken.BRLA]; + if (!brla) { + throw new Error("prepareAveniaOfframpPayoutTxs: Missing Base BRLA configuration"); + } + const payout = await createDestinationTransferTransaction({ + amountRaw: ctx.ownMetadata.transferAmountRaw, + destinationNetwork: Networks.Base, + isNativeToken: false, + toAddress: facts.brlaEvmAddress, + toToken: brla.erc20AddressSourceChain as `0x${string}` + }); + const fundingAddress = getEvmFundingAccount(Networks.Base).address; + const cleanupTokens = [ + [EvmToken.USDC, "baseCleanupUsdc"], + [EvmToken.BRLA, "baseCleanupBrla"], + [EvmToken.AXLUSDC, "baseCleanupAxlUsdc"] + ] as const; + const cleanupIntents: TxIntent[] = []; + for (const [token, phase] of cleanupTokens) { + const details = evmTokenConfig[Networks.Base][token]; + if (!details) { + throw new Error(`prepareAveniaOfframpPayoutTxs: Missing Base ${token} configuration`); + } + const approval = await prepareBaseCleanupApproval( + details.erc20AddressSourceChain as `0x${string}`, + fundingAddress, + Networks.Base + ); + cleanupIntents.push({ + lane: "cleanup", + network: Networks.Base, + phase, + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(approval) as EvmTransactionData + }); + } + return { + intents: [ + { + lane: "main", + network: Networks.Base, + phase: "brlaPayoutOnBase", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(payout) as EvmTransactionData + }, + ...cleanupIntents + ], + state: { ...facts } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-pendulum-offramp/execution.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-pendulum-offramp/execution.ts new file mode 100644 index 000000000..c097720dc --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-pendulum-offramp/execution.ts @@ -0,0 +1,103 @@ +import { + ApiManager, + decodeSubmittableExtrinsic, + FiatToken, + getAddressForFormat, + getAnyFiatTokenDetailsMoonbeam, + getEvmTokenBalance, + MOONBEAM_XCM_FEE_GLMR, + Networks, + nativeToDecimal, + RampPhase, + sleep, + submitXTokens +} from "@vortexfi/shared"; +import Big from "big.js"; +import logger from "../../../../../../config/logger"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { SubsidyToken } from "../../../../../../models/subsidy.model"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { getBlockMetadata, getBlockState } from "../../core/metadata"; +import type { AveniaOfframpPayoutRegistrationFacts } from "../avenia-offramp-payout/registration"; +import { AveniaPendulumOfframpContext } from "./simulation"; + +const POLL_INTERVAL_MS = 5_000; +const POLL_TIMEOUT_MS = 2 * 60_000; + +export class PendulumToAveniaXcmExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "pendulumToMoonbeamXcm"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) throw new Error("PendulumToAveniaXcmExecutor: quote not found"); + const metadata = getBlockMetadata(quote.metadata, AveniaPendulumOfframpContext); + const facts = getBlockState(state.state, AveniaPendulumOfframpContext); + const substrateAddress = state.state.substrateEphemeralAddress; + if (!substrateAddress) throw new Error("PendulumToAveniaXcmExecutor: missing Substrate ephemeral"); + const pendulum = await ApiManager.getInstance().getApi("pendulum"); + const arrived = async () => + ( + await getEvmTokenBalance({ + chain: Networks.Moonbeam, + ownerAddress: facts.brlaEvmAddress as `0x${string}`, + tokenAddress: getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL).moonbeamErc20Address as `0x${string}` + }) + ).gte(metadata.transferAmountRaw); + const leftPendulum = async () => { + const balance = await pendulum.api.query.tokens.accounts(substrateAddress, metadata.pendulumCurrencyId); + return new Big((balance as unknown as { free?: { toString(): string } }).free?.toString() ?? "0").lt( + metadata.transferAmountRaw + ); + }; + try { + let submittedHash: string | undefined; + if (!state.state.pendulumToMoonbeamXcmHash && !(await leftPendulum())) { + throwIfAborted(signal); + const presigned = this.getPresignedTransaction(state, this.getPhaseName()); + const extrinsic = decodeSubmittableExtrinsic(presigned.txData as string, pendulum.api); + const { hash } = await this.runFinancialOperation(state, { + attemptClass: "pendulum-moonbeam-xcm-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + return abortableCall(signal, () => + submitXTokens(getAddressForFormat(substrateAddress, pendulum.ss58Format), extrinsic) + ); + }, + provider: "pendulum", + request: { network: "pendulum", signedTransaction: presigned.txData }, + signal + }); + submittedHash = hash; + state.state = { ...state.state, pendulumToMoonbeamXcmHash: hash }; + await state.update({ state: state.state }); + } + const started = Date.now(); + while (Date.now() - started < POLL_TIMEOUT_MS) { + throwIfAborted(signal); + if (await abortableCall(signal, arrived)) { + if (submittedHash !== undefined) { + await this.createSubsidy( + state, + nativeToDecimal(MOONBEAM_XCM_FEE_GLMR, 18).toNumber(), + SubsidyToken.GLMR, + substrateAddress, + submittedHash || "0x" + ); + } + return state; + } + await sleep(POLL_INTERVAL_MS, signal); + } + throw this.createRecoverableError("PendulumToAveniaXcmExecutor: timed out waiting for Moonbeam arrival"); + } catch (error) { + logger.error("PendulumToAveniaXcmExecutor failed", error); + if (error instanceof Error && "isRecoverable" in error) throw error; + throw this.createRecoverableError("PendulumToAveniaXcmExecutor failed"); + } + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-pendulum-offramp/index.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-pendulum-offramp/index.ts new file mode 100644 index 000000000..3ca478f0e --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-pendulum-offramp/index.ts @@ -0,0 +1,63 @@ +import { + createPendulumToMoonbeamTransfer, + EphemeralAccountType, + encodeSubmittableExtrinsic, + FiatToken, + Networks +} from "@vortexfi/shared"; +import { requireAccount } from "../../core/accounts"; +import type { Phase, PhaseIO } from "../../core/types"; +import { AveniaOfframpPayoutExecutor } from "../avenia-offramp-payout/execution"; +import { + type AveniaOfframpPayoutRegistrationFacts, + type AveniaOfframpPayoutRegistrationInput, + registerAveniaOfframpPayout +} from "../avenia-offramp-payout/registration"; +import { PendulumToAveniaXcmExecutor } from "./execution"; +import { AveniaPendulumOfframpContext, type AveniaPendulumOfframpMetadata, simulateAveniaPendulumOfframp } from "./simulation"; + +export const AveniaPendulumOfframp: Phase< + typeof AveniaPendulumOfframpContext, + PhaseIO, + PhaseIO, + AveniaOfframpPayoutRegistrationFacts, + AveniaOfframpPayoutRegistrationInput +> = { + context: AveniaPendulumOfframpContext, + executors: [new PendulumToAveniaXcmExecutor(), new AveniaOfframpPayoutExecutor()], + name: "AveniaPendulumOfframp", + phases: ["pendulumToMoonbeamXcm", "brlaPayoutOnBase"], + async prepareTxs(ctx) { + const substrate = requireAccount(ctx.accounts, EphemeralAccountType.Substrate); + const facts = ctx.ownRegistrationFacts; + if (!facts) throw new Error("AveniaPendulumOfframp: missing registration facts"); + const transaction = await createPendulumToMoonbeamTransfer( + facts.brlaEvmAddress, + ctx.ownMetadata.transferAmountRaw, + ctx.ownMetadata.pendulumCurrencyId + ); + return { + intents: [ + { + lane: "main", + network: Networks.Pendulum, + phase: "pendulumToMoonbeamXcm", + signer: substrate.address, + txData: encodeSubmittableExtrinsic(transaction) + } + ], + state: facts + }; + }, + register: registerAveniaOfframpPayout as unknown as Phase< + typeof AveniaPendulumOfframpContext, + PhaseIO, + PhaseIO, + AveniaOfframpPayoutRegistrationFacts, + AveniaOfframpPayoutRegistrationInput + >["register"], + simulate: simulateAveniaPendulumOfframp as ( + input: PhaseIO, + ctx: import("../../core/types").PhaseCtx + ) => Promise, AveniaPendulumOfframpMetadata>> +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/avenia-pendulum-offramp/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/avenia-pendulum-offramp/simulation.ts new file mode 100644 index 000000000..5e5b18d35 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/avenia-pendulum-offramp/simulation.ts @@ -0,0 +1,37 @@ +import { FiatToken, getPendulumDetails, Networks } from "@vortexfi/shared"; +import Big from "big.js"; +import { defineContext, type SerializableBig } from "../../core/metadata"; + +export interface AveniaPendulumOfframpMetadata { + payoutAmountDecimal: SerializableBig; + payoutAmountRaw: string; + pendulumCurrencyId: ReturnType["currencyId"]; + transferAmountDecimal: SerializableBig; + transferAmountRaw: string; + transferNetwork: typeof Networks.Moonbeam; +} + +export const AveniaPendulumOfframpContext = defineContext()("aveniaPendulumOfframp"); + +export async function simulateAveniaPendulumOfframp( + input: import("../../core/types").PhaseIO, + ctx: import("../../core/types").PhaseCtx +) { + const payoutAmount = input.amount.minus(ctx.fees?.displayFiat?.anchor ?? 0); + return { + metadata: { + payoutAmountDecimal: payoutAmount, + payoutAmountRaw: payoutAmount.times(100).toFixed(0, 0), + pendulumCurrencyId: getPendulumDetails(FiatToken.BRL).currencyId, + transferAmountDecimal: input.amount, + transferAmountRaw: input.amountRaw, + transferNetwork: Networks.Moonbeam + }, + output: { + amount: payoutAmount, + amountRaw: payoutAmount.times(new Big(100)).toFixed(0, 0), + chain: "fiat" as const, + token: FiatToken.BRL + } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/destination-transfer/execution.ts b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/execution.ts new file mode 100644 index 000000000..38f6d842f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/execution.ts @@ -0,0 +1,207 @@ +import { + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + getOnChainTokenDetails, + multiplyByPowerOfTen, + RampPhase +} from "@vortexfi/shared"; +import { decodeFunctionData, erc20Abi, keccak256, parseTransaction } from "viem"; +import logger from "../../../../../../config/logger"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { PhaseError, UnrecoverablePhaseError } from "../../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { StateMetadata } from "../../../../phases/meta-state-types"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { FinancialOperationRejectedError } from "../../core/financial-operation"; + +const BALANCE_POLLING_TIME_MS = 5000; +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes + +function validateDestinationTransferRecipient(rawTx: `0x${string}`, expectedDestination: string): void { + const decoded = parseTransaction(rawTx); + + if (!decoded.to) { + throw new Error("DestinationTransferExecutor: Presigned transaction has no 'to' address"); + } + + const isNativeTransfer = !decoded.data || decoded.data === "0x"; + + if (isNativeTransfer) { + if (decoded.to.toLowerCase() !== expectedDestination.toLowerCase()) { + throw new Error( + "DestinationTransferExecutor: Native transfer recipient mismatch. " + + `Expected ${expectedDestination}, got ${decoded.to}` + ); + } + return; + } + + // ERC-20 transfer: `to` is the token contract, recipient is in calldata + if (!decoded.data) { + throw new Error("DestinationTransferExecutor: ERC-20 transfer missing calldata"); + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: decoded.data }); + if (functionName !== "transfer") { + throw new Error(`DestinationTransferExecutor: Expected ERC-20 'transfer' call, got '${functionName}'`); + } + + const [recipient] = args as [string, bigint]; + if (recipient.toLowerCase() !== expectedDestination.toLowerCase()) { + throw new Error( + "DestinationTransferExecutor: ERC-20 transfer recipient mismatch. " + `Expected ${expectedDestination}, got ${recipient}` + ); + } +} + +export class DestinationTransferExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "destinationTransfer"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const evmClientManager = EvmClientManager.getInstance(); + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; + if (!outTokenDetails) { + throw new Error( + `DestinationTransferExecutor: Unsupported output token ${quote.outputCurrency} for network ${quote.network}` + ); + } + + const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); + const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString(); + const destinationNetwork = quote.network as EvmNetworks; + const { destinationTransferTxHash, destinationAddress } = state.state as StateMetadata; + + if (destinationAddress) { + validateDestinationTransferRecipient(destinationTransfer as `0x${string}`, destinationAddress); + } else { + logger.warn("DestinationTransferExecutor: No destinationAddress in state metadata, skipping recipient validation"); + } + if (destinationTransferTxHash) { + try { + const client = evmClientManager.getClient(destinationNetwork); + const receipt = await abortableCall(signal, () => + client.getTransactionReceipt({ hash: destinationTransferTxHash as `0x${string}` }) + ); + + if (receipt.status === "success") { + return state; + } else { + throw new Error(`Transaction ${destinationTransferTxHash} failed on chain.`); + } + } catch (error) { + if (error instanceof Error && error.name !== "TransactionReceiptNotFoundError") { + throw error; + } + // If receipt not found, proceed to normal flow + } + } + + // Nonce-gap guard: a presigned nonce ahead of the live ephemeral nonce can never be mined and + // would silently retry until the processor gives up, stranding user funds. Both parsing and + // the live RPC preflight fail closed; only the latter is recoverable. + if (!destinationTransferTxHash && state.state.evmEphemeralAddress) { + let presignedNonce: number; + try { + const parsedNonce = parseTransaction(destinationTransfer as `0x${string}`).nonce; + if (parsedNonce === undefined) { + throw new Error("transaction has no nonce"); + } + presignedNonce = parsedNonce; + } catch (error) { + throw this.createUnrecoverableError( + `DestinationTransferExecutor: server-generated presigned destination transfer could not be validated: ${(error as Error).message}` + ); + } + + let liveNonce: number; + try { + liveNonce = await abortableCall(signal, () => + evmClientManager.getClient(destinationNetwork).getTransactionCount({ + address: state.state.evmEphemeralAddress as `0x${string}`, + blockTag: "pending" + }) + ); + } catch (error) { + throw this.createRecoverableError( + `DestinationTransferExecutor: destination nonce preflight is unavailable: ${(error as Error).message}` + ); + } + + if (presignedNonce > liveNonce) { + throw this.createUnrecoverableError( + `DestinationTransferExecutor: presigned nonce ${presignedNonce} is ahead of the ephemeral live nonce ${liveNonce}. ` + + "The transfer can never broadcast (nonce gap); manual review required." + ); + } + } + + try { + await checkEvmBalanceForToken({ + amountDesiredRaw: expectedAmountRaw, + chain: destinationNetwork, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: state.state.evmEphemeralAddress, + signal, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); + + const signedTransaction = destinationTransfer as `0x${string}`; + const deterministicHash = keccak256(signedTransaction); + const destinationClient = evmClientManager.getClient(destinationNetwork); + const { hash: txHash } = await this.runFinancialOperation(state, { + attemptClass: "destination-presigned-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + evmClientManager.sendRawTransactionWithRetry(destinationNetwork, signedTransaction) + ); + return { hash }; + }, + provider: destinationNetwork, + reconcile: async () => { + try { + const receipt = await abortableCall(signal, () => + destinationClient.getTransactionReceipt({ hash: deterministicHash }) + ); + if (receipt.status !== "success") { + throw new FinancialOperationRejectedError(`Destination transfer ${deterministicHash} failed`); + } + await abortableCall(signal, () => destinationClient.getTransaction({ hash: deterministicHash })); + return { hash: deterministicHash }; + } catch (error) { + throwIfAborted(signal); + if (error instanceof FinancialOperationRejectedError) throw error; + return null; + } + }, + request: { network: destinationNetwork, signedTransaction }, + signal + }); + await state.update({ + state: { + ...state.state, + destinationTransferTxHash: txHash + } + }); + + return state; + } catch (error) { + if (error instanceof PhaseError) throw error; + throw this.createRecoverableError( + `DestinationTransferExecutor: Error during phase execution - ${(error as Error).message}` + ); + } + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/destination-transfer/index.ts b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/index.ts new file mode 100644 index 000000000..18bd381cc --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/index.ts @@ -0,0 +1,19 @@ +import type { ChainBrand, Phase, PhaseIO, TokenBrand } from "../../core/types"; +import { DestinationTransferExecutor } from "./execution"; +import { DestinationTransferContext, simulateDestinationTransfer } from "./simulation"; +import { prepareDestinationTransferTxs } from "./transactions"; + +export function DestinationTransfer(): Phase< + typeof DestinationTransferContext, + PhaseIO, + PhaseIO +> { + return { + context: DestinationTransferContext, + executors: [new DestinationTransferExecutor()], + name: "DestinationTransfer", + phases: ["destinationTransfer"], + prepareTxs: prepareDestinationTransferTxs, + simulate: simulateDestinationTransfer + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/destination-transfer/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/simulation.ts new file mode 100644 index 000000000..8ad55a653 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/simulation.ts @@ -0,0 +1,22 @@ +import { defineContext, type SerializableBig } from "../../core/metadata"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; + +export interface DestinationTransferMetadata { + amountDecimal: SerializableBig; + amountRaw: string; + network: string; + token: string; +} + +export const DestinationTransferContext = defineContext()("destinationTransfer"); + +export async function simulateDestinationTransfer( + input: PhaseIO, + ctx: PhaseCtx +): Promise, DestinationTransferMetadata>> { + ctx.addNote(`DestinationTransfer: delivering ${input.amount.toFixed()} ${input.token} on ${input.chain} to the user`); + return { + metadata: { amountDecimal: input.amount, amountRaw: input.amountRaw, network: input.chain, token: input.token }, + output: input + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts new file mode 100644 index 000000000..765782256 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts @@ -0,0 +1,50 @@ +import { + EphemeralAccountType, + EvmNetworks, + getOnChainTokenDetails, + isEvmTokenDetails, + isNativeEvmToken, + Networks, + OnChainToken +} from "@vortexfi/shared"; +import { requireAccount } from "../../core/accounts"; +import { createDestinationTransferTransaction } from "../../core/evm-transactions"; +import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { DestinationTransferMetadata } from "./simulation"; + +// The presigned final transfer the DestinationTransferExecutor broadcasts: quote.outputAmount +// from the destination-chain ephemeral to the user's address. +export async function prepareDestinationTransferTxs(ctx: PrepareCtx): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const { destinationAddress, ownMetadata } = ctx; + if (!destinationAddress) { + throw new Error("prepareDestinationTransferTxs: Destination address is required"); + } + + const toNetwork = ownMetadata.network as Networks; + + const outputTokenDetails = getOnChainTokenDetails(toNetwork, ownMetadata.token as OnChainToken); + if (!outputTokenDetails || !isEvmTokenDetails(outputTokenDetails)) { + throw new Error(`prepareDestinationTransferTxs: Output token ${ownMetadata.token} is not an EVM token on ${toNetwork}`); + } + + const finalDestinationTransfer = await createDestinationTransferTransaction({ + amountRaw: ownMetadata.amountRaw, + destinationNetwork: toNetwork as EvmNetworks, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + return { + intents: [ + { + lane: "main", + network: toNetwork, + phase: "destinationTransfer", + signer: evmEphemeral.address, + txData: finalDestinationTransfer + } + ] + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/distribute-fees/execution.ts b/apps/api/src/api/services/phases/blocks/phases/distribute-fees/execution.ts new file mode 100644 index 000000000..467e99c66 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/distribute-fees/execution.ts @@ -0,0 +1,266 @@ +import { submitExtrinsic } from "@pendulum-chain/api-solang"; +import { + ApiManager, + checkEvmBalanceForToken, + decodeSubmittableExtrinsic, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + evmTokenConfig, + multiplyByPowerOfTen, + Networks, + RampPhase, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import Big from "big.js"; +import { keccak256 } from "viem"; +import logger from "../../../../../../config/logger"; +import { config } from "../../../../../../config/vars"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { fetchWithTimeout } from "../../../../../helpers/fetchWithTimeout"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { FinancialOperationRejectedError } from "../../core/financial-operation"; +import { getBlockMetadata } from "../../core/metadata"; +import { DistributeFeesContext, type DistributeFeesMetadata } from "./simulation"; + +const FEE_BALANCE_POLL_INTERVAL_MS = 5_000; +const FEE_BALANCE_POLL_TIMEOUT_MS = 60_000; + +// EVM slice of the production DistributeFeesHandler: verifies the ephemeral holds enough USDC on +// Base to cover the USD fees, then broadcasts the presigned fee-distribution transaction. The +// substrate (Pendulum/Subscan) branch is not ported. +export class DistributeFeesExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "distributeFees"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const quote = await QuoteTicket.findOne({ where: { id: state.quoteId } }); + if (!quote) { + throw this.createUnrecoverableError(`Quote ticket not found for ID: ${state.quoteId}`); + } + + const existingHash = state.state.distributeFeeHash || null; + const metadata = getBlockMetadata(quote.metadata, DistributeFeesContext); + if (metadata.network === Networks.Pendulum) { + try { + if (existingHash && (await this.isPendulumExtrinsicSuccessful(existingHash, signal))) return state; + const transaction = this.getPresignedTransaction(state, "distributeFees"); + if (!transaction) return state; + const substrateAddress = state.state.substrateEphemeralAddress; + if (!substrateAddress || !metadata.outputCurrencyId || metadata.outputDecimals === undefined) { + throw new Error("DistributeFeesExecutor: missing Pendulum state"); + } + const manager = ApiManager.getInstance(); + const pendulum = await manager.getApi("pendulum"); + const required = multiplyByPowerOfTen(metadata.totalFeesUsd, metadata.outputDecimals); + const balance = await pendulum.api.query.tokens.accounts(substrateAddress, metadata.outputCurrencyId); + const available = new Big((balance as unknown as { free?: { toString(): string } }).free?.toString() ?? "0"); + if (available.lt(required)) throw this.createRecoverableError("Pendulum fee balance is not available"); + throwIfAborted(signal); + const { hash } = await this.runFinancialOperation(state, { + attemptClass: "substrate-fee-distribution", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const result = await abortableCall(signal, () => + submitExtrinsic(decodeSubmittableExtrinsic(transaction.txData as string, pendulum.api)) + ); + if (result.status.type === "error") { + throw new FinancialOperationRejectedError("Pendulum fee distribution failed"); + } + return { hash: result.txHash.toString() }; + }, + provider: Networks.Pendulum, + request: { network: Networks.Pendulum, signedTransaction: transaction.txData }, + signal + }); + state.state = { ...state.state, distributeFeeHash: hash }; + await state.update({ state: state.state }); + return state; + } catch (e) { + logger.error(`Error distributing Pendulum fees for ramp ${state.id}:`, e); + if (e instanceof PhaseError) throw e; + const error = e instanceof Error ? e : new Error(String(e)); + throw this.createRecoverableError(`Failed to distribute Pendulum fees: ${error.message}`); + } + } + if (existingHash) { + logger.info(`Found existing distribute fee hash for ramp ${state.id}: ${existingHash}`); + + const isSuccessful = await this.isEvmTransactionSuccessful(existingHash, Networks.Base, signal).catch((_: unknown) => { + throw this.createRecoverableError("Failed to check EVM transaction status from existing hash."); + }); + + if (isSuccessful) { + logger.info(`Existing distribute fee EVM transaction was successful for ramp ${state.id}`); + return state; + } + logger.info("Existing distribute fee EVM transaction was not successful, will retry"); + } + + try { + const distributeFeeTransaction = this.getPresignedTransaction(state, "distributeFees"); + if (distributeFeeTransaction === undefined) { + logger.info("No fee distribution transaction data found. Skipping fee distribution."); + return state; + } + + // The funding token (USDC) may not yet be on the ephemeral when we reach this phase. + // Poll for it before submitting; if it never arrives within the timeout, throw a + // recoverable error so we retry the phase. + await this.ensureEvmFeeTokenBalance(metadata, distributeFeeTransaction.signer, signal); + + logger.info(`Submitting EVM fee distribution transaction for ramp ${state.id}...`); + const txData = distributeFeeTransaction.txData; + if (typeof txData !== "string" || !txData.startsWith("0x")) { + throw new Error("DistributeFeesExecutor: Invalid presigned EVM transaction data"); + } + const evmClientManager = EvmClientManager.getInstance(); + const network = distributeFeeTransaction.network as EvmNetworks; + const signedTransaction = txData as `0x${string}`; + const deterministicHash = keccak256(signedTransaction); + const client = evmClientManager.getClient(network); + const { hash: actualTxHash } = await this.runFinancialOperation(state, { + attemptClass: "evm-fee-distribution", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + evmClientManager.sendRawTransactionWithRetry(network, signedTransaction) + ); + return { hash }; + }, + provider: network, + reconcile: async () => { + try { + const receipt = await abortableCall(signal, () => client.getTransactionReceipt({ hash: deterministicHash })); + if (receipt.status !== "success") { + throw new FinancialOperationRejectedError(`Fee distribution transaction ${deterministicHash} failed`); + } + await abortableCall(signal, () => client.getTransaction({ hash: deterministicHash })); + return { hash: deterministicHash }; + } catch (error) { + throwIfAborted(signal); + if (error instanceof FinancialOperationRejectedError) throw error; + return null; + } + }, + request: { network, signedTransaction }, + signal + }); + + logger.info(`Transaction broadcast with hash ${actualTxHash}. Persisting hash...`); + await state.update({ + state: { + ...state.state, + distributeFeeHash: actualTxHash + } + }); + + await this.waitForEvmTransactionSuccess(actualTxHash, network, signal); + + logger.info(`Successfully verified fee distribution transaction for ramp ${state.id}: ${actualTxHash}`); + return state; + } catch (e: unknown) { + logger.error(`Error distributing fees for ramp ${state.id}:`, e); + + if (e instanceof PhaseError) { + throw e; + } + + const error = e instanceof Error ? e : new Error(String(e)); + throw this.createRecoverableError(`Failed to distribute fees: ${error.message || "Unknown error"}`); + } + } + + private computeRequiredFeeRaw(metadata: DistributeFeesMetadata, decimals: number): Big | null { + const totalUsd = new Big(metadata.totalFeesUsd); + if (totalUsd.lte(0)) { + return null; + } + + return multiplyByPowerOfTen(totalUsd, decimals); + } + + private async ensureEvmFeeTokenBalance( + metadata: DistributeFeesMetadata, + signerAddress: string, + signal?: AbortSignal + ): Promise { + const baseUsdcConfig = evmTokenConfig[Networks.Base][EvmToken.USDC] as EvmTokenDetails | undefined; + if (!baseUsdcConfig) { + throw this.createUnrecoverableError("Base USDC configuration not found; cannot verify fee balance."); + } + + const requiredRaw = this.computeRequiredFeeRaw(metadata, baseUsdcConfig.decimals); + if (!requiredRaw) { + logger.info("No positive USD fees configured; skipping fee balance precondition check."); + return; + } + + logger.info( + `Checking EVM fee balance: signer=${signerAddress} requires >= ${requiredRaw.toFixed(0)} USDC raw on Base before submitting fee distribution.` + ); + + try { + const balance = await checkEvmBalanceForToken({ + amountDesiredRaw: requiredRaw.toFixed(0), + chain: Networks.Base as EvmNetworks, + intervalMs: FEE_BALANCE_POLL_INTERVAL_MS, + ownerAddress: signerAddress, + signal, + timeoutMs: FEE_BALANCE_POLL_TIMEOUT_MS, + tokenDetails: baseUsdcConfig + }); + logger.info(`EVM fee balance precondition met: balance=${balance.toFixed(0)} >= required=${requiredRaw.toFixed(0)}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw this.createRecoverableError( + `Fee distribution precondition failed: USDC balance not available on ${signerAddress} within ${FEE_BALANCE_POLL_TIMEOUT_MS}ms. ${message}` + ); + } + } + + private async isEvmTransactionSuccessful(txHash: string, network: EvmNetworks, signal?: AbortSignal): Promise { + try { + const publicClient = EvmClientManager.getInstance().getClient(network); + const receipt = await abortableCall(signal, () => publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` })); + return receipt?.status === "success"; + } catch (error) { + throwIfAborted(signal); + logger.debug(`Error checking EVM transaction receipt: ${error}`); + return false; + } + } + + private async waitForEvmTransactionSuccess(txHash: string, network: EvmNetworks, signal?: AbortSignal): Promise { + await waitUntilTrueWithTimeout( + () => this.isEvmTransactionSuccessful(txHash, network, signal), + 2000, // check every 2 seconds + 180000, // timeout after 3 minutes + signal + ); + } + + private async isPendulumExtrinsicSuccessful(extrinsicHash: string, signal?: AbortSignal): Promise { + const response = await abortableCall(signal, () => + fetchWithTimeout("https://pendulum.api.subscan.io/api/scan/extrinsic", { + body: JSON.stringify({ events_limit: 10, hash: extrinsicHash, hide_events: false }), + headers: { + "Content-Type": "application/json", + "x-api-key": config.subscanApiKey || "" + }, + method: "POST" + }) + ); + if (!response.ok) throw new Error(`Subscan API response error: ${response.status} ${response.statusText}`); + const data = await response.json(); + if (data.code !== 0) throw new Error(`Subscan API error code: ${data.code}, message: ${data.message}`); + return data.data?.success === true; + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/distribute-fees/index.ts b/apps/api/src/api/services/phases/blocks/phases/distribute-fees/index.ts new file mode 100644 index 000000000..cb6553e5f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/distribute-fees/index.ts @@ -0,0 +1,19 @@ +import type { ChainBrand, Phase, PhaseIO, TokenBrand } from "../../core/types"; +import { DistributeFeesExecutor } from "./execution"; +import { DistributeFeesContext, simulateDistributeFees } from "./simulation"; +import { prepareDistributeFeesTxs } from "./transactions"; + +export function DistributeFees(): Phase< + typeof DistributeFeesContext, + PhaseIO, + PhaseIO +> { + return { + context: DistributeFeesContext, + executors: [new DistributeFeesExecutor()], + name: "DistributeFees", + phases: ["distributeFees"], + prepareTxs: prepareDistributeFeesTxs, + simulate: simulateDistributeFees + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/distribute-fees/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/distribute-fees/simulation.ts new file mode 100644 index 000000000..bde189a05 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/distribute-fees/simulation.ts @@ -0,0 +1,63 @@ +import Big from "big.js"; +import { evmIO } from "../../core/io"; +import { defineContext } from "../../core/metadata"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; + +const SIMPLIFIED_TOKEN_DECIMALS = 6; + +export interface DistributeFeesMetadata { + anchorFeeUsd: string; + networkFeeUsd: string; + partnerMarkupUsd: string; + totalFeesUsd: string; + network?: string; + outputCurrencyId?: ReturnType["currencyId"]; + outputDecimals?: number; + vortexFeeUsd: string; +} + +export const DistributeFeesContext = defineContext()("distributeFees"); + +export async function simulateDistributeFees( + input: PhaseIO, + ctx: PhaseCtx +): Promise, DistributeFeesMetadata>> { + if (!ctx.fees?.usd) { + throw new Error("DistributeFees: Missing USD fees"); + } + const totalFeesUsd = new Big(ctx.fees.usd.network).plus(ctx.fees.usd.vortex).plus(ctx.fees.usd.partnerMarkup); + const newAmount = new Big(input.amount).minus(totalFeesUsd); + if (newAmount.lt(0)) { + ctx.addNote(`DistributeFees: fees ${totalFeesUsd.toFixed()} USD exceed amount ${input.amount.toFixed()}, setting to 0`); + return { + metadata: { + anchorFeeUsd: ctx.fees.usd.anchor, + networkFeeUsd: ctx.fees.usd.network, + partnerMarkupUsd: ctx.fees.usd.partnerMarkup, + totalFeesUsd: totalFeesUsd.toString(), + vortexFeeUsd: ctx.fees.usd.vortex + }, + output: { + ...evmIO(input.token, input.chain, new Big(0), "0"), + requestInputAmountUsd: input.requestInputAmountUsd + } as PhaseIO + }; + } + const newAmountRaw = newAmount.times(new Big(10).pow(SIMPLIFIED_TOKEN_DECIMALS)).toFixed(0, 0); + ctx.addNote( + `DistributeFees: ${input.amount.toFixed()} ${input.token} -> ${newAmount.toFixed()} ${input.token} after ${totalFeesUsd.toFixed()} USD fees` + ); + return { + metadata: { + anchorFeeUsd: ctx.fees.usd.anchor, + networkFeeUsd: ctx.fees.usd.network, + partnerMarkupUsd: ctx.fees.usd.partnerMarkup, + totalFeesUsd: totalFeesUsd.toString(), + vortexFeeUsd: ctx.fees.usd.vortex + }, + output: { + ...evmIO(input.token, input.chain, newAmount, newAmountRaw), + requestInputAmountUsd: input.requestInputAmountUsd + } as PhaseIO + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/distribute-fees/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/distribute-fees/transactions.ts new file mode 100644 index 000000000..bc51f0010 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/distribute-fees/transactions.ts @@ -0,0 +1,34 @@ +import { EphemeralAccountType, Networks } from "@vortexfi/shared"; +import type { QuoteTicketAttributes } from "../../../../../../models/quoteTicket.model"; +import { requireAccount } from "../../core/accounts"; +import { createEvmFeeDistributionTransaction } from "../../core/fee-distribution"; +import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { DistributeFeesMetadata } from "./simulation"; + +// The presigned USDC fee transfer (or Multicall3 split) the DistributeFeesExecutor broadcasts. +// createEvmFeeDistributionTransaction returns null when there are no fees to distribute; the +// executor tolerates the missing presigned tx and skips. +export async function prepareDistributeFeesTxs(ctx: PrepareCtx): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const quote = { + ...ctx.quote, + metadata: { fees: ctx.globals.fees, request: ctx.globals.request } + } as QuoteTicketAttributes; + const feeDistributionTx = await createEvmFeeDistributionTransaction(quote); + + if (!feeDistributionTx) { + return { intents: [] }; + } + + return { + intents: [ + { + lane: "main", + network: Networks.Base, + phase: "distributeFees", + signer: evmEphemeral.address, + txData: feeDistributionTx + } + ] + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/index.ts b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/index.ts new file mode 100644 index 000000000..1688eb842 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/index.ts @@ -0,0 +1,28 @@ +import { type EvmNetworks, EvmToken, Networks, type OnChainToken } from "@vortexfi/shared"; +import type { Phase, PhaseIO } from "../../core/types"; +import { FundEphemeralExecutor } from "../fund-ephemeral/execution"; +import { + type EvmOfframpSourceRegistrationFacts, + type EvmOfframpSourceRegistrationInput, + registerEvmOfframpSource +} from "./registration"; +import { EvmOfframpSourceContext, simulateEvmOfframpSource } from "./simulation"; +import { prepareEvmOfframpSourceTxs } from "./transactions"; + +export function EvmOfframpSource(): Phase< + typeof EvmOfframpSourceContext, + PhaseIO, + PhaseIO, + EvmOfframpSourceRegistrationFacts, + EvmOfframpSourceRegistrationInput +> { + return { + context: EvmOfframpSourceContext, + executors: [new FundEphemeralExecutor()], + name: "EvmOfframpSource", + phases: ["fundEphemeral"], + prepareTxs: prepareEvmOfframpSourceTxs, + register: registerEvmOfframpSource, + simulate: simulateEvmOfframpSource + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/registration.ts b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/registration.ts new file mode 100644 index 000000000..8a3c90c0e --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/registration.ts @@ -0,0 +1,25 @@ +import httpStatus from "http-status"; +import { APIError } from "../../../../../errors/api-error"; +import { validateOfframpQuote } from "../../core/offramp-validation"; +import type { RegisterCtx, RegistrationResult } from "../../core/types"; +import type { EvmOfframpSourceMetadata } from "./simulation"; + +export interface EvmOfframpSourceRegistrationInput extends Record { + walletAddress?: string; +} + +export interface EvmOfframpSourceRegistrationFacts { + userAddress: string; +} + +export async function registerEvmOfframpSource( + ctx: RegisterCtx +): Promise> { + if (!ctx.input.walletAddress) { + throw new APIError({ message: "walletAddress is required for offramping", status: httpStatus.BAD_REQUEST }); + } + validateOfframpQuote(ctx.quote as Parameters[0], [...ctx.signingAccounts], { + requireSubstrateEphemeral: false + }); + return { facts: { userAddress: ctx.input.walletAddress } }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/simulation.ts new file mode 100644 index 000000000..dacbd7556 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/simulation.ts @@ -0,0 +1,155 @@ +import { + EvmNetworks, + EvmToken, + FiatToken, + getOnChainTokenDetails, + isEvmTokenDetails, + Networks, + OnChainToken +} from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { evmIO } from "../../core/io"; +import { defineContext, SerializableBig } from "../../core/metadata"; +import { getEvmBridgeQuote } from "../../core/squidrouter"; +import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; + +export interface EvmOfframpSourceMetadata { + fromNetwork: EvmNetworks; + fromToken: string; + inputAmountDecimal: SerializableBig; + inputAmountRaw: string; + network: typeof Networks.Base; + networkFeeUSD: string; + outputAmountDecimal: SerializableBig; + outputAmountRaw: string; + toNetwork: typeof Networks.Base; + toToken: string; + token: typeof EvmToken.USDC; +} + +export const EvmOfframpSourceContext = defineContext()("evmOfframpSource"); + +export async function simulateEvmOfframpSource( + input: PhaseIO, + ctx: PhaseCtx +): Promise, EvmOfframpSourceMetadata>> { + if (input.chain === Networks.Base && input.token === EvmToken.BRLA) { + const fromTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.BRLA); + const toTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.USDC); + if (!fromTokenDetails || !isEvmTokenDetails(fromTokenDetails) || !toTokenDetails || !isEvmTokenDetails(toTokenDetails)) { + throw new Error("EvmOfframpSource: Missing Base BRLA or USDC token details"); + } + if (!ctx.fees?.usd || !ctx.fees.displayFiat) { + throw new Error("EvmOfframpSource: Missing fee snapshot"); + } + const fiatToUsdRate = await priceFeedService.getFiatToUsdExchangeRate(FiatToken.BRL); + const amountUsd = input.amount.times(fiatToUsdRate); + const amountUsdRaw = amountUsd.times(new Big(10).pow(toTokenDetails.decimals)).toFixed(0, 0); + ctx.addNote(`EvmOfframpSource: valued direct Base BRLA at ${amountUsd.toFixed()} USDC`); + return { + fees: ctx.fees, + metadata: { + fromNetwork: Networks.Base, + fromToken: fromTokenDetails.erc20AddressSourceChain, + inputAmountDecimal: input.amount, + inputAmountRaw: input.amountRaw, + network: Networks.Base, + networkFeeUSD: "0", + outputAmountDecimal: amountUsd, + outputAmountRaw: amountUsdRaw, + token: EvmToken.USDC, + toNetwork: Networks.Base, + toToken: toTokenDetails.erc20AddressSourceChain + }, + output: { ...evmIO(EvmToken.USDC, Networks.Base, amountUsd, amountUsdRaw), requestInputAmountUsd: amountUsd } + }; + } + + if (input.chain === Networks.Base && input.token === EvmToken.USDC) { + const tokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.USDC); + if (!tokenDetails || !isEvmTokenDetails(tokenDetails)) { + throw new Error("EvmOfframpSource: Missing Base USDC token details"); + } + if (!ctx.fees?.usd || !ctx.fees.displayFiat) { + throw new Error("EvmOfframpSource: Missing fee snapshot"); + } + ctx.addNote(`EvmOfframpSource: direct ${input.amount.toFixed()} USDC transfer on Base`); + return { + fees: ctx.fees, + metadata: { + fromNetwork: Networks.Base, + fromToken: tokenDetails.erc20AddressSourceChain, + inputAmountDecimal: input.amount, + inputAmountRaw: input.amountRaw, + network: Networks.Base, + networkFeeUSD: "0", + outputAmountDecimal: input.amount, + outputAmountRaw: input.amountRaw, + token: EvmToken.USDC, + toNetwork: Networks.Base, + toToken: tokenDetails.erc20AddressSourceChain + }, + output: { ...evmIO(EvmToken.USDC, Networks.Base, input.amount, input.amountRaw), requestInputAmountUsd: input.amount } + }; + } + + const bridgeQuote = await getEvmBridgeQuote({ + amountDecimal: input.amount.toString(), + fromNetwork: input.chain, + inputCurrency: input.token, + outputCurrency: EvmToken.USDC, + toNetwork: Networks.Base + }); + if (!ctx.fees?.usd || !ctx.fees.displayFiat) { + throw new Error("EvmOfframpSource: Missing fee snapshot"); + } + const networkFeeDisplay = await priceFeedService.convertCurrency( + bridgeQuote.networkFeeUSD, + EvmToken.USDC, + ctx.fees.displayFiat.currency + ); + const fees = { + displayFiat: { + ...ctx.fees.displayFiat, + network: networkFeeDisplay, + total: new Big(ctx.fees.displayFiat.anchor) + .plus(networkFeeDisplay) + .plus(ctx.fees.displayFiat.partnerMarkup) + .plus(ctx.fees.displayFiat.vortex) + .toFixed(2) + }, + usd: { + ...ctx.fees.usd, + network: bridgeQuote.networkFeeUSD, + total: new Big(ctx.fees.usd.anchor) + .plus(bridgeQuote.networkFeeUSD) + .plus(ctx.fees.usd.partnerMarkup) + .plus(ctx.fees.usd.vortex) + .toFixed(6) + } + }; + ctx.addNote( + `EvmOfframpSource: ${input.amount.toFixed()} ${input.token} on ${input.chain} -> ${bridgeQuote.outputAmountDecimal.toFixed()} USDC on Base` + ); + return { + fees, + metadata: { + fromNetwork: input.chain, + fromToken: bridgeQuote.fromToken, + inputAmountDecimal: new Big(input.amount), + inputAmountRaw: bridgeQuote.inputAmountRaw, + network: Networks.Base, + networkFeeUSD: bridgeQuote.networkFeeUSD, + outputAmountDecimal: bridgeQuote.outputAmountDecimal, + outputAmountRaw: bridgeQuote.outputAmountRaw, + token: EvmToken.USDC, + toNetwork: Networks.Base, + toToken: bridgeQuote.toToken + }, + output: { + ...evmIO(EvmToken.USDC, Networks.Base, bridgeQuote.outputAmountDecimal, bridgeQuote.outputAmountRaw), + requestInputAmountUsd: bridgeQuote.outputAmountDecimal + } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/transactions.ts new file mode 100644 index 000000000..2f4c4c0b6 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/evm-offramp-source/transactions.ts @@ -0,0 +1,80 @@ +import { + createOfframpSquidrouterTransactionsToEvm, + EphemeralAccountType, + EvmToken, + EvmTransactionData, + evmTokenConfig, + Networks +} from "@vortexfi/shared"; +import { encodeFunctionData, erc20Abi } from "viem"; +import { requireAccount } from "../../core/accounts"; +import { encodeEvmTransactionData } from "../../core/evm-transactions"; +import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { EvmOfframpSourceRegistrationFacts } from "./registration"; +import type { EvmOfframpSourceMetadata } from "./simulation"; + +export async function prepareEvmOfframpSourceTxs( + ctx: PrepareCtx +): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const facts = ctx.ownRegistrationFacts; + if (!facts) { + throw new Error("prepareEvmOfframpSourceTxs: Missing source registration facts"); + } + const metadata = ctx.ownMetadata; + const baseUsdc = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!baseUsdc) { + throw new Error("prepareEvmOfframpSourceTxs: Missing Base USDC configuration"); + } + if (metadata.fromNetwork === Networks.Base && metadata.fromToken.toLowerCase() === baseUsdc.toLowerCase()) { + return { + intents: [ + { + lane: "main", + network: metadata.fromNetwork, + phase: "squidRouterNoPermitTransfer", + signer: facts.userAddress, + txData: { + data: encodeFunctionData({ + abi: erc20Abi, + args: [evmEphemeral.address as `0x${string}`, BigInt(metadata.inputAmountRaw)], + functionName: "transfer" + }), + gas: "0", + to: metadata.fromToken as `0x${string}`, + value: "0" + } + } + ], + state: { userAddress: facts.userAddress } + }; + } + const { approveData, swapData } = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeral.address, + fromAddress: facts.userAddress, + fromNetwork: metadata.fromNetwork, + fromToken: metadata.fromToken as `0x${string}`, + rawAmount: metadata.inputAmountRaw, + toNetwork: Networks.Base, + toToken: baseUsdc + }); + return { + intents: [ + { + lane: "main", + network: metadata.fromNetwork, + phase: "squidRouterApprove", + signer: facts.userAddress, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }, + { + lane: "main", + network: metadata.fromNetwork, + phase: "squidRouterSwap", + signer: facts.userAddress, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + } + ], + state: { userAddress: facts.userAddress } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts new file mode 100644 index 000000000..71b6ee824 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts @@ -0,0 +1,465 @@ +import { + ALFREDPAY_EVM_TOKEN, + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + getEvmBalance, + getNetworkId, + getOnChainTokenDetails, + getRoute, + isNativeEvmToken, + multiplyByPowerOfTen, + NATIVE_TOKEN_ADDRESS, + Networks, + RampCurrency, + RampDirection, + RampPhase, + TokenType +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import { generatePrivateKey, privateKeyToAddress } from "viem/accounts"; +import logger from "../../../../../../config/logger"; +import { MAX_FINAL_SETTLEMENT_SUBSIDY_USD } from "../../../../../../constants/constants"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { SubsidyToken } from "../../../../../../models/subsidy.model"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import type { SquidRouterDeliveryEvidence } from "../../../../phases/meta-state-types"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { DESTINATION_EVM_FUNDING_AMOUNTS } from "../../core/destination-funding"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { calculateSettlementSubsidyRaw, settlementBalanceKey } from "../../core/settlement"; + +const BALANCE_POLLING_TIME_MS = 5000; +const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes +// This is an explicitly scoped fallback for Squid-routed EVM delivery. It is not +// authoritative bridge finality and MUST NOT be reused as a global cross-chain rule. +const SQUID_EVM_DELIVERY_FALLBACK_MIN_RATIO_BPS = 9000; + +const NATIVE_TOKENS: Record = { + [Networks.Ethereum]: { decimals: 18, symbol: "ETH" }, + [Networks.Polygon]: { decimals: 18, symbol: "MATIC" }, + [Networks.PolygonAmoy]: { decimals: 18, symbol: "MATIC" }, + [Networks.BSC]: { decimals: 18, symbol: "BNB" }, + [Networks.Arbitrum]: { decimals: 18, symbol: "ETH" }, + [Networks.Base]: { decimals: 18, symbol: "ETH" }, + [Networks.Avalanche]: { decimals: 18, symbol: "AVAX" }, + [Networks.Moonbeam]: { decimals: 18, symbol: "GLMR" }, + [Networks.BaseSepolia]: { decimals: 18, symbol: "ETH" } +}; + +// BUY slice of the production FinalSettlementSubsidyHandler: waits for the bridge to deliver on +// the destination chain, then tops the ephemeral up to exactly quote.outputAmount (swapping the +// funding account's native token to the output token via SquidRouter when needed). SELL is not ported. +export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "finalSettlementSubsidy"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + logger.debug(`FinalSettlementSubsidyExecutor: Starting phase execution for ramp ${state.id}, type=${state.type}`); + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("FinalSettlementSubsidyExecutor: Quote not found for the given state"); + } + + const evmClientManager = EvmClientManager.getInstance(); + + const alfredpayMetadata = (quote.metadata as unknown as { blocks?: { alfredpayOfframp?: { inputAmountRaw: string } } }) + .blocks?.alfredpayOfframp; + const isAlfredpayOfframp = state.type === RampDirection.SELL && alfredpayMetadata !== undefined; + const outputNetwork = isAlfredpayOfframp ? Networks.Polygon : quote.network; + const outputCurrency = isAlfredpayOfframp ? ALFREDPAY_EVM_TOKEN : quote.outputCurrency; + const outTokenDetailsRaw = getOnChainTokenDetails(outputNetwork, outputCurrency); + if (!outTokenDetailsRaw || outTokenDetailsRaw.type === TokenType.AssetHub) { + throw new Error("FinalSettlementSubsidyExecutor: Output currency is not an EVM token"); + } + const outTokenDetails = outTokenDetailsRaw as EvmTokenDetails; + + const isNative = isNativeEvmToken(outTokenDetails); + const expectedAmountRaw = isAlfredpayOfframp + ? new Big(alfredpayMetadata.inputAmountRaw) + : multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); + const destinationNetwork = outputNetwork as EvmNetworks; + const fundingAccount = getEvmFundingAccount(destinationNetwork); + const publicClient = evmClientManager.getClient(destinationNetwork); + const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; + + logger.debug( + `FinalSettlementSubsidyExecutor: expectedAmountRaw=${expectedAmountRaw.toString()}, destinationNetwork=${destinationNetwork}, ephemeralAddress=${ephemeralAddress}, isNative=${isNative}` + ); + + // 1. Idempotency check + if (state.state.finalSettlementSubsidyTxHash) { + const receipt = await abortableCall(signal, () => + publicClient.getTransactionReceipt({ + hash: state.state.finalSettlementSubsidyTxHash as `0x${string}` + }) + ).catch(() => null); + + if (receipt && receipt.status === "success") { + logger.info( + `FinalSettlementSubsidyExecutor: Transaction ${state.state.finalSettlementSubsidyTxHash} already successful. Skipping.` + ); + return state; + } + if (receipt) { + throw this.createUnrecoverableError( + `FinalSettlementSubsidyExecutor: Persisted subsidy transaction ${state.state.finalSettlementSubsidyTxHash} failed` + ); + } + throw this.createRecoverableError( + `FinalSettlementSubsidyExecutor: Cannot reconcile persisted subsidy transaction ${state.state.finalSettlementSubsidyTxHash}` + ); + } + + const baselineKey = settlementBalanceKey(destinationNetwork, ephemeralAddress, outTokenDetails.erc20AddressSourceChain); + const baselineValue = + state.state.transactionPlan?.settlementBaselines?.[baselineKey] ?? (isAlfredpayOfframp ? "0" : undefined); + if (baselineValue === undefined) { + throw this.createUnrecoverableError("FinalSettlementSubsidyExecutor: Missing destination settlement baseline"); + } + const baseline = new Big(baselineValue); + const squidMetadata = ( + quote.metadata as unknown as { + blocks?: { + squidRouterSwap?: { + outputAmountRaw: string; + toNetwork: EvmNetworks; + toToken: string; + }; + }; + } + ).blocks?.squidRouterSwap; + const bridgeExpectedAmountRaw = squidMetadata?.outputAmountRaw ?? expectedAmountRaw.toFixed(0); + const existingEvidence = state.state.squidRouterDeliveryEvidence; + if (existingEvidence) { + this.assertMatchingDeliveryEvidence( + existingEvidence, + destinationNetwork, + outTokenDetails.erc20AddressSourceChain, + bridgeExpectedAmountRaw, + baselineValue, + state + ); + } + + // 2. Wait for the route-scoped bridge delivery delta, excluding any balance that + // existed before the bridge. Provider-terminal evidence is preferred. The 90% + // threshold remains only as the EVM balance fallback selected for provider-indexing + // gaps, and is persisted/logged as heuristic evidence rather than called finality. + const minimumBridgeDeliveryRaw = new Big(bridgeExpectedAmountRaw) + .mul(SQUID_EVM_DELIVERY_FALLBACK_MIN_RATIO_BPS) + .div(10_000) + .toFixed(0, 0); + const actualBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: baseline.plus(minimumBridgeDeliveryRaw).toFixed(0), + chain: destinationNetwork, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: ephemeralAddress, + signal, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); + logger.debug(`FinalSettlementSubsidyExecutor: Ephemeral balance=${actualBalance.toString()}`); + if (!existingEvidence) { + const sourceTransactionHash = + state.state.squidRouterSwapHash ?? state.state.squidRouterPermitExecutionHash ?? "legacy-unavailable"; + const fallbackEvidence: SquidRouterDeliveryEvidence = { + baselineRaw: baselineValue, + destinationNetwork, + destinationToken: outTokenDetails.erc20AddressSourceChain, + expectedAmountRaw: bridgeExpectedAmountRaw, + kind: "destination-balance", + minimumRatioBps: SQUID_EVM_DELIVERY_FALLBACK_MIN_RATIO_BPS, + observedAt: new Date().toISOString(), + observedBalanceRaw: actualBalance.toFixed(0), + sourceTransactionHash + }; + await state.update({ + state: { + ...state.state, + squidRouterDeliveryEvidence: fallbackEvidence + } + }); + logger.warn("SQUIDROUTER_DELIVERY_BALANCE_FALLBACK", { + destinationNetwork, + expectedAmountRaw: bridgeExpectedAmountRaw, + minimumRatioBps: SQUID_EVM_DELIVERY_FALLBACK_MIN_RATIO_BPS, + rampId: state.id, + sourceTransactionHash + }); + } else { + logger.info("SQUIDROUTER_SETTLEMENT_EVIDENCE_ACCEPTED", { + kind: existingEvidence.kind, + provider: existingEvidence.provider, + rampId: state.id, + sourceTransactionHash: existingEvidence.sourceTransactionHash + }); + } + + // 3. Check funding account balance + const actualBalanceFundingAccount = await getEvmBalance({ + chain: destinationNetwork, + ownerAddress: fundingAccount.address as `0x${string}`, + tokenDetails: outTokenDetails + }); + + const destinationGasReserveRaw = isNative + ? multiplyByPowerOfTen(DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork], outTokenDetails.decimals) + : new Big(0); + const requiredBalanceRaw = expectedAmountRaw.plus(destinationGasReserveRaw); + const subsidyAmountRaw = calculateSettlementSubsidyRaw( + expectedAmountRaw, + actualBalance, + baseline, + destinationGasReserveRaw + ); + logger.debug( + `FinalSettlementSubsidyExecutor: subsidyAmountRaw=${subsidyAmountRaw.toString()} (required=${requiredBalanceRaw.toString()} - actualBalance=${actualBalance.toString()})` + ); + + if (subsidyAmountRaw.lte(0)) { + logger.info( + `FinalSettlementSubsidyExecutor: Actual balance ${actualBalance.toString()} meets required balance ${requiredBalanceRaw.toString()}. No subsidy needed.` + ); + return state; + } + + const subsidyAmountDecimal = subsidyAmountRaw.div(new Big(10).pow(outTokenDetails.decimals)); + const subsidyAmountUsd = await priceFeedService.convertCurrency( + subsidyAmountDecimal.toFixed(), + outTokenDetails.assetSymbol as RampCurrency, + "USD" as RampCurrency + ); + if (new Big(subsidyAmountUsd).gt(MAX_FINAL_SETTLEMENT_SUBSIDY_USD)) { + throw this.createUnrecoverableError( + `FinalSettlementSubsidyExecutor: Required subsidy $${subsidyAmountUsd} exceeds maximum allowed $${MAX_FINAL_SETTLEMENT_SUBSIDY_USD}` + ); + } + + logger.info( + `FinalSettlementSubsidyExecutor: Subsidizing ${subsidyAmountRaw.toString()} raw units of ${isNative ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` + ); + + // 4. Top up funding account if insufficient balance (ERC-20 only; native tokens transfer directly) + if (!isNative && actualBalanceFundingAccount.lt(subsidyAmountRaw)) { + logger.info( + `FinalSettlementSubsidyExecutor: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}` + ); + + const nativeToken = NATIVE_TOKENS[destinationNetwork]; + const oneUsdInNative = await priceFeedService.convertCurrency( + "1", + "USD" as RampCurrency, + nativeToken.symbol as RampCurrency + ); + const oneUsdInNativeRaw = multiplyByPowerOfTen(oneUsdInNative, nativeToken.decimals).toFixed(0); + + const chainId = getNetworkId(destinationNetwork).toString(); + + // Use a placeholder address for this query to prevent rate limiting issues + const placeholderAddress = privateKeyToAddress(generatePrivateKey()); + const testRouteResult = await getRoute( + { + bypassGuardrails: true, + enableExpress: true, + fromAddress: placeholderAddress, + fromAmount: oneUsdInNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { + autoMode: 1 + }, + toAddress: placeholderAddress, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain + }, + { useCache: true } + ); + + const { route: testRoute } = testRouteResult.data; + const rate = new Big(testRoute.estimate.toAmount).div(new Big(oneUsdInNativeRaw)); + const requiredNativeRaw = subsidyAmountRaw.div(rate).mul(1.1).toFixed(0); + + logger.info( + `FinalSettlementSubsidyExecutor: Swapping ${requiredNativeRaw} native units (approx. rate ${rate}) to get required subsidy.` + ); + + // Check the amount of native is not higher than cap, cap specified in units of usd. + const requiredNative = new Big(requiredNativeRaw).div(new Big(10).pow(nativeToken.decimals)); + const requiredNativeInUsd = await priceFeedService.convertCurrency( + requiredNative.toString(), + nativeToken.symbol as RampCurrency, + "USD" as RampCurrency + ); + + if (new Big(requiredNativeInUsd).gt(MAX_FINAL_SETTLEMENT_SUBSIDY_USD)) { + throw this.createUnrecoverableError( + `FinalSettlementSubsidyExecutor: Required subsidy swap amount $${requiredNativeInUsd} exceeds maximum allowed $${MAX_FINAL_SETTLEMENT_SUBSIDY_USD}` + ); + } + + const swapRouteResult = await getRoute({ + bypassGuardrails: true, + enableExpress: true, + fromAddress: fundingAccount.address, + fromAmount: requiredNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { + autoMode: 1 + }, + toAddress: fundingAccount.address, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain + }); + + const { route: swapRoute } = swapRouteResult.data; + + // Validate swap route output is within acceptable range (>=80% of required subsidy) + const estimatedOutput = new Big(swapRoute.estimate.toAmount); + const minimumAcceptableOutput = subsidyAmountRaw.mul(0.8); + if (estimatedOutput.lt(minimumAcceptableOutput)) { + throw this.createUnrecoverableError( + `FinalSettlementSubsidyExecutor: SquidRouter swap output ${estimatedOutput.toString()} is below 80% of required subsidy ${subsidyAmountRaw.toString()}` + ); + } + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const nonce = await publicClient.getTransactionCount({ address: fundingAccount.address, blockTag: "pending" }); + const { hash: txHashIdx } = await this.runFinancialOperation(state, { + attemptClass: "funding-swap", + externalId: operation => operation.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data: swapRoute.transactionRequest.data as `0x${string}`, + gas: BigInt(swapRoute.transactionRequest.gasLimit), + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + to: swapRoute.transactionRequest.target as `0x${string}`, + value: BigInt(swapRoute.transactionRequest.value) + }); + const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") throw new Error(`Swap transaction ${hash} failed`); + return { hash }; + }, + provider: destinationNetwork, + request: { + amountRaw: requiredNativeRaw, + destination: fundingAccount.address, + network: destinationNetwork, + nonce, + routeTarget: swapRoute.transactionRequest.target, + token: outTokenDetails.erc20AddressSourceChain + }, + signal + }); + + logger.info(`FinalSettlementSubsidyExecutor: Swap transaction ${txHashIdx} confirmed. Waiting for balance update...`); + + await checkEvmBalanceForToken({ + amountDesiredRaw: subsidyAmountRaw.toString(), + chain: destinationNetwork, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: fundingAccount.address, + signal, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); + } + + // 5. Execute the subsidy transfer (native value transfer vs ERC-20 transfer) + try { + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const nonce = await publicClient.getTransactionCount({ address: fundingAccount.address, blockTag: "pending" }); + const data = isNative + ? undefined + : encodeFunctionData({ + abi: erc20Abi, + args: [ephemeralAddress, BigInt(subsidyAmountRaw.toFixed(0))], + functionName: "transfer" + }); + const { hash: txHash } = await this.runFinancialOperation(state, { + attemptClass: "settlement-subsidy-transfer", + externalId: operation => operation.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + to: isNative ? ephemeralAddress : (outTokenDetails.erc20AddressSourceChain as `0x${string}`), + value: isNative ? BigInt(subsidyAmountRaw.toFixed(0)) : 0n + }); + const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") throw new Error(`Subsidy transaction ${hash} failed`); + return { hash }; + }, + provider: destinationNetwork, + request: { + amountRaw: subsidyAmountRaw.toFixed(0), + destination: ephemeralAddress, + network: destinationNetwork, + nonce, + source: fundingAccount.address, + token: isNative ? NATIVE_TOKEN_ADDRESS : outTokenDetails.erc20AddressSourceChain + }, + signal + }); + + await this.createSubsidy( + state, + subsidyAmountDecimal.toNumber(), + outTokenDetails.assetSymbol as SubsidyToken, + fundingAccount.address, + txHash + ); + + await state.update({ + state: { + ...state.state, + finalSettlementSubsidyTxHash: txHash + } + }); + + return state; + } catch (error) { + if (error instanceof PhaseError) throw error; + throw this.createRecoverableError( + `FinalSettlementSubsidyExecutor: Error during phase execution - ${(error as Error).message}` + ); + } + } + + private assertMatchingDeliveryEvidence( + evidence: SquidRouterDeliveryEvidence, + destinationNetwork: EvmNetworks, + destinationToken: string, + expectedAmountRaw: string, + baselineRaw: string, + state: RampState + ): void { + const expectedSourceHash = state.state.squidRouterSwapHash ?? state.state.squidRouterPermitExecutionHash; + const mismatch = + evidence.destinationNetwork !== destinationNetwork || + evidence.destinationToken.toLowerCase() !== destinationToken.toLowerCase() || + evidence.expectedAmountRaw !== expectedAmountRaw || + (evidence.baselineRaw !== undefined && evidence.baselineRaw !== baselineRaw) || + (expectedSourceHash !== undefined && evidence.sourceTransactionHash !== expectedSourceHash) || + (evidence.kind === "destination-balance" && evidence.minimumRatioBps !== SQUID_EVM_DELIVERY_FALLBACK_MIN_RATIO_BPS); + if (mismatch) { + throw this.createUnrecoverableError( + "FinalSettlementSubsidyExecutor: Persisted cross-chain delivery evidence does not match the ramp route" + ); + } + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/index.ts b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/index.ts new file mode 100644 index 000000000..bb92e52d0 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/index.ts @@ -0,0 +1,17 @@ +import type { ChainBrand, Phase, PhaseIO, TokenBrand } from "../../core/types"; +import { FinalSettlementSubsidyExecutor } from "./execution"; +import { FinalSettlementSubsidyContext, simulateFinalSettlementSubsidy } from "./simulation"; + +export function FinalSettlementSubsidy(): Phase< + typeof FinalSettlementSubsidyContext, + PhaseIO, + PhaseIO +> { + return { + context: FinalSettlementSubsidyContext, + executors: [new FinalSettlementSubsidyExecutor()], + name: "FinalSettlementSubsidy", + phases: ["finalSettlementSubsidy"], + simulate: simulateFinalSettlementSubsidy + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.ts new file mode 100644 index 000000000..f792b141e --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.ts @@ -0,0 +1,25 @@ +import Big from "big.js"; +import { defineContext } from "../../core/metadata"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; +import { buildFullSubsidy, computeExpectedOutput, type SubsidyMetadata } from "../subsidize-pre/simulation"; + +export interface FinalSettlementSubsidyMetadata extends SubsidyMetadata { + amountRaw: string; + network: string; + token: string; +} + +export const FinalSettlementSubsidyContext = defineContext()("finalSettlementSubsidy"); + +export async function simulateFinalSettlementSubsidy( + input: PhaseIO, + ctx: PhaseCtx +): Promise, FinalSettlementSubsidyMetadata>> { + const expected = await computeExpectedOutput(ctx); + const subsidy = buildFullSubsidy(input.amount, input.amountRaw, expected.decimal, expected.raw, ctx); + ctx.addNote(`FinalSettlementSubsidy: finalized, amount=${Big(subsidy.subsidyAmountInOutputTokenDecimal).toFixed()}`); + return { + metadata: { ...subsidy, amountRaw: input.amountRaw, network: input.chain, token: input.token }, + output: input + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts new file mode 100644 index 000000000..705dddf6a --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts @@ -0,0 +1,371 @@ +import { + ApiManager, + EvmClientManager, + EvmNetworks, + FiatToken, + getNetworkFromDestination, + isAlfredpayToken, + isNetworkEVM, + multiplyByPowerOfTen, + Networks, + RampDirection, + RampPhase, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import logger from "../../../../../../config/logger"; +import { config } from "../../../../../../config/vars"; +import { + BASE_EPHEMERAL_STARTING_BALANCE_UNITS, + POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS +} from "../../../../../../constants/constants"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { fundEphemeralAccount } from "../../../../pendulum/pendulum.service"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { verifyUserSubmittedTxByHash } from "../../../../phases/helpers/user-tx-verifier"; +import { StateMetadata } from "../../../../phases/meta-state-types"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { + DESTINATION_EVM_FUNDING_AMOUNTS, + isDestinationEvmEphemeralFunded, + isPendulumEphemeralFunded +} from "../../core/destination-funding"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { getBlockMetadata, getBlockState, getFlowMetadata } from "../../core/metadata"; +import { getNativePrefunding } from "../../core/prepare"; +import { AssethubOfframpSourceContext, type AssethubOfframpSourceRegistrationFacts } from "../assethub-offramp-source"; +import { EvmOfframpSourceContext, EvmOfframpSourceMetadata } from "../evm-offramp-source/simulation"; +import { FundEphemeralContext } from "./simulation"; + +export class FundEphemeralExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "fundEphemeral"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + const blocks = getFlowMetadata(quote.metadata).blocks; + if (blocks[AssethubOfframpSourceContext.key]) { + await this.verifyAssethubSourceTransaction(state); + const substrateAddress = state.state.substrateEphemeralAddress; + if (!substrateAddress) throw new Error("FundEphemeralExecutor: missing Substrate ephemeral for AssetHub route"); + const pendulum = await ApiManager.getInstance().getApi("pendulum"); + if (!(await isPendulumEphemeralFunded(substrateAddress, pendulum))) { + await this.fundSubstrateEphemeralAccount(state, substrateAddress, true, "assethub-substrate-native-funding", signal); + } + return state; + } + + const { evmEphemeralAddress } = state.state as StateMetadata; + if (!evmEphemeralAddress) { + throw new Error("FundEphemeralExecutor: State metadata corrupted, missing evmEphemeralAddress. This is a bug."); + } + await this.verifyUserSubmittedSourceTransactions(state, quote, signal); + const metadata = blocks[EvmOfframpSourceContext.key] + ? (blocks[EvmOfframpSourceContext.key] as EvmOfframpSourceMetadata) + : blocks.alfredpayOfframp + ? (blocks.alfredpayOfframp as { network?: string; fromNetwork: EvmNetworks }) + : getBlockMetadata(quote.metadata, FundEphemeralContext); + const sourceNetwork = (metadata.network ?? (metadata as { fromNetwork?: EvmNetworks }).fromNetwork) as EvmNetworks; + + try { + if (sourceNetwork === Networks.Moonbeam) { + const substrateAddress = state.state.substrateEphemeralAddress; + if (!substrateAddress) throw new Error("FundEphemeralExecutor: missing Substrate ephemeral for Moonbeam route"); + const pendulum = await ApiManager.getInstance().getApi("pendulum"); + if (!(await isPendulumEphemeralFunded(substrateAddress, pendulum))) { + await this.fundSubstrateEphemeralAccount(state, substrateAddress, false, "moonbeam-substrate-native-funding", signal); + } + } + const sourceClient = EvmClientManager.getInstance().getClient(sourceNetwork); + const chain = sourceClient.chain; + if (!chain) { + throw new Error(`FundEphemeralExecutor: Could not get chain info for ${sourceNetwork}`); + } + const fixedFundingUnits = + sourceNetwork === Networks.Polygon + ? POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS + : sourceNetwork === Networks.Moonbeam + ? DESTINATION_EVM_FUNDING_AMOUNTS[Networks.Moonbeam] + : BASE_EPHEMERAL_STARTING_BALANCE_UNITS; + const fixedFundingRaw = BigInt(multiplyByPowerOfTen(fixedFundingUnits, chain.nativeCurrency.decimals).toFixed()); + const plannedNativeValueRaw = getNativePrefunding(state.state.transactionPlan, sourceNetwork, evmEphemeralAddress); + const requiredFundingRaw = fixedFundingRaw + plannedNativeValueRaw; + const currentBalanceRaw = await sourceClient.getBalance({ address: evmEphemeralAddress as `0x${string}` }); + + if (currentBalanceRaw < requiredFundingRaw) { + logger.info(`Funding ${sourceNetwork} ephemeral account ${evmEphemeralAddress}`); + await this.fundEvmEphemeralAccount( + state, + sourceNetwork, + requiredFundingRaw - currentBalanceRaw, + requiredFundingRaw, + signal + ); + } else { + logger.info(`${sourceNetwork} ephemeral address already funded.`); + } + + const destinationNetwork = getNetworkFromDestination(state.to); + if ( + state.type === RampDirection.BUY && + state.to !== Networks.AssetHub && + destinationNetwork && + isNetworkEVM(destinationNetwork) + ) { + const isFunded = await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork); + if (!isFunded) { + logger.info(`Funding EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); + await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork, signal); + } else { + logger.info(`EVM ephemeral account already funded on ${destinationNetwork}.`); + } + } + } catch (e) { + logger.error("Error in FundEphemeralExecutor:", e); + + if (e instanceof PhaseError) { + throw e; + } + + throw this.createRecoverableError("Error funding ephemeral account"); + } + + return state; + } + + private async verifyAssethubSourceTransaction(state: RampState): Promise { + if (!state.state.assethubToPendulumHash) { + throw this.createRecoverableError("AssetHub to Pendulum transaction hash not yet reported by frontend"); + } + const blueprint = state.unsignedTxs.find(tx => tx.phase === "assethubToPendulum"); + if (!blueprint || blueprint.network !== (config.sandboxEnabled ? Networks.Paseo : Networks.AssetHub)) { + throw this.createUnrecoverableError("AssetHub to Pendulum transaction blueprint is missing or on the wrong network"); + } + const facts = getBlockState(state.state, AssethubOfframpSourceContext); + if (blueprint.signer !== facts.userAddress || typeof blueprint.txData !== "string") { + throw this.createUnrecoverableError("AssetHub to Pendulum transaction authority does not match registration"); + } + } + + private async verifyUserSubmittedSourceTransactions( + state: RampState, + quote: QuoteTicket, + signal?: AbortSignal + ): Promise { + if (state.type !== RampDirection.SELL) return; + if (state.from === Networks.AssetHub) return; + if (isAlfredpayToken(quote.outputCurrency as FiatToken)) return; + const metadata = getFlowMetadata(quote.metadata).blocks[EvmOfframpSourceContext.key] as + | EvmOfframpSourceMetadata + | undefined; + if (!metadata) return; + if (state.unsignedTxs.some(tx => tx.phase === "squidRouterNoPermitTransfer")) { + await verifyUserSubmittedTxByHash({ + fromNetwork: metadata.fromNetwork, + hash: state.state.squidRouterNoPermitTransferHash as `0x${string}` | undefined, + label: "User direct USDC transfer to ephemeral", + presignedPhase: "squidRouterNoPermitTransfer", + signal, + state + }); + return; + } + const hasUserSquidSwapBlueprint = state.unsignedTxs.some( + tx => tx.phase === "squidRouterSwap" && tx.signer.toLowerCase() !== (state.state.evmEphemeralAddress ?? "").toLowerCase() + ); + if (!hasUserSquidSwapBlueprint) return; + + const approveHash = state.state.squidRouterApproveHash as `0x${string}` | undefined; + if (approveHash) { + await verifyUserSubmittedTxByHash({ + fromNetwork: metadata.fromNetwork, + hash: approveHash, + label: "User squidRouter approve", + presignedPhase: "squidRouterApprove", + signal, + state + }); + } + await verifyUserSubmittedTxByHash({ + fromNetwork: metadata.fromNetwork, + hash: state.state.squidRouterSwapHash as `0x${string}` | undefined, + label: "User squidRouter swap", + presignedPhase: "squidRouterSwap", + signal, + state + }); + } + + protected async fundEvmEphemeralAccount( + state: RampState, + network: EvmNetworks, + fundingAmountRaw: bigint, + requiredFundingRaw: bigint, + signal?: AbortSignal + ): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const networkClient = evmClientManager.getClient(network); + const chain = networkClient.chain; + + if (!chain) { + throw new Error(`FundEphemeralExecutor: Could not get chain info for ${network}`); + } + + const ephemeralAddress = state.state.evmEphemeralAddress; + + const fundingAccount = getEvmFundingAccount(network); + const walletClient = evmClientManager.getWalletClient(network, fundingAccount); + + await this.runFinancialOperation(state, { + attemptClass: "source-evm-native-funding", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + walletClient.sendTransaction({ + to: ephemeralAddress as `0x${string}`, + value: fundingAmountRaw + }) + ); + const receipt = await abortableCall(signal, () => + networkClient.waitForTransactionReceipt({ + hash: hash as `0x${string}` + }) + ); + if (!receipt || receipt.status !== "success") { + throw new Error(`FundEphemeralExecutor: Transaction ${hash} failed or was not found`); + } + return { hash }; + }, + provider: network, + request: { + amountRaw: fundingAmountRaw.toString(), + destination: ephemeralAddress, + network, + source: fundingAccount.address + }, + signal + }); + + // The receipt confirms inclusion, but downstream phases use a different RPC client which + // may briefly lag behind. Poll the balance until it reflects the funded amount so that + // subsequent phases (nablaApprove etc.) don't read a stale balance. + try { + await waitUntilTrueWithTimeout( + async () => (await networkClient.getBalance({ address: ephemeralAddress as `0x${string}` })) >= requiredFundingRaw, + 1000, + 30000, + signal + ); + } catch (pollError) { + throw new Error( + `FundEphemeralExecutor: Funded ${ephemeralAddress} on ${network} but balance not reflected on RPC within timeout: ${pollError}` + ); + } + } catch (error) { + logger.error(`FundEphemeralExecutor: Error during funding ${network} ephemeral:`, error); + if (error instanceof PhaseError) throw error; + throw new Error(`FundEphemeralExecutor: Error during funding ${network} ephemeral: ` + error); + } + } + + protected async fundDestinationEvmEphemeralAccount( + state: RampState, + destinationNetwork: EvmNetworks, + signal?: AbortSignal + ): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const destinationClient = evmClientManager.getClient(destinationNetwork); + const chain = destinationClient.chain; + + if (!chain) { + throw new Error(`FundEphemeralExecutor: Could not get chain info for ${destinationNetwork}`); + } + + const ephemeralAddress = state.state.evmEphemeralAddress; + const fundingAmountUnits = DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork]; + const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, chain.nativeCurrency.decimals).toFixed(); + + const fundingAccount = getEvmFundingAccount(destinationNetwork); + const walletClient = evmClientManager.getWalletClient(destinationNetwork, fundingAccount); + + await this.runFinancialOperation(state, { + attemptClass: "destination-evm-native-funding", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + walletClient.sendTransaction({ + to: ephemeralAddress as `0x${string}`, + value: BigInt(fundingAmountRaw) + }) + ); + const receipt = await abortableCall(signal, () => + destinationClient.waitForTransactionReceipt({ + hash: hash as `0x${string}` + }) + ); + if (!receipt || receipt.status !== "success") { + throw new Error(`FundEphemeralExecutor: Transaction ${hash} failed or was not found on ${destinationNetwork}`); + } + return { hash }; + }, + provider: destinationNetwork, + request: { + amountRaw: fundingAmountRaw, + destination: ephemeralAddress, + network: destinationNetwork, + source: fundingAccount.address + }, + signal + }); + + try { + await waitUntilTrueWithTimeout( + () => isDestinationEvmEphemeralFunded(ephemeralAddress, destinationNetwork), + 1000, + 30000, + signal + ); + } catch (pollError) { + throw new Error( + `FundEphemeralExecutor: Funded ${ephemeralAddress} on ${destinationNetwork} but balance not reflected on RPC within timeout: ${pollError}` + ); + } + } catch (error) { + logger.error(`FundEphemeralExecutor: Error during funding ${destinationNetwork} ephemeral:`, error); + if (error instanceof PhaseError) throw error; + throw new Error(`FundEphemeralExecutor: Error during funding ${destinationNetwork} ephemeral: ` + error); + } + } + + private async fundSubstrateEphemeralAccount( + state: RampState, + substrateAddress: string, + requiresGlmr: boolean, + attemptClass: string, + signal?: AbortSignal + ): Promise { + await this.runFinancialOperation(state, { + attemptClass, + perform: async () => { + throwIfAborted(signal); + const funded = await abortableCall(signal, () => fundEphemeralAccount("pendulum", substrateAddress, requiresGlmr)); + if (!funded) { + throw new Error(`FundEphemeralExecutor: Pendulum funding outcome is unknown for ${substrateAddress}`); + } + return { funded: true }; + }, + provider: "pendulum", + request: { destination: substrateAddress, requiresGlmr }, + signal + }); + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/index.ts b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/index.ts new file mode 100644 index 000000000..37be87433 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/index.ts @@ -0,0 +1,16 @@ +import type { ChainBrand, Phase, PhaseIO, TokenBrand } from "../../core/types"; +import { FundEphemeralExecutor } from "./execution"; +import { FundEphemeralContext, simulateFundEphemeral } from "./simulation"; + +export function FundEphemeral( + _token: Token, + _chain: Chain +): Phase, PhaseIO> { + return { + context: FundEphemeralContext, + executors: [new FundEphemeralExecutor()], + name: "FundEphemeral", + phases: ["fundEphemeral"], + simulate: simulateFundEphemeral + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/simulation.ts new file mode 100644 index 000000000..be46613d0 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/simulation.ts @@ -0,0 +1,17 @@ +import { defineContext } from "../../core/metadata"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; + +export interface FundEphemeralMetadata { + network: string; + token: string; +} + +export const FundEphemeralContext = defineContext()("fundEphemeral"); + +export async function simulateFundEphemeral( + input: PhaseIO, + ctx: PhaseCtx +): Promise, FundEphemeralMetadata>> { + ctx.addNote(`FundEphemeral: funding ephemeral on ${input.chain} for ${input.amount.toFixed()} ${input.token}`); + return { metadata: { network: input.chain, token: input.token }, output: input }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/moonbeam-to-pendulum-xcm/execution.ts b/apps/api/src/api/services/phases/blocks/phases/moonbeam-to-pendulum-xcm/execution.ts new file mode 100644 index 000000000..14c61672c --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/moonbeam-to-pendulum-xcm/execution.ts @@ -0,0 +1,75 @@ +import { ApiManager, decodeSubmittableExtrinsic, RampPhase, submitMoonbeamXcm, waitUntilTrue } from "@vortexfi/shared"; +import Big from "big.js"; +import logger from "../../../../../../config/logger"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { RecoverablePhaseError } from "../../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { getBlockMetadata } from "../../core/metadata"; +import { MoonbeamToPendulumXcmContext } from "."; + +export class MoonbeamToPendulumXcmExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "moonbeamToPendulumXcm"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) throw new Error("Quote not found for the given state"); + const metadata = getBlockMetadata(quote.metadata, MoonbeamToPendulumXcmContext); + const substrateAddress = state.state.substrateEphemeralAddress; + const evmAddress = state.state.evmEphemeralAddress; + if (!substrateAddress || !evmAddress) throw new Error("MoonbeamToPendulumXcmExecutor: missing ephemeral account"); + + const manager = ApiManager.getInstance(); + const pendulum = await manager.getApi("pendulum"); + const arrived = async () => { + const balance = await pendulum.api.query.tokens.accounts(substrateAddress, metadata.pendulumCurrencyId); + return new Big((balance as unknown as { free?: { toString(): string } }).free?.toString() ?? "0").gte( + metadata.outputAmountRaw + ); + }; + if (!(await arrived()) && !state.state.moonbeamXcmTransactionHash) { + const hasPreviousError = state.errorLogs.some(log => log.phase === this.getPhaseName()); + let moonbeam; + try { + moonbeam = hasPreviousError + ? await manager.getApiWithShuffling("moonbeam", state.id) + : await manager.getApi("moonbeam"); + } catch { + throw new RecoverablePhaseError("MoonbeamToPendulumXcmExecutor: All RPC options exhausted.", 1800); + } + try { + throwIfAborted(signal); + const presigned = this.getPresignedTransaction(state, this.getPhaseName()); + const extrinsic = decodeSubmittableExtrinsic(presigned.txData as string, moonbeam.api); + const { hash } = await this.runFinancialOperation(state, { + attemptClass: "moonbeam-xcm-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + return abortableCall(signal, () => submitMoonbeamXcm(evmAddress, extrinsic)); + }, + provider: "moonbeam", + request: { network: "moonbeam", signedTransaction: presigned.txData }, + signal + }); + state.state = { ...state.state, moonbeamXcmTransactionHash: hash as `0x${string}` }; + await state.update({ state: state.state }); + } catch (error) { + logger.error("MoonbeamToPendulumXcmExecutor: XCM submission failed", error); + if (error instanceof RecoverablePhaseError) throw error; + const message = error instanceof Error ? error.message : String(error); + throw new RecoverablePhaseError( + message.includes("IsInvalid") || message.includes("banned") + ? "MoonbeamToPendulumXcmExecutor: XCM transaction is invalid or banned" + : "MoonbeamToPendulumXcmExecutor: Failed to send XCM transaction", + message.includes("IsInvalid") || message.includes("banned") ? 60 : 120 + ); + } + } + await waitUntilTrue(arrived, 5000, signal); + return state; + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/moonbeam-to-pendulum-xcm/index.ts b/apps/api/src/api/services/phases/blocks/phases/moonbeam-to-pendulum-xcm/index.ts new file mode 100644 index 000000000..00fe6b129 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/moonbeam-to-pendulum-xcm/index.ts @@ -0,0 +1,80 @@ +import { + createMoonbeamToPendulumXCM, + EphemeralAccountType, + EvmToken, + encodeSubmittableExtrinsic, + FiatToken, + getAnyFiatTokenDetailsMoonbeam, + getPendulumDetails, + Networks +} from "@vortexfi/shared"; +import { prepareMoonbeamCleanupTransaction } from "../../../../transactions/moonbeam/cleanup"; +import { requireAccount } from "../../core/accounts"; +import { defineContext } from "../../core/metadata"; +import type { Phase, PhaseIO } from "../../core/types"; +import { MoonbeamToPendulumXcmExecutor } from "./execution"; + +export interface MoonbeamToPendulumXcmMetadata { + inputAmountRaw: string; + outputAmountRaw: string; + pendulumCurrencyId: ReturnType["currencyId"]; +} + +export const MoonbeamToPendulumXcmContext = defineContext()("moonbeamToPendulumXcm"); + +export const MoonbeamToPendulumXcm: Phase< + typeof MoonbeamToPendulumXcmContext, + PhaseIO, + PhaseIO +> = { + context: MoonbeamToPendulumXcmContext, + executors: [new MoonbeamToPendulumXcmExecutor()], + name: "MoonbeamToPendulumXcm", + phases: ["moonbeamToPendulumXcm"], + async prepareTxs(ctx) { + const evm = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const substrate = requireAccount(ctx.accounts, EphemeralAccountType.Substrate); + const token = getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL); + const xcm = await createMoonbeamToPendulumXCM( + substrate.address, + ctx.ownMetadata.inputAmountRaw, + token.moonbeamErc20Address + ); + return { + intents: [ + { + lane: "main", + network: Networks.Moonbeam, + nonceSpan: 2, + phase: "moonbeamToPendulumXcm", + signer: evm.address, + txData: encodeSubmittableExtrinsic(xcm) + }, + { + lane: "cleanup", + network: Networks.Moonbeam, + phase: "moonbeamCleanup", + signer: evm.address, + txData: encodeSubmittableExtrinsic(await prepareMoonbeamCleanupTransaction()) + } + ] + }; + }, + async simulate(input, ctx) { + const pendulum = getPendulumDetails(FiatToken.BRL); + ctx.addNote(`MoonbeamToPendulumXcm: ${input.amount.toFixed()} BRLA to Pendulum`); + return { + metadata: { + inputAmountRaw: input.amountRaw, + outputAmountRaw: input.amountRaw, + pendulumCurrencyId: pendulum.currencyId + }, + output: { + amount: input.amount, + amountRaw: input.amountRaw, + chain: Networks.Pendulum, + token: FiatToken.BRL + } + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/execution.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/execution.ts new file mode 100644 index 000000000..0aa0e98e3 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/execution.ts @@ -0,0 +1,107 @@ +import { + BalanceCheckError, + BalanceCheckErrorType, + checkEvmBalancePeriodically, + EvmAddress, + EvmToken, + evmTokenConfig, + getEvmTokenBalance, + Networks, + RampPhase +} from "@vortexfi/shared"; +import Big from "big.js"; +import logger from "../../../../../../config/logger"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { StateMetadata } from "../../../../phases/meta-state-types"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { getBlockMetadata } from "../../core/metadata"; +import { MykoboMintContext } from "./simulation"; + +const PAYMENT_TIMEOUT_MS = 24 * 60 * 60 * 1000; +const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; +const POLL_INTERVAL_MS = 5000; +const EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95; + +export class MykoboOnrampDepositExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "mykoboOnrampDeposit"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const { evmEphemeralAddress } = state.state as StateMetadata; + if (!evmEphemeralAddress) { + throw new Error("MykoboOnrampDepositExecutor: Missing EVM ephemeral address"); + } + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("MykoboOnrampDepositExecutor: Quote not found"); + } + const metadata = getBlockMetadata(quote.metadata, MykoboMintContext); + const token = evmTokenConfig[Networks.Base][EvmToken.EURC]; + if (!token) { + throw new Error("MykoboOnrampDepositExecutor: EURC token details not found for Base"); + } + const expectedAmountRaw = metadata.mint.outputAmountRaw; + const recoveryThresholdRaw = new Big(expectedAmountRaw).mul(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0); + if (await this.ephemeralAlreadyFunded(token.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw, signal)) { + logger.info(`MykoboOnrampDepositExecutor: Base ephemeral already holds at least 95% of ${expectedAmountRaw} EURC`); + return state; + } + try { + await checkEvmBalancePeriodically( + token.erc20AddressSourceChain, + evmEphemeralAddress, + expectedAmountRaw, + POLL_INTERVAL_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + Networks.Base, + signal + ); + } catch (error) { + if (!(error instanceof BalanceCheckError)) { + throw new Error(`MykoboOnrampDepositExecutor: Error checking Base EURC balance: ${error}`); + } + const isCheckTimeout = error.type === BalanceCheckErrorType.Timeout; + if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { + logger.error("MykoboOnrampDepositExecutor: Payment timeout reached. Cancelling ramp."); + return this.transitionToNextPhase(state, "failed"); + } + throw isCheckTimeout + ? this.createRecoverableError(`MykoboOnrampDepositExecutor: balance-check timeout waiting for settlement: ${error}`) + : new Error(`MykoboOnrampDepositExecutor: Error checking Base EURC balance: ${error}`); + } + return state; + } + + private async ephemeralAlreadyFunded( + tokenAddress: string, + ownerAddress: string, + expectedAmountRaw: string, + signal?: AbortSignal + ): Promise { + try { + const balance = await abortableCall(signal, () => + getEvmTokenBalance({ + chain: Networks.Base, + ownerAddress: ownerAddress as EvmAddress, + tokenAddress: tokenAddress as EvmAddress + }) + ); + return balance.gte(new Big(expectedAmountRaw)); + } catch (error) { + throwIfAborted(signal); + logger.warn(`MykoboOnrampDepositExecutor: balance pre-check failed, falling back to wait loop: ${error}`); + return false; + } + } + + protected isPaymentTimeoutReached(state: RampState): boolean { + const phase = state.phaseHistory.find(entry => entry.phase === this.getPhaseName()); + if (!phase) { + throw new Error("MykoboOnrampDepositExecutor: Phase not found in history"); + } + return new Date(phase.timestamp).getTime() + PAYMENT_TIMEOUT_MS < Date.now(); + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/index.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/index.ts new file mode 100644 index 000000000..fa9adc351 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/index.ts @@ -0,0 +1,23 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import type { Phase, PhaseIO } from "../../core/types"; +import { MykoboOnrampDepositExecutor } from "./execution"; +import { type MykoboMintRegistrationFacts, type MykoboMintRegistrationInput, registerMykoboMint } from "./registration"; +import { MykoboMintContext, simulateMykoboMint } from "./simulation"; +import { prepareMykoboMintTxs } from "./transactions"; + +export const MykoboMint: Phase< + typeof MykoboMintContext, + PhaseIO, + PhaseIO, + MykoboMintRegistrationFacts, + MykoboMintRegistrationInput +> = { + context: MykoboMintContext, + executors: [new MykoboOnrampDepositExecutor()], + externalOperations: { register: { provider: "mykobo" } }, + name: "MykoboMint", + phases: ["mykoboOnrampDeposit"], + prepareTxs: prepareMykoboMintTxs, + register: registerMykoboMint, + simulate: simulateMykoboMint +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/registration.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/registration.ts new file mode 100644 index 000000000..e07ed46c4 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/registration.ts @@ -0,0 +1,74 @@ +import { + EphemeralAccountType, + IbanPaymentData, + MykoboApiService, + MykoboCurrency, + MykoboTransactionType +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { requireAccount } from "../../core/accounts"; +import type { RegisterCtx, RegistrationResult } from "../../core/types"; +import type { MykoboMintMetadata } from "./simulation"; + +export interface MykoboMintRegistrationInput extends Record { + email?: string; +} + +export interface MykoboMintRegistrationFacts { + mykoboEmail: string; + mykoboTransactionId: string; + mykoboTransactionReference: string; +} + +export interface MykoboMintResponseArtifacts extends Record { + ibanPaymentData: IbanPaymentData; +} + +export async function registerMykoboMint( + ctx: RegisterCtx +): Promise> { + const [{ APIError }, { resolveMykoboCustomerForUser }] = await Promise.all([ + import("../../../../../errors/api-error"), + import("../../../../mykobo/mykobo-customer.service") + ]); + if (!ctx.ipAddress) { + throw new APIError({ message: "IP address is required for Mykobo EUR onramp", status: httpStatus.BAD_REQUEST }); + } + const { email } = await resolveMykoboCustomerForUser(ctx.authenticatedUser.id, ctx.input.email); + const evmEphemeral = requireAccount( + Object.fromEntries(ctx.signingAccounts.map(account => [account.type, account])), + EphemeralAccountType.EVM + ); + const intent = await MykoboApiService.getInstance().createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: email, + ip_address: ctx.ipAddress, + transaction_type: MykoboTransactionType.DEPOSIT, + value: new Big(ctx.quote.inputAmount).toFixed(2, 0), + wallet_address: evmEphemeral.address + }); + const instructions = intent.instructions; + if (!instructions || !("iban" in instructions)) { + throw new APIError({ + message: "Mykobo deposit intent did not return IBAN instructions", + status: httpStatus.BAD_GATEWAY + }); + } + const responseArtifacts: MykoboMintResponseArtifacts = { + ibanPaymentData: { + bic: "", + iban: instructions.iban, + receiverName: instructions.bank_account_name, + reference: intent.transaction.reference + } + }; + return { + facts: { + mykoboEmail: email, + mykoboTransactionId: intent.transaction.id, + mykoboTransactionReference: intent.transaction.reference + }, + responseArtifacts + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/simulation.ts new file mode 100644 index 000000000..02a3f28ab --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/simulation.ts @@ -0,0 +1,92 @@ +import { + EvmNetworks, + EvmToken, + EvmTokenDetails, + FiatToken, + getNetworkFromDestination, + getOnChainTokenDetails, + isNetworkEVM, + multiplyByPowerOfTen, + Networks, + OnChainToken, + RampCurrency +} from "@vortexfi/shared"; +import Big from "big.js"; +import { calculateFees } from "../../core/fees"; +import { evmIO } from "../../core/io"; +import { defineContext } from "../../core/metadata"; +import { resolveMykoboDepositFee } from "../../core/mykobo-fee"; +import { calculateEvmBridgeAndNetworkFee, getBridgeTargetTokenDetails } from "../../core/squidrouter"; +import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; +import type { AnchorOperationMetadata } from "../avenia-mint/simulation"; + +export interface MykoboMintMetadata { + mint: AnchorOperationMetadata; +} + +export const MykoboMintContext = defineContext()("mykoboMint"); + +export async function simulateMykoboMint( + input: PhaseIO, + ctx: PhaseCtx +): Promise, MykoboMintMetadata>> { + const eurcBaseDetails = getOnChainTokenDetails(Networks.Base, EvmToken.EURC); + if (!eurcBaseDetails) { + throw new Error("MykoboMint: EURC token details not found for Base"); + } + + const inputAmountDecimal = new Big(input.amount); + const mykoboFeeDecimal = new Big(await resolveMykoboDepositFee(inputAmountDecimal.toFixed(2, 0))); + + const deliveredEurcDecimal = inputAmountDecimal.minus(mykoboFeeDecimal); + if (deliveredEurcDecimal.lte(0)) { + throw new Error( + `MykoboMint: Mykobo deposit fee ${mykoboFeeDecimal.toFixed()} EUR is greater than or equal to input amount ${inputAmountDecimal.toFixed()} EUR` + ); + } + const deliveredEurcRaw = multiplyByPowerOfTen(deliveredEurcDecimal, eurcBaseDetails.decimals).toFixed(0, 0); + const toNetwork = getNetworkFromDestination(ctx.request.to); + if (!toNetwork || !isNetworkEVM(toNetwork)) { + throw new Error(`MykoboMint: Invalid EVM destination ${ctx.request.to}`); + } + const toToken = getBridgeTargetTokenDetails(ctx.request.outputCurrency as OnChainToken, toNetwork); + const isDirectTransfer = + toNetwork === Networks.Base && + (eurcBaseDetails as EvmTokenDetails).erc20AddressSourceChain.toLowerCase() === + toToken.erc20AddressSourceChain.toLowerCase(); + const networkFee = isDirectTransfer + ? "0" + : ( + await calculateEvmBridgeAndNetworkFee({ + amountRaw: multiplyByPowerOfTen(inputAmountDecimal, eurcBaseDetails.decimals).toFixed(0, 0), + fromNetwork: Networks.Base as EvmNetworks, + fromToken: (eurcBaseDetails as EvmTokenDetails).erc20AddressSourceChain, + originalInputAmountForRateCalc: ctx.request.inputAmount, + toNetwork, + toToken: toToken.erc20AddressSourceChain + }) + ).networkFeeUSD; + const fees = await calculateFees(ctx, { + anchor: { amount: mykoboFeeDecimal.toString(), currency: FiatToken.EURC as RampCurrency }, + network: { amount: networkFee, currency: EvmToken.USDC as RampCurrency } + }); + + ctx.addNote( + `MykoboMint: ${deliveredEurcDecimal.toFixed()} EURC delivered on Base after ${mykoboFeeDecimal.toFixed()} EUR fee` + ); + + return { + fees, + metadata: { + mint: { + currency: FiatToken.EURC, + fee: mykoboFeeDecimal, + inputAmountDecimal, + inputAmountRaw: multiplyByPowerOfTen(inputAmountDecimal, eurcBaseDetails.decimals).toFixed(0, 0), + outputAmountDecimal: deliveredEurcDecimal, + outputAmountRaw: deliveredEurcRaw + } + }, + output: evmIO(EvmToken.EURC, Networks.Base, deliveredEurcDecimal, deliveredEurcRaw) + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/transactions.ts new file mode 100644 index 000000000..62728fae5 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-mint/transactions.ts @@ -0,0 +1,51 @@ +import { + EphemeralAccountType, + EvmToken, + EvmTransactionData, + evmTokenConfig, + getNetworkFromDestination, + Networks +} from "@vortexfi/shared"; +import { requireAccount } from "../../core/accounts"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { encodeEvmTransactionData, prepareBaseCleanupApproval } from "../../core/evm-transactions"; +import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { MykoboMintRegistrationFacts } from "./registration"; +import type { MykoboMintMetadata } from "./simulation"; + +export type MykoboMintPreparation = MykoboMintRegistrationFacts; + +export async function prepareMykoboMintTxs( + ctx: PrepareCtx +): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + if (!ctx.ownRegistrationFacts) { + throw new Error("prepareMykoboMintTxs: Missing Mykobo registration facts"); + } + const isDirectTransfer = + ctx.globals.request.outputCurrency === EvmToken.EURC && getNetworkFromDestination(ctx.globals.request.to) === Networks.Base; + if (isDirectTransfer) { + return { intents: [], state: { ...ctx.ownRegistrationFacts } }; + } + const eurc = evmTokenConfig[Networks.Base][EvmToken.EURC]; + if (!eurc) { + throw new Error("prepareMykoboMintTxs: EURC token details not found for Base"); + } + const cleanup = await prepareBaseCleanupApproval( + eurc.erc20AddressSourceChain as `0x${string}`, + getEvmFundingAccount(Networks.Base).address, + Networks.Base + ); + return { + intents: [ + { + lane: "cleanup", + network: Networks.Base, + phase: "baseCleanupEurc", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(cleanup) as EvmTransactionData + } + ], + state: { ...ctx.ownRegistrationFacts } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-fee/index.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-fee/index.ts new file mode 100644 index 000000000..0e1de21d1 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-fee/index.ts @@ -0,0 +1,15 @@ +import type { ChainBrand, Phase, PhaseIO, TokenBrand } from "../../core/types"; +import { MykoboOfframpFeeContext, simulateMykoboOfframpFee } from "./simulation"; + +export function MykoboOfframpFee(): Phase< + typeof MykoboOfframpFeeContext, + PhaseIO, + PhaseIO +> { + return { + context: MykoboOfframpFeeContext, + name: "MykoboOfframpFee", + phases: [], + simulate: simulateMykoboOfframpFee + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-fee/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-fee/simulation.ts new file mode 100644 index 000000000..6fd68a9de --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-fee/simulation.ts @@ -0,0 +1,51 @@ +import { EvmToken, FiatToken, RampCurrency } from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { defineContext } from "../../core/metadata"; +import { resolveMykoboWithdrawFee } from "../../core/mykobo-fee"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; + +export interface MykoboOfframpFeeMetadata { + anchorFeeEur: string; + grossAmountEur: string; +} + +export const MykoboOfframpFeeContext = defineContext()("mykoboOfframpFee"); + +export async function simulateMykoboOfframpFee( + input: PhaseIO, + ctx: PhaseCtx, + dependencies: { resolveWithdrawFee?: typeof resolveMykoboWithdrawFee } = {} +): Promise, MykoboOfframpFeeMetadata>> { + if (!ctx.fees?.usd || !ctx.fees.displayFiat) { + throw new Error("MykoboOfframpFee: Missing fee snapshot"); + } + const grossAmountEur = input.amount.toFixed(2, 0); + const anchorFeeEur = await (dependencies.resolveWithdrawFee ?? resolveMykoboWithdrawFee)(grossAmountEur); + const displayCurrency = ctx.fees.displayFiat.currency; + const [anchorUsd, anchorDisplay] = await Promise.all([ + priceFeedService.convertCurrency(anchorFeeEur, FiatToken.EURC as RampCurrency, EvmToken.USDC as RampCurrency), + priceFeedService.convertCurrency(anchorFeeEur, FiatToken.EURC as RampCurrency, displayCurrency) + ]); + const fees = { + displayFiat: { + ...ctx.fees.displayFiat, + anchor: anchorDisplay, + total: new Big(anchorDisplay) + .plus(ctx.fees.displayFiat.network) + .plus(ctx.fees.displayFiat.partnerMarkup) + .plus(ctx.fees.displayFiat.vortex) + .toFixed(2) + }, + usd: { + ...ctx.fees.usd, + anchor: anchorUsd, + total: new Big(anchorUsd).plus(ctx.fees.usd.network).plus(ctx.fees.usd.partnerMarkup).plus(ctx.fees.usd.vortex).toFixed(6) + } + }; + return { + fees, + metadata: { anchorFeeEur, grossAmountEur }, + output: input + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/execution.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/execution.ts new file mode 100644 index 000000000..c9478e798 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/execution.ts @@ -0,0 +1,111 @@ +import { EvmClientManager, MykoboApiService, MykoboTransactionStatus, Networks, type RampPhase, sleep } from "@vortexfi/shared"; +import logger from "../../../../../../config/logger"; +import RampState from "../../../../../../models/rampState.model"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { ensurePresignedTransferFunded } from "../../core/destination-funding"; +import { getBlockState } from "../../core/metadata"; +import type { MykoboOfframpPayoutRegistrationFacts } from "./registration"; +import { MykoboOfframpPayoutContext } from "./simulation"; + +const POLL_INTERVAL_MS = 5_000; +const POLL_TIMEOUT_MS = 10 * 60 * 1_000; + +export class MykoboOfframpPayoutExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "mykoboPayoutOnBase"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const facts = state.state.blockState?.[MykoboOfframpPayoutContext.key] + ? getBlockState(state.state, MykoboOfframpPayoutContext) + : this.legacyFacts(state); + await this.sendPayout(state, signal); + await this.pollUntilCompleted(facts.mykoboTransactionId, signal); + return state; + } + + private legacyFacts(state: RampState): MykoboOfframpPayoutRegistrationFacts { + const { mykoboEmail, mykoboReceivablesAddress, mykoboTransactionId, mykoboTransactionReference } = state.state; + if (!mykoboEmail || !mykoboReceivablesAddress || !mykoboTransactionId || !mykoboTransactionReference) { + throw new Error("MykoboOfframpPayoutExecutor: Missing payout registration facts"); + } + return { mykoboEmail, mykoboReceivablesAddress, mykoboTransactionId, mykoboTransactionReference }; + } + + private async sendPayout(state: RampState, signal?: AbortSignal): Promise { + try { + const manager = EvmClientManager.getInstance(); + const client = manager.getClient(Networks.Base); + const transaction = this.getPresignedTransaction(state, "mykoboPayoutOnBase"); + if (!transaction || typeof transaction.txData !== "string") { + throw new Error("MykoboOfframpPayoutExecutor: Missing presigned payout transaction"); + } + if (state.state.mykoboPayoutTxHash) { + const receipt = await abortableCall(signal, () => + client.waitForTransactionReceipt({ hash: state.state.mykoboPayoutTxHash as `0x${string}` }) + ); + if (receipt.status === "success") return; + throw this.createUnrecoverableError(`Mykobo payout transfer ${state.state.mykoboPayoutTxHash} failed`); + } else { + await ensurePresignedTransferFunded(transaction.txData as `0x${string}`, Networks.Base, this.getPhaseName(), signal); + } + const { hash } = await this.runFinancialOperation(state, { + attemptClass: "presigned-payout-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = (await manager.sendRawTransactionWithRetry( + Networks.Base, + transaction.txData as `0x${string}` + )) as `0x${string}`; + const receipt = await abortableCall(signal, () => client.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") throw new Error(`Mykobo payout transfer ${hash} failed`); + return { hash }; + }, + provider: Networks.Base, + request: { network: Networks.Base, signedTransaction: transaction.txData }, + signal + }); + await state.update({ state: { ...state.state, mykoboPayoutTxHash: hash } }); + } catch (error) { + if (error instanceof PhaseError) throw error; + logger.error("MykoboOfframpPayoutExecutor: Failed to send Mykobo payout transaction", error); + throw this.createRecoverableError("Failed to send Mykobo payout transaction"); + } + } + + private async pollUntilCompleted(transactionId: string, signal?: AbortSignal): Promise { + const api = MykoboApiService.getInstance(); + const start = Date.now(); + let lastError: unknown; + while (Date.now() - start < POLL_TIMEOUT_MS) { + throwIfAborted(signal); + try { + const { transaction } = await abortableCall(signal, () => api.getTransaction(transactionId)); + if (transaction.status === MykoboTransactionStatus.COMPLETED) return; + if ( + transaction.status === MykoboTransactionStatus.FAILED || + transaction.status === MykoboTransactionStatus.CANCELLED || + transaction.status === MykoboTransactionStatus.EXPIRED + ) { + throw this.createUnrecoverableError( + `MykoboOfframpPayoutExecutor: Mykobo transaction ${transactionId} ended with status ${transaction.status}` + ); + } + } catch (error) { + if (error instanceof PhaseError) throw error; + lastError = error; + logger.warn("MykoboOfframpPayoutExecutor: Polling Mykobo transaction failed; retrying", error); + } + await sleep(POLL_INTERVAL_MS, signal); + } + if (lastError) { + throw this.createRecoverableError( + `MykoboOfframpPayoutExecutor: Polling timed out with transient error: ${(lastError as Error).message}` + ); + } + throw this.createRecoverableError("MykoboOfframpPayoutExecutor: Polling for Mykobo transaction status timed out"); + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/index.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/index.ts new file mode 100644 index 000000000..167a411f0 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/index.ts @@ -0,0 +1,27 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import type { Phase, PhaseIO } from "../../core/types"; +import { MykoboOfframpPayoutExecutor } from "./execution"; +import { + type MykoboOfframpPayoutRegistrationFacts, + type MykoboOfframpPayoutRegistrationInput, + registerMykoboOfframpPayout +} from "./registration"; +import { MykoboOfframpPayoutContext, simulateMykoboOfframpPayout } from "./simulation"; +import { prepareMykoboOfframpPayoutTxs } from "./transactions"; + +export const MykoboOfframpPayout: Phase< + typeof MykoboOfframpPayoutContext, + PhaseIO, + PhaseIO, + MykoboOfframpPayoutRegistrationFacts, + MykoboOfframpPayoutRegistrationInput +> = { + context: MykoboOfframpPayoutContext, + executors: [new MykoboOfframpPayoutExecutor()], + externalOperations: { register: { provider: "mykobo" } }, + name: "MykoboOfframpPayout", + phases: ["mykoboPayoutOnBase"], + prepareTxs: prepareMykoboOfframpPayoutTxs, + register: registerMykoboOfframpPayout, + simulate: simulateMykoboOfframpPayout +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/registration.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/registration.ts new file mode 100644 index 000000000..735e3a9c5 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/registration.ts @@ -0,0 +1,59 @@ +import { + EphemeralAccountType, + isWithdrawInstructions, + MykoboApiService, + MykoboCurrency, + MykoboTransactionType +} from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { APIError } from "../../../../../errors/api-error"; +import { resolveMykoboCustomerForUser } from "../../../../mykobo/mykobo-customer.service"; +import { requireAccount } from "../../core/accounts"; +import type { RegisterCtx, RegistrationResult } from "../../core/types"; +import type { MykoboOfframpPayoutMetadata } from "./simulation"; + +export interface MykoboOfframpPayoutRegistrationInput extends Record { + email?: string; +} + +export interface MykoboOfframpPayoutRegistrationFacts { + mykoboEmail: string; + mykoboReceivablesAddress: string; + mykoboTransactionId: string; + mykoboTransactionReference: string; +} + +export async function registerMykoboOfframpPayout( + ctx: RegisterCtx +): Promise> { + if (!ctx.ipAddress) { + throw new APIError({ message: "IP address is required for Mykobo EUR offramp", status: httpStatus.BAD_REQUEST }); + } + const { email } = await resolveMykoboCustomerForUser(ctx.authenticatedUser.id, ctx.input.email); + const evmEphemeral = requireAccount( + Object.fromEntries(ctx.signingAccounts.map(account => [account.type, account])), + EphemeralAccountType.EVM + ); + const intent = await MykoboApiService.getInstance().createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: email, + ip_address: ctx.ipAddress, + transaction_type: MykoboTransactionType.WITHDRAW, + value: String(ctx.metadata.transferAmountDecimal), + wallet_address: evmEphemeral.address + }); + if (!isWithdrawInstructions(intent.instructions)) { + throw new APIError({ + message: "Mykobo withdraw intent did not return receivables instructions", + status: httpStatus.BAD_GATEWAY + }); + } + return { + facts: { + mykoboEmail: email, + mykoboReceivablesAddress: intent.instructions.address, + mykoboTransactionId: intent.transaction.id, + mykoboTransactionReference: intent.transaction.reference + } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/simulation.ts new file mode 100644 index 000000000..ba52077ba --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/simulation.ts @@ -0,0 +1,32 @@ +import { EvmToken, FiatToken, multiplyByPowerOfTen, Networks } from "@vortexfi/shared"; +import Big from "big.js"; +import { defineContext, type SerializableBig } from "../../core/metadata"; +import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; + +export interface MykoboOfframpPayoutMetadata { + payoutAmountDecimal: SerializableBig; + payoutAmountRaw: string; + transferAmountDecimal: SerializableBig; + transferAmountRaw: string; +} + +export const MykoboOfframpPayoutContext = defineContext()("mykoboOfframpPayout"); + +export async function simulateMykoboOfframpPayout( + input: PhaseIO, + ctx: PhaseCtx +): Promise, MykoboOfframpPayoutMetadata>> { + const transferAmount = new Big(input.amount.toFixed(2, 0)); + const transferAmountRaw = multiplyByPowerOfTen(transferAmount, 6).toFixed(0, 0); + const payoutAmount = transferAmount.minus(ctx.fees?.displayFiat?.anchor ?? 0); + const payoutAmountRaw = multiplyByPowerOfTen(payoutAmount, 2).toFixed(0, 0); + return { + metadata: { + payoutAmountDecimal: payoutAmount, + payoutAmountRaw, + transferAmountDecimal: transferAmount, + transferAmountRaw + }, + output: { amount: payoutAmount, amountRaw: payoutAmountRaw, chain: "fiat", token: FiatToken.EURC } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/transactions.ts new file mode 100644 index 000000000..9db37b9db --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/mykobo-offramp-payout/transactions.ts @@ -0,0 +1,63 @@ +import { EphemeralAccountType, EvmToken, EvmTransactionData, evmTokenConfig, Networks } from "@vortexfi/shared"; +import { requireAccount } from "../../core/accounts"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { + createDestinationTransferTransaction, + encodeEvmTransactionData, + prepareBaseCleanupApproval +} from "../../core/evm-transactions"; +import type { PrepareCtx, PreparedPhaseTxs, TxIntent } from "../../core/types"; +import type { MykoboOfframpPayoutRegistrationFacts } from "./registration"; +import type { MykoboOfframpPayoutMetadata } from "./simulation"; + +export async function prepareMykoboOfframpPayoutTxs( + ctx: PrepareCtx +): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const facts = ctx.ownRegistrationFacts; + if (!facts) throw new Error("prepareMykoboOfframpPayoutTxs: Missing Mykobo registration facts"); + const eurc = evmTokenConfig[Networks.Base][EvmToken.EURC]; + if (!eurc) throw new Error("prepareMykoboOfframpPayoutTxs: Missing Base EURC configuration"); + const payout = await createDestinationTransferTransaction({ + amountRaw: ctx.ownMetadata.transferAmountRaw, + destinationNetwork: Networks.Base, + isNativeToken: false, + toAddress: facts.mykoboReceivablesAddress as `0x${string}`, + toToken: eurc.erc20AddressSourceChain as `0x${string}` + }); + const fundingAddress = getEvmFundingAccount(Networks.Base).address; + const cleanupIntents: TxIntent[] = []; + for (const [token, phase] of [ + [EvmToken.USDC, "baseCleanupUsdc"], + [EvmToken.EURC, "baseCleanupEurc"], + [EvmToken.AXLUSDC, "baseCleanupAxlUsdc"] + ] as const) { + const details = evmTokenConfig[Networks.Base][token]; + if (!details) throw new Error(`prepareMykoboOfframpPayoutTxs: Missing Base ${token} configuration`); + const approval = await prepareBaseCleanupApproval( + details.erc20AddressSourceChain as `0x${string}`, + fundingAddress, + Networks.Base + ); + cleanupIntents.push({ + lane: "cleanup", + network: Networks.Base, + phase, + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(approval) as EvmTransactionData + }); + } + return { + intents: [ + { + lane: "main", + network: Networks.Base, + phase: "mykoboPayoutOnBase", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(payout) as EvmTransactionData + }, + ...cleanupIntents + ], + state: { ...facts } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/nabla-swap/execution.ts b/apps/api/src/api/services/phases/blocks/phases/nabla-swap/execution.ts new file mode 100644 index 000000000..0d307f21f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/nabla-swap/execution.ts @@ -0,0 +1,332 @@ +import { createExecuteMessageExtrinsic, readMessage, submitExtrinsic } from "@pendulum-chain/api-solang"; +import { Abi } from "@polkadot/api-contract"; +import { + ApiManager, + checkEvmBalanceForToken, + decodeSubmittableExtrinsic, + defaultReadLimits, + EvmClientManager, + EvmToken, + EvmTokenDetails, + evmTokenConfig, + getOnChainTokenDetails, + NABLA_ROUTER, + Networks, + RampDirection, + RampPhase +} from "@vortexfi/shared"; +import { Big } from "big.js"; +import { parseTransaction, recoverTransactionAddress } from "viem"; +import logger from "../../../../../../config/logger"; +import { erc20WrapperAbi } from "../../../../../../contracts/ERC20Wrapper"; +import { routerAbi } from "../../../../../../contracts/Router"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { FinancialOperationRejectedError } from "../../core/financial-operation"; +import { getBlockMetadata, getBlockState } from "../../core/metadata"; +import { NablaSwapContext } from "./simulation"; + +// EVM slice of the production NablaApprovePhaseHandler: broadcasts the presigned ERC-20 approve +// for the Nabla router on Base. The substrate (Pendulum) branch is not ported. +export class NablaApproveExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "nablaApprove"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) throw new Error("Quote not found for the given state"); + const metadata = getBlockMetadata(quote.metadata, NablaSwapContext); + if (metadata.network === Networks.Pendulum) { + const manager = ApiManager.getInstance(); + const pendulum = await manager.getApi("pendulum"); + const substrateAddress = state.state.substrateEphemeralAddress; + if (!substrateAddress || !metadata.inputCurrencyId) throw new Error("NablaApproveExecutor: missing Pendulum data"); + try { + const approval = await pendulum.api.query.tokenAllowance.approvals( + metadata.inputCurrencyId, + substrateAddress, + NABLA_ROUTER + ); + if (new Big(approval.toString() || "0").gte(metadata.inputAmountForSwapRaw)) return state; + } catch (e) { + throw this.createRecoverableError( + `NablaApproveExecutor: Could not check if the approve has already been performed. ${(e as Error).message}` + ); + } + const preparation = getBlockState<{ + approveExtrinsicOptions: Parameters[0]; + }>(state.state, NablaSwapContext); + const abi = new Abi(erc20WrapperAbi, pendulum.api.registry.getChainProperties()); + const dryRun = await createExecuteMessageExtrinsic({ + ...preparation.approveExtrinsicOptions, + abi, + api: pendulum.api, + skipDryRunning: false + }); + if (!dryRun.result || dryRun.result.type !== "success") throw new Error("Could not dry-run Nabla approval"); + const presigned = this.getPresignedTransaction(state, "nablaApprove"); + await this.runFinancialOperation(state, { + attemptClass: "substrate-contract-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const result = await abortableCall(signal, () => + submitExtrinsic(decodeSubmittableExtrinsic(presigned.txData as string, pendulum.api)) + ); + if (result.status.type === "error") { + throw new FinancialOperationRejectedError("Could not approve token"); + } + return { hash: result.txHash.toString() }; + }, + provider: Networks.Pendulum, + request: { network: Networks.Pendulum, signedTransaction: presigned.txData }, + signal + }); + return state; + } + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + try { + const { txData: nablaApproveTransaction } = this.getPresignedTransaction(state, "nablaApprove"); + + if (typeof nablaApproveTransaction !== "string") { + throw new Error("NablaApproveExecutor: Invalid EVM transaction data. This is a bug."); + } + + const { hash: txHash } = await this.runFinancialOperation(state, { + attemptClass: "evm-presigned-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + baseClient.sendRawTransaction({ + serializedTransaction: nablaApproveTransaction as `0x${string}` + }) + ); + const receipt = await abortableCall(signal, () => baseClient.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") { + throw new FinancialOperationRejectedError(`NablaApproveExecutor: EVM approve transaction ${hash} failed`); + } + return { hash }; + }, + provider: Networks.Base, + request: { network: Networks.Base, signedTransaction: nablaApproveTransaction }, + signal + }); + + logger.info(`NablaApproveExecutor: EVM approve transaction successful: ${txHash}`); + + return state; + } catch (e) { + logger.error(`Could not approve token on EVM: ${(e as Error).message}`); + throw e; + } + } +} + +// EVM slice of the production NablaSwapPhaseHandler: validates the ephemeral holds the simulated +// swap input on Base, then broadcasts the presigned swap. The substrate branch (soft-minimum +// dry-run via getAmountOut) is not ported. +export class NablaSwapExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "nablaSwap"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const metadata = getBlockMetadata(quote.metadata, NablaSwapContext); + + if (metadata.network === Networks.Pendulum) { + return this.executePendulumSwap(state, metadata, signal); + } + + const evmEphemeralAddress = state.state.evmEphemeralAddress; + if (!evmEphemeralAddress) { + throw new Error("Missing EVM ephemeral address to validate nabla swap input balance"); + } + + const inputTokenDetails = evmTokenConfig[Networks.Base]?.[metadata.inputCurrency as EvmToken] as + | EvmTokenDetails + | undefined; + if (!inputTokenDetails) { + throw new Error(`Invalid input token ${metadata.inputCurrency} for Base nabla swap`); + } + + try { + await checkEvmBalanceForToken({ + amountDesiredRaw: metadata.inputAmountForSwapRaw, + chain: Networks.Base, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + signal, + timeoutMs: 5000, + tokenDetails: inputTokenDetails + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + logger.error(`Could not validate EVM input balance before swap: ${errorMessage}`); + + throw this.createUnrecoverableError(`Could not validate EVM input balance before swap: ${errorMessage}`); + } + + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + + try { + const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, "nablaSwap"); + + if (typeof nablaSwapTransaction !== "string") { + throw new Error("NablaSwapExecutor: Invalid EVM transaction data. This is a bug."); + } + + await this.dryRunEvmSwap(nablaSwapTransaction as `0x${string}`, evmEphemeralAddress as `0x${string}`, signal); + + const { hash: txHash } = await this.runFinancialOperation(state, { + attemptClass: "evm-presigned-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + baseClient.sendRawTransaction({ + serializedTransaction: nablaSwapTransaction as `0x${string}` + }) + ); + const receipt = await abortableCall(signal, () => baseClient.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") { + throw new FinancialOperationRejectedError(`NablaSwapExecutor: EVM swap transaction ${hash} failed`); + } + return { hash }; + }, + provider: Networks.Base, + request: { network: Networks.Base, signedTransaction: nablaSwapTransaction }, + signal + }); + + logger.info(`NablaSwapExecutor: EVM swap transaction successful: ${txHash}`); + } catch (e) { + logger.error(`Could not swap token on EVM: ${(e as Error).message}`); + if (e instanceof PhaseError) throw e; + throw this.createUnrecoverableError(`Could not swap token on EVM: ${(e as Error).message}`); + } + + return state; + } + + private async executePendulumSwap( + state: RampState, + metadata: ReturnType>, + signal?: AbortSignal + ): Promise { + const substrateAddress = state.state.substrateEphemeralAddress; + if (!substrateAddress || !metadata.inputCurrencyId) throw new Error("NablaSwapExecutor: missing Pendulum data"); + if (state.state.nablaSwapTxHash) return state; + const manager = ApiManager.getInstance(); + const pendulum = await manager.getApi("pendulum"); + const preparation = getBlockState<{ + softMinimumOutputRaw: string; + swapExtrinsicOptions: Parameters[0]; + }>(state.state, NablaSwapContext); + const dryRun = await createExecuteMessageExtrinsic({ + ...preparation.swapExtrinsicOptions, + abi: new Abi(routerAbi), + api: pendulum.api, + skipDryRunning: false + }); + if (!dryRun.result || dryRun.result.type !== "success") throw new Error("Could not dry-run Nabla swap"); + const quote = await readMessage({ + abi: new Abi(routerAbi), + api: pendulum.api, + callerAddress: substrateAddress, + contractDeploymentAddress: NABLA_ROUTER, + limits: defaultReadLimits, + messageArguments: [metadata.inputAmountForSwapRaw, [metadata.inputToken, metadata.outputToken]], + messageName: "getAmountOut" + }); + if (quote.type !== "success" || new Big(quote.value[0].toString()).lt(preparation.softMinimumOutputRaw)) { + throw this.createRecoverableError("NablaSwapExecutor: estimated Pendulum output is below the soft minimum"); + } + const presigned = this.getPresignedTransaction(state, "nablaSwap"); + const { hash } = await this.runFinancialOperation(state, { + attemptClass: "substrate-contract-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const result = await abortableCall(signal, () => + submitExtrinsic(decodeSubmittableExtrinsic(presigned.txData as string, pendulum.api)) + ); + if (result.status.type === "error") { + throw new FinancialOperationRejectedError("Could not swap token"); + } + return { hash: result.txHash.toString() }; + }, + provider: Networks.Pendulum, + request: { network: Networks.Pendulum, signedTransaction: presigned.txData }, + signal + }); + state.state = { ...state.state, nablaSwapTxHash: hash }; + await state.update({ state: state.state }); + return state; + } + + private async dryRunEvmSwap( + serializedTransaction: `0x${string}`, + expectedSender: `0x${string}`, + signal?: AbortSignal + ): Promise { + const transaction = parseTransaction(serializedTransaction); + type RecoverParams = Parameters[0]; + const sender = await recoverTransactionAddress({ + serializedTransaction: serializedTransaction as RecoverParams["serializedTransaction"] + }); + if (sender.toLowerCase() !== expectedSender.toLowerCase()) { + throw new Error(`NablaSwapExecutor: sender mismatch. Expected ${expectedSender}, got ${sender}`); + } + if (!transaction.to) throw new Error("NablaSwapExecutor: swap transaction has no recipient"); + const call = { + account: sender, + blockTag: "pending" as const, + data: transaction.data, + gas: transaction.gas, + to: transaction.to, + value: transaction.value + }; + try { + const baseClient = EvmClientManager.getInstance().getClient(Networks.Base); + if (transaction.type === "legacy" || transaction.type === undefined) { + await abortableCall(signal, () => baseClient.call({ ...call, gasPrice: transaction.gasPrice, type: "legacy" })); + } else if (transaction.type === "eip2930") { + await abortableCall(signal, () => + baseClient.call({ + ...call, + accessList: transaction.accessList, + gasPrice: transaction.gasPrice, + type: "eip2930" + }) + ); + } else if (transaction.type === "eip1559") { + await abortableCall(signal, () => + baseClient.call({ + ...call, + accessList: transaction.accessList, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + type: "eip1559" + }) + ); + } else { + throw new Error(`Unsupported transaction type ${transaction.type}`); + } + } catch (error) { + throw this.createRecoverableError(`NablaSwapExecutor: EVM swap dry-run failed: ${error}`); + } + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/nabla-swap/index.ts b/apps/api/src/api/services/phases/blocks/phases/nabla-swap/index.ts new file mode 100644 index 000000000..613243bc7 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/nabla-swap/index.ts @@ -0,0 +1,21 @@ +import type { ChainBrand, Phase, PhaseIO, PrepareCtx, TokenBrand } from "../../core/types"; +import { NablaApproveExecutor, NablaSwapExecutor } from "./execution"; +import { NablaSwapContext, type NablaSwapMetadata, simulateNablaSwap } from "./simulation"; +import { prepareNablaSwapTxs } from "./transactions"; + +export function NablaSwap( + chain: Chain, + inToken: InToken, + outToken: OutToken, + options: { cleanup?: boolean } = {} +): Phase, PhaseIO> { + return { + context: NablaSwapContext, + executors: [new NablaApproveExecutor(), new NablaSwapExecutor()], + name: `NablaSwap(${chain}/${inToken}->${outToken})`, + phases: ["nablaApprove", "nablaSwap"], + prepareTxs: (ctx: PrepareCtx) => + prepareNablaSwapTxs(chain, inToken, outToken, ctx, options.cleanup !== false), + simulate: (input, ctx) => simulateNablaSwap(chain, inToken, outToken, input, ctx) + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/nabla-swap/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/nabla-swap/simulation.ts new file mode 100644 index 000000000..8bdfd9405 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/nabla-swap/simulation.ts @@ -0,0 +1,81 @@ +import { EvmTokenDetails, getOnChainTokenDetails, Networks, RampDirection } from "@vortexfi/shared"; +import { Big } from "big.js"; +import logger from "../../../../../../config/logger"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { evmIO } from "../../core/io"; +import { defineContext, type SerializableBig } from "../../core/metadata"; +import { calculateNablaSwapOutputEvm } from "../../core/nabla"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; + +export interface NablaSwapMetadata { + ammOutputAmountRaw?: string; + effectiveExchangeRate?: string; + inputAmountForSwapDecimal: string; + inputAmountForSwapRaw: string; + inputCurrency: string; + inputCurrencyId?: ReturnType["currencyId"]; + inputDecimals: number; + inputToken: string; + network?: string; + oraclePrice?: SerializableBig; + outputAmountDecimal: SerializableBig; + outputAmountRaw: string; + outputCurrency: string; + outputCurrencyId?: ReturnType["currencyId"]; + outputDecimals: number; + outputToken: string; +} + +export const NablaSwapContext = defineContext()("nablaSwap"); + +export async function simulateNablaSwap( + chain: Chain, + inToken: InToken, + outToken: OutToken, + input: PhaseIO, + ctx: PhaseCtx +): Promise, NablaSwapMetadata>> { + const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inToken) as EvmTokenDetails; + const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outToken) as EvmTokenDetails; + if (!inputTokenDetails || !outputTokenDetails) { + throw new Error("NablaSwap: Could not find EVM token details for the requested tokens"); + } + const inputAmountForSwap = new Big(input.amount).toString(); + const inputAmountForSwapRaw = new Big(inputAmountForSwap).times(new Big(10).pow(inputTokenDetails.decimals)).toFixed(0); + const result = await calculateNablaSwapOutputEvm({ + inputAmountForSwap, + inputTokenDetails, + outputTokenDetails, + rampType: ctx.request.rampType + }); + const oracleCurrency = ctx.request.rampType === RampDirection.BUY ? ctx.request.inputCurrency : ctx.request.outputCurrency; + let oraclePrice: Big | undefined; + try { + oraclePrice = await priceFeedService.getFiatToUsdExchangeRate(oracleCurrency); + } catch (error) { + logger.warn(`NablaSwap: Unable to fetch oracle price for ${oracleCurrency}, proceeding without it. Error: ${error}`); + } + ctx.addNote( + `NablaSwap: ${inputAmountForSwap} ${inToken} -> ${result.nablaOutputAmountDecimal.toFixed()} ${outToken} on ${chain}` + ); + return { + metadata: { + effectiveExchangeRate: result.effectiveExchangeRate, + inputAmountForSwapDecimal: inputAmountForSwap, + inputAmountForSwapRaw, + inputCurrency: inToken, + inputDecimals: inputTokenDetails.decimals, + inputToken: inputTokenDetails.erc20AddressSourceChain, + oraclePrice, + outputAmountDecimal: result.nablaOutputAmountDecimal, + outputAmountRaw: result.nablaOutputAmountRaw, + outputCurrency: outToken, + outputDecimals: outputTokenDetails.decimals, + outputToken: outputTokenDetails.erc20AddressSourceChain + }, + output: { + ...evmIO(outToken, chain, result.nablaOutputAmountDecimal, result.nablaOutputAmountRaw), + requestInputAmountUsd: input.requestInputAmountUsd + } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/nabla-swap/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/nabla-swap/transactions.ts new file mode 100644 index 000000000..153c1861c --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/nabla-swap/transactions.ts @@ -0,0 +1,101 @@ +import { + AMM_MINIMUM_OUTPUT_HARD_MARGIN, + AMM_MINIMUM_OUTPUT_SOFT_MARGIN, + createNablaTransactionsForOnrampOnEVM, + EphemeralAccountType, + EvmNetworks, + EvmToken, + EvmTransactionData, + evmTokenConfig, + getNablaBasePool, + Networks +} from "@vortexfi/shared"; +import Big from "big.js"; +import { config } from "../../../../../../config/vars"; +import { requireAccount } from "../../core/accounts"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { encodeEvmTransactionData, prepareBaseCleanupApproval } from "../../core/evm-transactions"; +import type { ChainBrand, PrepareCtx, PreparedPhaseTxs, TokenBrand } from "../../core/types"; +import type { NablaSwapMetadata } from "./simulation"; + +export interface NablaSwapPreparation { + softMinimumOutputRaw: string; +} + +// The presigned approve+swap the NablaApprove/NablaSwap executors broadcast, plus the cleanup +// approval sweeping leftover swap-output dust. Reads only this phase's own simulated metadata. +export async function prepareNablaSwapTxs( + chain: ChainBrand, + inToken: TokenBrand, + outToken: TokenBrand, + ctx: PrepareCtx, + includeCleanup = true +): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const { ownMetadata } = ctx; + + const inputTokenDetails = evmTokenConfig[chain as EvmNetworks]?.[inToken as EvmToken]; + const outputTokenDetails = evmTokenConfig[chain as EvmNetworks]?.[outToken as EvmToken]; + if (!inputTokenDetails || !outputTokenDetails) { + throw new Error(`prepareNablaSwapTxs: Missing token config for ${inToken} or ${outToken} on ${chain}`); + } + + const inputAmountForNablaSwapRaw = ownMetadata.inputAmountForSwapRaw; + // For offramps, outputAmountRaw may include a partner subsidy; use the AMM-only amount when + // available so the on-chain minimum reflects what the AMM can actually deliver. + const minOutputBaseRaw = ownMetadata.ammOutputAmountRaw ?? ownMetadata.outputAmountRaw; + + const nablaSoftMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN) + .toFixed(0, 0); + const nablaHardMinimumOutputRaw = Big(minOutputBaseRaw) + .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) + .toFixed(0, 0); + + const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( + inputAmountForNablaSwapRaw, + evmEphemeral, + inputTokenDetails.erc20AddressSourceChain, + outputTokenDetails.erc20AddressSourceChain, + nablaHardMinimumOutputRaw, + config.swap.deadlineMinutes, + getNablaBasePool(inputTokenDetails.erc20AddressSourceChain, outputTokenDetails.erc20AddressSourceChain).router + ); + + const cleanupIntent = includeCleanup + ? { + lane: "cleanup" as const, + network: chain as Networks, + phase: "baseCleanupUsdc" as const, + signer: evmEphemeral.address, + txData: encodeEvmTransactionData( + await prepareBaseCleanupApproval( + outputTokenDetails.erc20AddressSourceChain as `0x${string}`, + getEvmFundingAccount(chain as EvmNetworks).address, + chain as EvmNetworks + ) + ) as EvmTransactionData + } + : undefined; + + return { + intents: [ + { + lane: "main", + network: chain as Networks, + phase: "nablaApprove", + signer: evmEphemeral.address, + txData: approve + }, + { + lane: "main", + network: chain as Networks, + phase: "nablaSwap", + signer: evmEphemeral.address, + txData: swap + }, + ...(cleanupIntent ? [cleanupIntent] : []) + ], + state: { softMinimumOutputRaw: nablaSoftMinimumOutputRaw } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-distribute-fees/index.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-distribute-fees/index.ts new file mode 100644 index 000000000..8bf435824 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-distribute-fees/index.ts @@ -0,0 +1,68 @@ +import { AssetHubToken, EvmToken, Networks, PENDULUM_USDC_ASSETHUB } from "@vortexfi/shared"; +import Big from "big.js"; +import type { Phase, PhaseIO } from "../../core/types"; +import { DistributeFeesExecutor } from "../distribute-fees/execution"; +import { DistributeFeesContext } from "../distribute-fees/simulation"; +import { preparePendulumDistributeFeesTxs } from "./transactions"; + +export const PendulumDistributeFees: Phase< + typeof DistributeFeesContext, + PhaseIO, + PhaseIO +> = { + context: DistributeFeesContext, + executors: [new DistributeFeesExecutor()], + name: "PendulumDistributeFees", + phases: ["distributeFees"], + prepareTxs: preparePendulumDistributeFeesTxs, + async simulate(input, ctx) { + if (!ctx.fees?.usd) throw new Error("PendulumDistributeFees: missing USD fees"); + const total = new Big(ctx.fees.usd.network).plus(ctx.fees.usd.vortex).plus(ctx.fees.usd.partnerMarkup); + const amount = input.amount.minus(total); + const amountRaw = amount.times(new Big(10).pow(PENDULUM_USDC_ASSETHUB.decimals)).toFixed(0, 0); + return { + metadata: { + anchorFeeUsd: ctx.fees.usd.anchor, + network: Networks.Pendulum, + networkFeeUsd: ctx.fees.usd.network, + outputCurrencyId: PENDULUM_USDC_ASSETHUB.currencyId, + outputDecimals: PENDULUM_USDC_ASSETHUB.decimals, + partnerMarkupUsd: ctx.fees.usd.partnerMarkup, + totalFeesUsd: total.toString(), + vortexFeeUsd: ctx.fees.usd.vortex + }, + output: { ...input, amount, amountRaw } + }; + } +}; + +export const PendulumAssethubDistributeFees: Phase< + typeof DistributeFeesContext, + PhaseIO, + PhaseIO +> = { + context: DistributeFeesContext, + executors: [new DistributeFeesExecutor()], + name: "PendulumAssethubDistributeFees", + phases: ["distributeFees"], + prepareTxs: preparePendulumDistributeFeesTxs, + async simulate(input, ctx) { + if (!ctx.fees?.usd) throw new Error("PendulumAssethubDistributeFees: missing USD fees"); + const total = new Big(ctx.fees.usd.network).plus(ctx.fees.usd.vortex).plus(ctx.fees.usd.partnerMarkup); + const amount = input.amount.minus(total); + const amountRaw = amount.times(new Big(10).pow(PENDULUM_USDC_ASSETHUB.decimals)).toFixed(0, 0); + return { + metadata: { + anchorFeeUsd: ctx.fees.usd.anchor, + network: Networks.Pendulum, + networkFeeUsd: ctx.fees.usd.network, + outputCurrencyId: PENDULUM_USDC_ASSETHUB.currencyId, + outputDecimals: PENDULUM_USDC_ASSETHUB.decimals, + partnerMarkupUsd: ctx.fees.usd.partnerMarkup, + totalFeesUsd: total.toString(), + vortexFeeUsd: ctx.fees.usd.vortex + }, + output: { ...input, amount, amountRaw } + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-distribute-fees/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-distribute-fees/transactions.ts new file mode 100644 index 000000000..a10f87bd8 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-distribute-fees/transactions.ts @@ -0,0 +1,19 @@ +import { EphemeralAccountType, Networks } from "@vortexfi/shared"; +import type { QuoteTicketAttributes } from "../../../../../../models/quoteTicket.model"; +import { requireAccount } from "../../core/accounts"; +import { createSubstrateFeeDistributionTransaction } from "../../core/fee-distribution"; +import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; +import type { DistributeFeesMetadata } from "../distribute-fees/simulation"; + +export async function preparePendulumDistributeFeesTxs(ctx: PrepareCtx): Promise { + const substrate = requireAccount(ctx.accounts, EphemeralAccountType.Substrate); + const txData = await createSubstrateFeeDistributionTransaction({ + ...ctx.quote, + metadata: { fees: ctx.globals.fees, request: ctx.globals.request } + } as QuoteTicketAttributes); + return { + intents: txData + ? [{ lane: "main", network: Networks.Pendulum, phase: "distributeFees", signer: substrate.address, txData }] + : [] + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-nabla-swap/index.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-nabla-swap/index.ts new file mode 100644 index 000000000..ab383feee --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-nabla-swap/index.ts @@ -0,0 +1,111 @@ +import { + AMM_MINIMUM_OUTPUT_HARD_MARGIN, + AMM_MINIMUM_OUTPUT_SOFT_MARGIN, + createNablaTransactionsForOnramp, + EphemeralAccountType, + EvmToken, + encodeSubmittableExtrinsic, + FiatToken, + getPendulumDetails, + Networks, + PENDULUM_USDC_ASSETHUB +} from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { preparePendulumCleanupTransaction } from "../../../../transactions/pendulum/cleanup"; +import { requireAccount } from "../../core/accounts"; +import { calculateNablaSwapOutput } from "../../core/nabla"; +import type { Phase, PhaseIO } from "../../core/types"; +import { NablaApproveExecutor, NablaSwapExecutor } from "../nabla-swap/execution"; +import { NablaSwapContext } from "../nabla-swap/simulation"; + +export const PendulumNablaSwap: Phase< + typeof NablaSwapContext, + PhaseIO, + PhaseIO +> = { + context: NablaSwapContext, + executors: [new NablaApproveExecutor(), new NablaSwapExecutor()], + name: "PendulumNablaSwap(BRL->USDC)", + phases: ["nablaApprove", "nablaSwap"], + async prepareTxs(ctx) { + const account = requireAccount(ctx.accounts, EphemeralAccountType.Substrate); + const input = getPendulumDetails(FiatToken.BRL); + const output = PENDULUM_USDC_ASSETHUB; + const softMinimumOutputRaw = new Big(ctx.ownMetadata.outputAmountRaw).mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); + const hardMinimumOutputRaw = new Big(ctx.ownMetadata.outputAmountRaw).mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); + const { approve, swap } = await createNablaTransactionsForOnramp( + ctx.ownMetadata.inputAmountForSwapRaw, + account, + input, + output, + hardMinimumOutputRaw + ); + return { + intents: [ + { + lane: "main", + network: Networks.Pendulum, + phase: "nablaApprove", + signer: account.address, + txData: approve.transaction + }, + { + lane: "main", + network: Networks.Pendulum, + phase: "nablaSwap", + signer: account.address, + txData: swap.transaction + }, + { + lane: "cleanup", + network: Networks.Pendulum, + phase: "pendulumCleanup", + signer: account.address, + txData: encodeSubmittableExtrinsic(await preparePendulumCleanupTransaction(input.currencyId, output.currencyId)) + } + ], + state: { + approveExtrinsicOptions: approve.extrinsicOptions, + softMinimumOutputRaw, + swapExtrinsicOptions: swap.extrinsicOptions + } + }; + }, + async simulate(input, ctx) { + const inputDetails = getPendulumDetails(FiatToken.BRL); + const outputDetails = PENDULUM_USDC_ASSETHUB; + const result = await calculateNablaSwapOutput({ + inputAmountForSwap: input.amount.toString(), + inputTokenPendulumDetails: inputDetails, + outputTokenPendulumDetails: outputDetails, + rampType: ctx.request.rampType + }); + const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate(FiatToken.BRL); + return { + metadata: { + effectiveExchangeRate: result.effectiveExchangeRate, + inputAmountForSwapDecimal: input.amount.toString(), + inputAmountForSwapRaw: input.amountRaw, + inputCurrency: inputDetails.currency, + inputCurrencyId: inputDetails.currencyId, + inputDecimals: inputDetails.decimals, + inputToken: inputDetails.erc20WrapperAddress, + network: Networks.Pendulum, + oraclePrice, + outputAmountDecimal: result.nablaOutputAmountDecimal, + outputAmountRaw: result.nablaOutputAmountRaw, + outputCurrency: outputDetails.currency, + outputCurrencyId: outputDetails.currencyId, + outputDecimals: outputDetails.decimals, + outputToken: outputDetails.erc20WrapperAddress + }, + output: { + amount: result.nablaOutputAmountDecimal, + amountRaw: result.nablaOutputAmountRaw, + chain: Networks.Pendulum, + token: EvmToken.USDC + } + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-offramp-nabla-swap/index.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-offramp-nabla-swap/index.ts new file mode 100644 index 000000000..1237c7dd4 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-offramp-nabla-swap/index.ts @@ -0,0 +1,105 @@ +import { + AMM_MINIMUM_OUTPUT_HARD_MARGIN, + AMM_MINIMUM_OUTPUT_SOFT_MARGIN, + AssetHubToken, + createNablaTransactionsForOfframp, + EphemeralAccountType, + encodeSubmittableExtrinsic, + FiatToken, + getPendulumDetails, + Networks, + PENDULUM_USDC_ASSETHUB +} from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { preparePendulumCleanupTransaction } from "../../../../transactions/pendulum/cleanup"; +import { requireAccount } from "../../core/accounts"; +import { calculateNablaSwapOutput } from "../../core/nabla"; +import type { Phase, PhaseIO } from "../../core/types"; +import { NablaApproveExecutor, NablaSwapExecutor } from "../nabla-swap/execution"; +import { NablaSwapContext } from "../nabla-swap/simulation"; + +export const PendulumOfframpNablaSwap: Phase< + typeof NablaSwapContext, + PhaseIO, + PhaseIO +> = { + context: NablaSwapContext, + executors: [new NablaApproveExecutor(), new NablaSwapExecutor()], + name: "PendulumOfframpNablaSwap(USDC->BRL)", + phases: ["nablaApprove", "nablaSwap"], + async prepareTxs(ctx) { + const account = requireAccount(ctx.accounts, EphemeralAccountType.Substrate); + const input = PENDULUM_USDC_ASSETHUB; + const output = getPendulumDetails(FiatToken.BRL); + const softMinimumOutputRaw = new Big(ctx.ownMetadata.outputAmountRaw).mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); + const hardMinimumOutputRaw = new Big(ctx.ownMetadata.outputAmountRaw).mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); + const { approve, swap } = await createNablaTransactionsForOfframp( + ctx.ownMetadata.inputAmountForSwapRaw, + account, + input, + output, + hardMinimumOutputRaw + ); + return { + intents: [ + { + lane: "main", + network: Networks.Pendulum, + phase: "nablaApprove", + signer: account.address, + txData: approve.transaction + }, + { lane: "main", network: Networks.Pendulum, phase: "nablaSwap", signer: account.address, txData: swap.transaction }, + { + lane: "cleanup", + network: Networks.Pendulum, + phase: "pendulumCleanup", + signer: account.address, + txData: encodeSubmittableExtrinsic(await preparePendulumCleanupTransaction(input.currencyId, output.currencyId)) + } + ], + state: { + approveExtrinsicOptions: approve.extrinsicOptions, + softMinimumOutputRaw, + swapExtrinsicOptions: swap.extrinsicOptions + } + }; + }, + async simulate(input, ctx) { + const inputDetails = PENDULUM_USDC_ASSETHUB; + const outputDetails = getPendulumDetails(FiatToken.BRL); + const result = await calculateNablaSwapOutput({ + inputAmountForSwap: input.amount.toString(), + inputTokenPendulumDetails: inputDetails, + outputTokenPendulumDetails: outputDetails, + rampType: ctx.request.rampType + }); + const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate(FiatToken.BRL); + return { + metadata: { + effectiveExchangeRate: result.effectiveExchangeRate, + inputAmountForSwapDecimal: input.amount.toString(), + inputAmountForSwapRaw: input.amountRaw, + inputCurrency: inputDetails.currency, + inputCurrencyId: inputDetails.currencyId, + inputDecimals: inputDetails.decimals, + inputToken: inputDetails.erc20WrapperAddress, + network: Networks.Pendulum, + oraclePrice, + outputAmountDecimal: result.nablaOutputAmountDecimal, + outputAmountRaw: result.nablaOutputAmountRaw, + outputCurrency: outputDetails.currency, + outputCurrencyId: outputDetails.currencyId, + outputDecimals: outputDetails.decimals, + outputToken: outputDetails.erc20WrapperAddress + }, + output: { + amount: result.nablaOutputAmountDecimal, + amountRaw: result.nablaOutputAmountRaw, + chain: Networks.Pendulum, + token: FiatToken.BRL + } + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-offramp-subsidize-post/index.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-offramp-subsidize-post/index.ts new file mode 100644 index 000000000..075daefd6 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-offramp-subsidize-post/index.ts @@ -0,0 +1,75 @@ +import { FiatToken, getPendulumDetails, multiplyByPowerOfTen, Networks } from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { + calculateExpectedOutput, + calculateSubsidyAmount, + getUsdDenominatedInputAmount, + resolveDiscountPartner +} from "../../core/discount"; +import type { Phase, PhaseIO } from "../../core/types"; +import { SubsidizePostSwapExecutor } from "../subsidize-post/execution"; +import { SubsidizePostContext } from "../subsidize-post/simulation"; + +export const PendulumOfframpSubsidizePost: Phase< + typeof SubsidizePostContext, + PhaseIO, + PhaseIO +> = { + context: SubsidizePostContext, + executors: [new SubsidizePostSwapExecutor()], + name: "PendulumOfframpSubsidizePost", + phases: ["subsidizePostSwap"], + async simulate(input, ctx) { + const details = getPendulumDetails(FiatToken.BRL); + const partner = await resolveDiscountPartner(ctx as never, ctx.request.rampType); + const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate(FiatToken.BRL); + const inputAmountUsd = await getUsdDenominatedInputAmount(ctx as never); + if (!inputAmountUsd.eq(ctx.request.inputAmount)) { + ctx.addNote( + `PendulumOfframpSubsidizePost: valued input ${ctx.request.inputAmount} ${ctx.request.inputCurrency} at ${inputAmountUsd.toFixed(6)} USD for discount calculation` + ); + } + const expected = calculateExpectedOutput( + inputAmountUsd.toString(), + oraclePrice, + partner?.targetDiscount ?? 0, + true, + partner + ); + const expectedWithAnchor = expected.expectedOutput.plus(ctx.fees?.displayFiat?.anchor ?? 0); + const subsidyUnrounded = + (partner?.targetDiscount ?? 0) !== 0 + ? calculateSubsidyAmount(expectedWithAnchor, input.amount, partner?.maxSubsidy ?? 0) + : new Big(0); + const subsidy = new Big(subsidyUnrounded.toFixed(6, 0)); + const subsidyRaw = multiplyByPowerOfTen(subsidyUnrounded, details.decimals).toFixed(0, 0); + const target = input.amount.plus(subsidy); + const targetRaw = new Big(input.amountRaw).plus(subsidyRaw).toFixed(0, 0); + const ideal = input.amount.gte(expectedWithAnchor) ? new Big(0) : expectedWithAnchor.minus(input.amount); + return { + metadata: { + actualOutputAmountDecimal: input.amount, + actualOutputAmountRaw: input.amountRaw, + adjustedDifference: expected.adjustedDifference, + adjustedTargetDiscount: expected.adjustedTargetDiscount, + applied: subsidy.gt(0), + expectedOutputAmountDecimal: expectedWithAnchor, + expectedOutputAmountRaw: multiplyByPowerOfTen(expectedWithAnchor, details.decimals).toFixed(0, 0), + idealSubsidyAmountInOutputTokenDecimal: ideal, + idealSubsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(ideal, details.decimals).toFixed(0, 0), + network: Networks.Pendulum, + outputCurrency: FiatToken.BRL, + outputCurrencyId: details.currencyId, + outputDecimals: details.decimals, + partnerId: partner?.id ?? null, + subsidyAmountInOutputTokenDecimal: subsidy, + subsidyAmountInOutputTokenRaw: subsidyRaw, + subsidyRate: expectedWithAnchor.gt(0) ? subsidyUnrounded.div(expectedWithAnchor) : new Big(0), + targetOutputAmountDecimal: target, + targetOutputAmountRaw: targetRaw + }, + output: { ...input, amount: target, amountRaw: targetRaw } + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-offramp-subsidize-pre/index.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-offramp-subsidize-pre/index.ts new file mode 100644 index 000000000..c0a69475b --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-offramp-subsidize-pre/index.ts @@ -0,0 +1,30 @@ +import { AssetHubToken, Networks, PENDULUM_USDC_ASSETHUB } from "@vortexfi/shared"; +import type { Phase, PhaseIO } from "../../core/types"; +import { SubsidizePreSwapExecutor } from "../subsidize-pre/execution"; +import { computeExpectedOutput, SubsidizePreContext } from "../subsidize-pre/simulation"; + +export const PendulumOfframpSubsidizePre: Phase< + typeof SubsidizePreContext, + PhaseIO, + PhaseIO +> = { + context: SubsidizePreContext, + executors: [new SubsidizePreSwapExecutor()], + name: "PendulumOfframpSubsidizePre", + phases: ["subsidizePreSwap"], + async simulate(input, ctx) { + const expected = await computeExpectedOutput(ctx); + return { + metadata: { + expectedOutputAmountDecimal: expected.decimal, + expectedOutputAmountRaw: expected.raw, + inputCurrency: AssetHubToken.USDC, + inputCurrencyId: PENDULUM_USDC_ASSETHUB.currencyId, + inputDecimals: PENDULUM_USDC_ASSETHUB.decimals, + network: Networks.Pendulum, + targetInputAmountRaw: input.amountRaw + }, + output: input + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-subsidize-post/index.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-subsidize-post/index.ts new file mode 100644 index 000000000..c0a1fb33d --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-subsidize-post/index.ts @@ -0,0 +1,33 @@ +import { EvmToken, Networks, PENDULUM_USDC_ASSETHUB } from "@vortexfi/shared"; +import Big from "big.js"; +import type { Phase, PhaseIO } from "../../core/types"; +import { SubsidizePostSwapExecutor } from "../subsidize-post/execution"; +import { SubsidizePostContext } from "../subsidize-post/simulation"; +import { buildFullSubsidy, computeExpectedOutput } from "../subsidize-pre/simulation"; + +export const PendulumSubsidizePost: Phase< + typeof SubsidizePostContext, + PhaseIO, + PhaseIO +> = { + context: SubsidizePostContext, + executors: [new SubsidizePostSwapExecutor()], + name: "PendulumSubsidizePost", + phases: ["subsidizePostSwap"], + async simulate(input, ctx) { + const expected = await computeExpectedOutput(ctx); + const subsidy = buildFullSubsidy(input.amount, input.amountRaw, expected.decimal, expected.raw, ctx); + const amount = input.amount.plus(subsidy.subsidyAmountInOutputTokenDecimal); + const amountRaw = new Big(input.amountRaw).plus(subsidy.subsidyAmountInOutputTokenRaw).toFixed(0, 0); + return { + metadata: { + ...subsidy, + network: Networks.Pendulum, + outputCurrency: EvmToken.USDC, + outputCurrencyId: PENDULUM_USDC_ASSETHUB.currencyId, + outputDecimals: PENDULUM_USDC_ASSETHUB.decimals + }, + output: { ...input, amount, amountRaw } + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-subsidize-pre/index.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-subsidize-pre/index.ts new file mode 100644 index 000000000..04960e926 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-subsidize-pre/index.ts @@ -0,0 +1,29 @@ +import { FiatToken, getPendulumDetails, Networks } from "@vortexfi/shared"; +import type { Phase, PhaseIO } from "../../core/types"; +import { SubsidizePreSwapExecutor } from "../subsidize-pre/execution"; +import { computeExpectedOutput, SubsidizePreContext } from "../subsidize-pre/simulation"; + +export const PendulumSubsidizePre: Phase< + typeof SubsidizePreContext, + PhaseIO, + PhaseIO +> = { + context: SubsidizePreContext, + executors: [new SubsidizePreSwapExecutor()], + name: "PendulumSubsidizePre", + phases: ["subsidizePreSwap"], + async simulate(input, ctx) { + const expected = await computeExpectedOutput(ctx); + return { + metadata: { + expectedOutputAmountDecimal: expected.decimal, + expectedOutputAmountRaw: expected.raw, + inputCurrency: input.token, + inputDecimals: getPendulumDetails(FiatToken.BRL).decimals, + network: Networks.Pendulum, + targetInputAmountRaw: input.amountRaw + }, + output: input + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-to-assethub-xcm/execution.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-to-assethub-xcm/execution.ts new file mode 100644 index 000000000..d0e5d5b3e --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-to-assethub-xcm/execution.ts @@ -0,0 +1,42 @@ +import { ApiManager, decodeSubmittableExtrinsic, getAddressForFormat, RampPhase, submitXTokens } from "@vortexfi/shared"; +import logger from "../../../../../../config/logger"; +import RampState from "../../../../../../models/rampState.model"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; + +export class PendulumToAssethubXcmExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "pendulumToAssethubXcm"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const substrateAddress = state.state.substrateEphemeralAddress; + if (!substrateAddress) throw new Error("PendulumToAssethubXcmExecutor: missing Substrate ephemeral"); + if (state.state.pendulumToAssethubXcmHash) return state; + try { + const pendulum = await ApiManager.getInstance().getApi("pendulum"); + const presigned = this.getPresignedTransaction(state, this.getPhaseName()); + const extrinsic = decodeSubmittableExtrinsic(presigned.txData as string, pendulum.api); + throwIfAborted(signal); + const { hash } = await this.runFinancialOperation(state, { + attemptClass: "pendulum-assethub-xcm-broadcast", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + return abortableCall(signal, () => + submitXTokens(getAddressForFormat(substrateAddress, pendulum.ss58Format), extrinsic) + ); + }, + provider: "pendulum", + request: { network: "pendulum", signedTransaction: presigned.txData }, + signal + }); + state.state = { ...state.state, pendulumToAssethubXcmHash: hash }; + await state.update({ state: state.state }); + return state; + } catch (error) { + logger.error("PendulumToAssethubXcmExecutor failed", error); + throw error; + } + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/pendulum-to-assethub-xcm/index.ts b/apps/api/src/api/services/phases/blocks/phases/pendulum-to-assethub-xcm/index.ts new file mode 100644 index 000000000..97b32344a --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/pendulum-to-assethub-xcm/index.ts @@ -0,0 +1,80 @@ +import { + AssetHubToken, + createPendulumToAssethubTransfer, + EphemeralAccountType, + EvmToken, + encodeSubmittableExtrinsic, + Networks, + PENDULUM_USDC_ASSETHUB, + RampCurrency +} from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { requireAccount } from "../../core/accounts"; +import { defineContext } from "../../core/metadata"; +import type { Phase, PhaseIO } from "../../core/types"; +import { PendulumToAssethubXcmExecutor } from "./execution"; + +export interface PendulumToAssethubXcmMetadata { + inputAmountRaw: string; + outputAmountRaw: string; + outputCurrencyId: typeof PENDULUM_USDC_ASSETHUB.currencyId; +} + +export const PendulumToAssethubXcmContext = defineContext()("pendulumToAssethubXcm"); + +export const PendulumToAssethubXcm: Phase< + typeof PendulumToAssethubXcmContext, + PhaseIO, + PhaseIO +> = { + context: PendulumToAssethubXcmContext, + executors: [new PendulumToAssethubXcmExecutor()], + name: "PendulumToAssethubXcm", + phases: ["pendulumToAssethubXcm"], + async prepareTxs(ctx) { + if (!ctx.destinationAddress) throw new Error("PendulumToAssethubXcm requires destinationAddress"); + const substrate = requireAccount(ctx.accounts, EphemeralAccountType.Substrate); + const tx = await createPendulumToAssethubTransfer( + ctx.destinationAddress, + ctx.ownMetadata.outputCurrencyId, + ctx.ownMetadata.inputAmountRaw + ); + return { + intents: [ + { + lane: "main", + network: Networks.Pendulum, + phase: "pendulumToAssethubXcm", + signer: substrate.address, + txData: encodeSubmittableExtrinsic(tx) + } + ] + }; + }, + async simulate(input, ctx) { + if (!ctx.fees?.usd || !ctx.fees.displayFiat) throw new Error("PendulumToAssethubXcm requires fees"); + const origin = "0.01"; + const destination = "0.018"; + const [originDisplay, destinationDisplay] = await Promise.all([ + priceFeedService.convertCurrency(origin, AssetHubToken.USDC as RampCurrency, ctx.fees.displayFiat.currency), + priceFeedService.convertCurrency(destination, AssetHubToken.USDC as RampCurrency, ctx.fees.displayFiat.currency) + ]); + const extraUsd = new Big(origin).plus(destination); + const extraDisplay = new Big(originDisplay).plus(destinationDisplay); + ctx.fees.usd.network = new Big(ctx.fees.usd.network).plus(extraUsd).toString(); + ctx.fees.usd.total = new Big(ctx.fees.usd.total).plus(extraUsd).toFixed(2); + ctx.fees.displayFiat.network = new Big(ctx.fees.displayFiat.network).plus(extraDisplay).toString(); + ctx.fees.displayFiat.total = new Big(ctx.fees.displayFiat.total).plus(extraDisplay).toFixed(2); + const amount = input.amount.minus(extraUsd); + const amountRaw = amount.times(new Big(10).pow(PENDULUM_USDC_ASSETHUB.decimals)).toFixed(0, 0); + return { + metadata: { + inputAmountRaw: input.amountRaw, + outputAmountRaw: amountRaw, + outputCurrencyId: PENDULUM_USDC_ASSETHUB.currencyId + }, + output: { amount, amountRaw, chain: Networks.AssetHub, token: AssetHubToken.USDC } + }; + } +}; diff --git a/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts new file mode 100644 index 000000000..48979065f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts @@ -0,0 +1,1028 @@ +import { + AxelarScanStatusFees, + AxelarScanStatusResponse, + BalanceCheckError, + BalanceCheckErrorType, + checkEvmBalanceForToken, + classifyGmpStatus, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + evmTokenConfig, + GmpClassification, + getEvmBalance, + getNetworkFromDestination, + getNetworkId, + getOnChainTokenDetails, + getStatus, + getStatusAxelarScan, + isEvmTokenDetails, + isNetworkEVM, + Networks, + nativeToDecimal, + OnChainToken, + RampPhase, + recoverAxelarStuckConfirm, + SquidRouterPayResponse, + sleep +} from "@vortexfi/shared"; +import { Big } from "big.js"; +import { QueryTypes } from "sequelize"; +import { encodeFunctionData, Hash } from "viem"; +import logger from "../../../../../../config/logger"; +import { axelarGasServiceAbi } from "../../../../../../contracts/AxelarGasService"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { SubsidyToken } from "../../../../../../models/subsidy.model"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { SquidRouterDeliveryEvidence, StateMetadata } from "../../../../phases/meta-state-types"; +import { getSquidRouterPayStuckAlertMs, getSquidRouterPayTimeoutMs } from "../../../../phases/phase-processor-config"; +import { SlackNotifier } from "../../../../slack.service"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { FinancialOperationRejectedError } from "../../core/financial-operation"; +import { getBlockMetadata, getBlockState } from "../../core/metadata"; +import { settlementBalanceKey } from "../../core/settlement"; +import { SquidRouterSwapContext } from "./simulation"; + +const AXELAR_POLLING_INTERVAL_MS = 10000; // 10 seconds +const SQUIDROUTER_INITIAL_DELAY_MS = 60000; // 60 seconds +const AXL_GAS_SERVICE_EVM = "0x2d5d7d31F671F86C782533cc367F14109a082712"; +const BALANCE_POLLING_TIME_MS = 10000; +const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; +const AXELAR_CONFIRM_RECOVERY_COOLDOWN_MS = 10 * 60 * 1000; +const STUCK_ALERT_REPEAT_MS = 6 * 60 * 60 * 1000; +const EXTRA_GAS_PENDING_MARKER = "pending"; +const STATUS_REQUEST_TIMEOUT_MS = 30000; +const DESTINATION_BALANCE_FALLBACK_MIN_RATIO_BPS = 9000; + +type TerminalBridgeEvidence = Pick; + +type SquidRouterStatusWithSource = SquidRouterPayResponse & { + evidenceProvider: "axelar" | "squid"; +}; + +// Port of the production SquidRouterPhaseHandler for block-owned bridge and passthrough routes. +export class SquidRouterSwapExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "squidRouterSwap"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + logger.info(`Executing squidRouter phase for ramp ${state.id}`); + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const bridgeMeta = getBlockMetadata(quote.metadata, SquidRouterSwapContext); + + if ( + bridgeMeta.fromNetwork === bridgeMeta.toNetwork && + bridgeMeta.fromToken.toLowerCase() === bridgeMeta.toToken.toLowerCase() + ) { + const evmEphemeralAddress = state.state.evmEphemeralAddress; + if (!evmEphemeralAddress) { + throw new Error("Missing EVM ephemeral address for squidRouter passthrough"); + } + const tokenDetails = getOnChainTokenDetails(bridgeMeta.toNetwork, quote.outputCurrency as OnChainToken); + if (!tokenDetails || !isEvmTokenDetails(tokenDetails)) { + throw new Error(`Could not resolve passthrough token details on ${bridgeMeta.toNetwork}`); + } + const baselineKey = settlementBalanceKey(bridgeMeta.toNetwork, evmEphemeralAddress, tokenDetails.erc20AddressSourceChain); + await state.update({ + state: { + ...state.state, + transactionPlan: { + ...state.state.transactionPlan, + settlementBaselines: { + ...state.state.transactionPlan?.settlementBaselines, + [baselineKey]: "0" + } + } + } + }); + logger.info(`SquidRouterSwapExecutor: Skipping same-chain same-token passthrough for ramp ${state.id}`); + return state; + } + + const evmEphemeralAddress = state.state.evmEphemeralAddress; + if (!evmEphemeralAddress) { + throw new Error("Missing EVM ephemeral address to validate squidRouter input balance"); + } + + const sourceNetwork = bridgeMeta.fromNetwork as EvmNetworks; + const sourceTokenDetails = Object.values(evmTokenConfig[sourceNetwork] || {}).find( + token => token.erc20AddressSourceChain.toLowerCase() === bridgeMeta.fromToken.toLowerCase() + ) as EvmTokenDetails | undefined; + + if (!sourceTokenDetails) { + throw new Error( + `Could not resolve source token details on ${bridgeMeta.fromNetwork} for token ${bridgeMeta.fromToken} in squidRouter phase` + ); + } + + try { + try { + await checkEvmBalanceForToken({ + amountDesiredRaw: bridgeMeta.inputAmountRaw, + chain: sourceNetwork, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + signal, + timeoutMs: 15000, + tokenDetails: sourceTokenDetails + }); + } catch (_error) { + throwIfAborted(signal); + throw this.createRecoverableError( + `Unable to verify squidRouter input balance for ${evmEphemeralAddress} on ${sourceNetwork}; balance may not be settled yet` + ); + } + + const approveTransaction = this.getPresignedTransaction(state, "squidRouterApprove"); + const swapTransaction = this.getPresignedTransaction(state, "squidRouterSwap"); + + if (!approveTransaction || !swapTransaction) { + throw new Error("Missing presigned transactions for squidRouter phase"); + } + + const destinationNetwork = bridgeMeta.toNetwork as EvmNetworks; + const destinationTokenDetails = getOnChainTokenDetails(destinationNetwork, quote.outputCurrency as OnChainToken); + if (!destinationTokenDetails || !isEvmTokenDetails(destinationTokenDetails)) { + throw new Error(`Could not resolve destination token details on ${destinationNetwork}`); + } + const baselineKey = settlementBalanceKey( + destinationNetwork, + evmEphemeralAddress, + destinationTokenDetails.erc20AddressSourceChain + ); + if (state.state.transactionPlan?.settlementBaselines?.[baselineKey] === undefined) { + const baseline = await abortableCall(signal, () => + getEvmBalance({ + chain: destinationNetwork, + ownerAddress: evmEphemeralAddress as `0x${string}`, + tokenDetails: destinationTokenDetails + }) + ); + await state.update({ + state: { + ...state.state, + transactionPlan: { + ...state.state.transactionPlan, + settlementBaselines: { + ...state.state.transactionPlan?.settlementBaselines, + [baselineKey]: baseline.toFixed(0) + } + } + } + }); + } + + let approveHash = state.state.squidRouterApproveHash; + if (!approveHash) { + const accountNonce = await this.getNonce(sourceNetwork, approveTransaction.signer as `0x${string}`, signal); + if (approveTransaction.nonce && approveTransaction.nonce !== accountNonce) { + logger.warn( + `Nonce mismatch for approve transaction of account ${approveTransaction.signer}: expected ${accountNonce}, got ${approveTransaction.nonce}` + ); + } + + approveHash = await this.executeTransaction( + state, + sourceNetwork, + approveTransaction.txData as string, + "approve-broadcast", + signal + ); + logger.info(`Approve transaction executed with hash: ${approveHash}`); + + await state.update({ + state: { + ...state.state, + squidRouterApproveHash: approveHash + } + }); + } + + await this.waitForTransactionConfirmation(sourceNetwork, approveHash, signal); + logger.info(`Approve transaction confirmed: ${approveHash}`); + + let swapHash = state.state.squidRouterSwapHash; + let updatedState = state; + if (!swapHash) { + swapHash = await this.executeTransaction( + state, + sourceNetwork, + swapTransaction.txData as string, + "swap-broadcast", + signal + ); + logger.info(`Swap transaction executed with hash: ${swapHash}`); + + updatedState = await state.update({ + state: { + ...state.state, + squidRouterSwapHash: swapHash + } + }); + } + + await this.waitForTransactionConfirmation(sourceNetwork, swapHash, signal); + logger.info(`Swap transaction confirmed: ${swapHash}`); + + return updatedState; + } catch (error) { + logger.error(`Error in squidRouter phase for ramp ${state.id}:`, error); + throw error; + } + } + + private async executeTransaction( + state: RampState, + network: EvmNetworks, + txData: string, + attemptClass: string, + signal?: AbortSignal + ): Promise { + try { + const publicClient = EvmClientManager.getInstance().getClient(network); + const { hash } = await this.runFinancialOperation(state, { + attemptClass, + externalId: operation => operation.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + publicClient.sendRawTransaction({ + serializedTransaction: txData as `0x${string}` + }) + ); + const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") { + throw new FinancialOperationRejectedError(`Squid Router transaction ${hash} failed`); + } + return { hash }; + }, + provider: network, + request: { network, signedTransaction: txData }, + signal + }); + return hash; + } catch (error) { + logger.error("Error sending raw transaction", error); + if (error instanceof PhaseError) throw error; + throw new Error("Failed to send transaction"); + } + } + + private async waitForTransactionConfirmation(network: EvmNetworks, txHash: string, signal?: AbortSignal): Promise { + const maxRetries = 3; + const baseDelay = 5000; // 5 seconds + const maxDelay = 30000; // 30 seconds + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const publicClient = EvmClientManager.getInstance().getClient(network); + const receipt = await abortableCall(signal, () => + publicClient.waitForTransactionReceipt({ + hash: txHash as `0x${string}` + }) + ); + + if (!receipt || receipt.status !== "success") { + throw new Error(`SquidRouterSwapExecutor: Transaction ${txHash} failed or was not found`); + } + + return; + } catch (error) { + throwIfAborted(signal); + const isLastAttempt = attempt === maxRetries; + const isTransactionNotFoundError = + error instanceof Error && + (error.message.includes("TransactionReceiptNotFoundError") || + error.message.includes("could not be found") || + error.message.includes("Transaction may not be processed")); + + if (isLastAttempt) { + throw new Error( + `SquidRouterSwapExecutor: Error waiting for transaction confirmation after ${maxRetries + 1} attempts: ${error}` + ); + } + + if (isTransactionNotFoundError) { + const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); + + logger.info( + `SquidRouterSwapExecutor: Transaction ${txHash} not found on attempt ${attempt + 1}/${maxRetries + 1}. Retrying in ${delay}ms...` + ); + + await sleep(delay, signal); + } else { + throw this.createRecoverableError(`SquidRouterSwapExecutor: Error waiting for transaction confirmation: ${error}`); + } + } + } + } + + private async getNonce(network: EvmNetworks, address: `0x${string}`, signal?: AbortSignal): Promise { + try { + const publicClient = EvmClientManager.getInstance().getClient(network); + return await abortableCall(signal, () => publicClient.getTransactionCount({ address })); + } catch (error) { + logger.error("Error getting nonce", error); + throw this.createRecoverableError("Failed to get transaction nonce"); + } + } +} + +// Port of the production SquidRouterPayPhaseHandler with a single network-generic Axelar gas +// funding method instead of one per chain. Clients are created lazily (no work at import time). +export class SquidRouterPayExecutor extends BasePhaseHandler { + // Instance fields allow focused tests to avoid production polling delays. + private initialDelayMs = SQUIDROUTER_INITIAL_DELAY_MS; + private pollIntervalMs = AXELAR_POLLING_INTERVAL_MS; + private stuckAlertThresholdMs?: number; + private slackNotifier?: SlackNotifier | null; + + public getPhaseName(): RampPhase { + return "squidRouterPay"; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + logger.info(`Executing squidRouterPay phase for ramp ${state.id}`); + + try { + const bridgeCallHash = state.state.squidRouterSwapHash; + if (!bridgeCallHash) { + throw new Error("SquidRouterPayExecutor: Missing bridge hash in state for squidRouterPay phase. State corrupted."); + } + + await this.checkStatus(state, bridgeCallHash, quote, signal); + + return state; + } catch (error: unknown) { + logger.error(`SquidRouterPayExecutor: Error in squidRouterPay phase for ramp ${state.id}:`, error); + throw error; + } + } + + // Prefer authoritative provider-terminal status. An exact route-scoped destination + // balance delta remains a fallback because provider indexing can miss a real arrival. + // Whichever succeeds is persisted so downstream subsidy logic cannot silently change + // the meaning of "bridge complete". + private async checkStatus(state: RampState, swapHash: string, quote: QuoteTicket, signal?: AbortSignal): Promise { + const toChain = this.resolveBridgeToChain(quote); + const pollingTimeoutMs = getSquidRouterPayTimeoutMs(); + const bridgeMeta = getBlockMetadata(quote.metadata, SquidRouterSwapContext); + + if (!toChain || !isNetworkEVM(toChain)) { + logger.info("SquidRouterPayExecutor: Destination network is non-EVM; skipping EVM balance check optimization.", { + toNetwork: quote.to + }); + const terminal = await this.checkBridgeStatus(state, swapHash, quote, pollingTimeoutMs, signal); + await this.persistDeliveryEvidence(state, { + ...terminal, + destinationNetwork: bridgeMeta.toNetwork, + destinationToken: bridgeMeta.toToken, + expectedAmountRaw: bridgeMeta.outputAmountRaw, + sourceTransactionHash: swapHash + }); + return; + } + + const competingCheckController = new AbortController(); + const competingSignal = signal + ? AbortSignal.any([signal, competingCheckController.signal]) + : competingCheckController.signal; + let balanceCheckPromise: Promise; + + try { + const outTokenDetails = getOnChainTokenDetails(toChain, quote.outputCurrency as OnChainToken) as EvmTokenDetails; + const ephemeralAddress = state.state.evmEphemeralAddress; + + if (outTokenDetails && ephemeralAddress) { + const baselineKey = settlementBalanceKey(toChain, ephemeralAddress, outTokenDetails.erc20AddressSourceChain); + const baselineRaw = state.state.transactionPlan?.settlementBaselines?.[baselineKey]; + if (baselineRaw === undefined) { + throw new Error(`Missing destination settlement baseline ${baselineKey}`); + } + const minimumDeliveryRaw = new Big(bridgeMeta.outputAmountRaw) + .mul(DESTINATION_BALANCE_FALLBACK_MIN_RATIO_BPS) + .div(10_000) + .toFixed(0, 0); + balanceCheckPromise = checkEvmBalanceForToken({ + amountDesiredRaw: new Big(baselineRaw).plus(minimumDeliveryRaw).toFixed(0), + chain: toChain, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: ephemeralAddress, + signal: competingSignal, + timeoutMs: pollingTimeoutMs, + tokenDetails: outTokenDetails + }).then(observedBalance => ({ + baselineRaw, + destinationNetwork: bridgeMeta.toNetwork, + destinationToken: bridgeMeta.toToken, + expectedAmountRaw: bridgeMeta.outputAmountRaw, + kind: "destination-balance", + minimumRatioBps: DESTINATION_BALANCE_FALLBACK_MIN_RATIO_BPS, + observedAt: new Date().toISOString(), + observedBalanceRaw: observedBalance.toFixed(0), + sourceTransactionHash: swapHash + })); + } else { + logger.warn( + "SquidRouterPayExecutor: Cannot perform balance check optimization (missing expected token details or address)." + ); + balanceCheckPromise = Promise.reject(new Error("Skipped balance check")); + } + } catch (err) { + logger.warn(`SquidRouterPayExecutor: Error preparing balance check: ${err}`); + balanceCheckPromise = Promise.reject(err); + } + + const bridgeCheckPromise = this.checkBridgeStatus(state, swapHash, quote, pollingTimeoutMs, competingSignal).then( + terminal => ({ + ...terminal, + destinationNetwork: bridgeMeta.toNetwork, + destinationToken: bridgeMeta.toToken, + expectedAmountRaw: bridgeMeta.outputAmountRaw, + sourceTransactionHash: swapHash + }) + ); + + try { + const evidence = await Promise.any([bridgeCheckPromise, balanceCheckPromise]); + competingCheckController.abort(new Error("Alternative Squid completion check succeeded")); + await this.persistDeliveryEvidence(state, evidence); + } catch (error) { + if (error instanceof AggregateError) { + const balanceError = error.errors.find(e => e instanceof BalanceCheckError); + const bridgeError = error.errors.find(e => !(e instanceof BalanceCheckError)); + + let errorMessage = "SquidRouterPayExecutor: Both bridge status check and balance check failed."; + + if (balanceError instanceof BalanceCheckError) { + if (balanceError.type === BalanceCheckErrorType.Timeout) { + errorMessage += ` Balance check timed out after ${pollingTimeoutMs}ms.`; + } else if (balanceError.type === BalanceCheckErrorType.ReadFailure) { + errorMessage += ` Balance check read failure (unexpected infrastructure issue): ${balanceError.message}.`; + } + } + + if (bridgeError) { + errorMessage += ` Bridge check error: ${bridgeError instanceof Error ? bridgeError.message : String(bridgeError)}.`; + } + + throw this.createRecoverableError(errorMessage); + } + throw error; + } finally { + competingCheckController.abort(new Error("Squid completion check finished")); + } + } + + private async checkBridgeStatus( + state: RampState, + swapHash: string, + quote: QuoteTicket, + timeoutMs = getSquidRouterPayTimeoutMs(), + signal?: AbortSignal + ): Promise { + let payTxHash: string | undefined = state.state.squidRouterPayTxHash; + const timeoutAt = Date.now() + timeoutMs; + + await sleep(Math.min(this.initialDelayMs, timeoutMs), signal); + + while (true) { + if (Date.now() >= timeoutAt) { + throw this.createRecoverableError(`SquidRouterPayExecutor: Bridge status check timed out after ${timeoutMs}ms`); + } + + let fundedThisIteration = false; + let lastAxelarScanStatus: AxelarScanStatusResponse | undefined; + let recoveryOutcome: string | undefined; + + try { + const squidRouterStatus = await this.getSquidrouterStatus(swapHash, state, quote, signal); + + if (!squidRouterStatus) { + logger.warn(`SquidRouterPayExecutor: No squidRouter status found for swap hash ${swapHash}.`); + } else if (squidRouterStatus.status === "success") { + logger.info(`SquidRouterPayExecutor: Transaction ${swapHash} successfully executed on Squidrouter.`); + return { + kind: "provider-terminal", + observedAt: new Date().toISOString(), + provider: squidRouterStatus.evidenceProvider, + providerStatus: "success" + }; + } + + const isGmp = squidRouterStatus ? squidRouterStatus.isGMPTransaction : true; + + if (isGmp) { + const axelarScanStatus = await getStatusAxelarScan(swapHash, this.statusRequestSignal(signal)); + lastAxelarScanStatus = axelarScanStatus ?? undefined; + + if (!axelarScanStatus) { + logger.info(`SquidRouterPayExecutor: Axelar status not found yet for hash ${swapHash}.`); + } else if (axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed") { + logger.info(`SquidRouterPayExecutor: Transaction ${swapHash} successfully executed on Axelar.`); + return { + kind: "provider-terminal", + observedAt: new Date().toISOString(), + provider: "axelar", + providerStatus: axelarScanStatus.status + }; + } else if (!payTxHash) { + logger.info("SquidRouterPayExecutor: Bridge transaction detected on Axelar. Proceeding to fund gas."); + fundedThisIteration = true; + + const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); + const logIndex = Number(axelarScanStatus.id.split("_")[2]); + + const fromChain = getBlockMetadata(quote.metadata, SquidRouterSwapContext).fromNetwork as EvmNetworks; + + payTxHash = await this.executeFundTransaction( + state, + fromChain, + nativeToFundRaw, + swapHash as `0x${string}`, + logIndex, + "initial-gas-payment", + signal + ); + + const subsidyToken = fromChain === Networks.Polygon ? SubsidyToken.MATIC : SubsidyToken.ETH; + const payerAccount = getEvmFundingAccount(fromChain).address; + const subsidyAmount = nativeToDecimal(nativeToFundRaw, 18).toNumber(); + + await this.createSubsidy(state, subsidyAmount, subsidyToken, payerAccount, payTxHash); + + await this.patchStateKey(state, "squidRouterPayTxHash", payTxHash); + } else if (axelarScanStatus.status === "called" && axelarScanStatus.confirm_failed) { + recoveryOutcome = await this.maybeRecoverStuckConfirm(state, swapHash, axelarScanStatus.call?.chain, signal); + } + + if (!fundedThisIteration) { + await this.monitorStuckGmp(state, swapHash, quote, axelarScanStatus ?? undefined, signal, { recoveryOutcome }); + } + } else { + logger.info("SquidRouterPayExecutor: Same-chain transaction detected. Skipping Axelar check."); + } + } catch (error) { + await this.monitorStuckGmp(state, swapHash, quote, lastAxelarScanStatus, signal, { lastError: error, recoveryOutcome }); + throw this.createRecoverableError( + `SquidRouterPayExecutor: Failed to check bridge status for ${swapHash}, error: ${error instanceof Error ? error.message : String(error)}` + ); + } + + await sleep(this.pollIntervalMs, signal); + } + } + + private statusRequestSignal(signal?: AbortSignal): AbortSignal { + const timeoutSignal = AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + } + + private async patchStateKey( + state: RampState, + key: K, + value: StateMetadata[K], + guardSql = "TRUE", + guardReplacements: Record = {} + ): Promise { + const sequelizeInstance = RampState.sequelize; + if (!sequelizeInstance) { + throw new Error("SquidRouterPayExecutor: RampState model is not attached to a sequelize instance"); + } + const [, affectedRows] = await sequelizeInstance.query( + `UPDATE ramp_states SET state = jsonb_set(state, '{${key}}', :patchValue::jsonb), updated_at = NOW() WHERE id = :rampId AND (${guardSql})`, + { + replacements: { patchValue: JSON.stringify(value), rampId: state.id, ...guardReplacements }, + type: QueryTypes.UPDATE + } + ); + const updatedRows = typeof affectedRows === "number" ? affectedRows : 0; + if (updatedRows > 0) { + state.state = { ...state.state, [key]: value }; + } + return updatedRows; + } + + private async persistDeliveryEvidence(state: RampState, evidence: SquidRouterDeliveryEvidence): Promise { + await this.patchStateKey(state, "squidRouterDeliveryEvidence", evidence); + logger.info("SQUIDROUTER_DELIVERY_EVIDENCE", { + destinationNetwork: evidence.destinationNetwork, + expectedAmountRaw: evidence.expectedAmountRaw, + kind: evidence.kind, + minimumRatioBps: evidence.minimumRatioBps, + provider: evidence.provider, + rampId: state.id, + sourceTransactionHash: evidence.sourceTransactionHash + }); + } + + private async maybeRecoverStuckConfirm( + state: RampState, + swapHash: string, + sourceChain: string | undefined, + signal?: AbortSignal + ): Promise { + const parsedLastAttempt = state.state.axelarConfirmRecoveryAt ? new Date(state.state.axelarConfirmRecoveryAt).getTime() : 0; + const lastAttempt = Number.isFinite(parsedLastAttempt) ? parsedLastAttempt : 0; + if (Date.now() - lastAttempt < AXELAR_CONFIRM_RECOVERY_COOLDOWN_MS) { + return `confirm recovery on cooldown (last attempt ${new Date(lastAttempt).toISOString()})`; + } + + if (!sourceChain) { + logger.warn( + `SquidRouterPayExecutor: Confirm poll failed for ${swapHash} but Axelar status has no source chain; cannot attempt recovery.` + ); + return "confirm recovery unavailable: Axelar status has no source chain"; + } + + await this.patchStateKey(state, "axelarConfirmRecoveryAt", new Date().toISOString()); + + try { + const axelarTxHash = await recoverAxelarStuckConfirm(swapHash, sourceChain, signal); + logger.info( + `SquidRouterPayExecutor: Confirm poll failed for ${swapHash}; broadcast recovery ConfirmGatewayTx ${axelarTxHash} on Axelar.` + ); + return `broadcast recovery ConfirmGatewayTx ${axelarTxHash} on Axelar`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`SquidRouterPayExecutor: Axelar stuck-confirm recovery attempt failed for ${swapHash}: ${message}`); + return `confirm recovery attempt failed: ${message}`; + } + } + + private getElapsedInPhaseMs(state: RampState): number { + const entry = [...(state.phaseHistory ?? [])].reverse().find(e => e.phase === "squidRouterPay"); + const startIso = entry?.timestamp ?? state.createdAt; + const start = startIso ? new Date(startIso).getTime() : Number.NaN; + return Number.isFinite(start) ? Date.now() - start : 0; + } + + private async monitorStuckGmp( + state: RampState, + swapHash: string, + quote: QuoteTicket, + axelarScanStatus: AxelarScanStatusResponse | undefined, + signal?: AbortSignal, + context: { lastError?: unknown; recoveryOutcome?: string } = {} + ): Promise { + try { + if (signal?.aborted) { + return; + } + + const elapsedMs = this.getElapsedInPhaseMs(state); + if (elapsedMs < (this.stuckAlertThresholdMs ?? getSquidRouterPayStuckAlertMs())) { + return; + } + + const classification = classifyGmpStatus(axelarScanStatus); + if (classification === "executed") { + return; + } + + let actionTaken = "none"; + if (classification === "insufficient_gas") { + actionTaken = await this.maybeTopUpGas(state, swapHash, quote, axelarScanStatus, signal); + } else if (classification === "waiting_source_confirmation" || classification === "source_confirmation_stuck") { + actionTaken = + context.recoveryOutcome ?? + (await this.maybeRecoverStuckConfirm(state, swapHash, axelarScanStatus?.call?.chain, signal)); + } + + await this.alertStuckGmp( + state, + swapHash, + quote, + classification, + axelarScanStatus, + elapsedMs, + actionTaken, + context.lastError + ); + } catch (error) { + logger.warn( + `SquidRouterPayExecutor: Stuck-GMP monitor failed for ramp ${state.id}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private async maybeTopUpGas( + state: RampState, + swapHash: string, + quote: QuoteTicket, + axelarScanStatus: AxelarScanStatusResponse | undefined, + signal?: AbortSignal + ): Promise { + if (!state.state.squidRouterPayTxHash) { + return "initial gas payment still pending; regular funding flow will pay"; + } + if (state.state.squidRouterExtraGasTxHash === EXTRA_GAS_PENDING_MARKER) { + return "gas top-up previously attempted with unknown outcome; not retrying; check the funding wallet's transactions manually"; + } + if (state.state.squidRouterExtraGasTxHash) { + return `gas top-up already sent (${state.state.squidRouterExtraGasTxHash}); not topping up again`; + } + if (!axelarScanStatus?.fees) { + return "cannot top up gas: Axelar status has no fee data"; + } + const logIndex = Number(axelarScanStatus.id?.split("_")[2]); + if (!Number.isFinite(logIndex)) { + return `cannot top up gas: malformed Axelar status id "${axelarScanStatus.id}"`; + } + if (signal?.aborted) { + return "execution aborted before gas top-up; not sending"; + } + + const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); + const claimedRows = await this.patchStateKey( + state, + "squidRouterExtraGasTxHash", + EXTRA_GAS_PENDING_MARKER, + `state->>'squidRouterExtraGasTxHash' IS NULL` + ); + if (claimedRows === 0) { + return "gas top-up already claimed by a concurrent execution; not sending"; + } + + const fromChain = getBlockMetadata(quote.metadata, SquidRouterSwapContext).fromNetwork as EvmNetworks; + const extraGasTxHash = await this.executeFundTransaction( + state, + fromChain, + nativeToFundRaw, + swapHash as `0x${string}`, + logIndex, + "supplemental-gas-payment", + signal + ); + await this.patchStateKey(state, "squidRouterExtraGasTxHash", extraGasTxHash); + + logger.warn( + `SQUIDROUTER_EXTRA_GAS_PAID: supplemental Axelar gas top-up sent. ramp=${state.id} amountRaw=${nativeToFundRaw} tx=${extraGasTxHash}` + ); + return `sent one-time gas top-up ${extraGasTxHash} (${nativeToDecimal(nativeToFundRaw, 18).toNumber()} native units)`; + } + + private async alertStuckGmp( + state: RampState, + swapHash: string, + quote: QuoteTicket, + classification: GmpClassification, + axelarScanStatus: AxelarScanStatusResponse | undefined, + elapsedMs: number, + actionTaken: string, + lastError?: unknown + ): Promise { + const previousAlertAt = state.state.squidRouterStuckAlertedAt; + const parsedLastAlert = previousAlertAt ? new Date(previousAlertAt).getTime() : 0; + const lastAlert = Number.isFinite(parsedLastAlert) ? parsedLastAlert : 0; + if (Date.now() - lastAlert < STUCK_ALERT_REPEAT_MS) { + return; + } + + const claimedRows = previousAlertAt + ? await this.patchStateKey( + state, + "squidRouterStuckAlertedAt", + new Date().toISOString(), + `state->>'squidRouterStuckAlertedAt' = :previousAlertAt`, + { previousAlertAt } + ) + : await this.patchStateKey( + state, + "squidRouterStuckAlertedAt", + new Date().toISOString(), + `state->>'squidRouterStuckAlertedAt' IS NULL` + ); + if (claimedRows === 0) { + return; + } + + const guidanceByClassification: Record = { + executed: "", + execution_failed: "destination execution failed; external; retry the execution manually from the Axelarscan page", + insufficient_gas: "Vortex-actionable: Axelar reports the paid gas as insufficient", + relayer_pending: + "gas paid and call approved; likely external Axelar/Squid relayer latency; manual execute possible on Axelarscan", + source_confirmation_stuck: "validator confirm poll failed; auto-recovery attempted; external if it persists", + unknown: "status unavailable or not indexed; possible Squid/Axelarscan API outage; check the Axelarscan link manually", + waiting_source_confirmation: "waiting for Axelar source confirmation; auto-recovery attempted; external if it persists" + }; + + const lastErrorLog = state.errorLogs?.[state.errorLogs.length - 1]; + const lastErrorText = + lastError instanceof Error ? lastError.message : lastError ? String(lastError) : (lastErrorLog?.error ?? "none"); + const squidRouterQuoteId = getBlockState<{ quoteId: string }>(state.state, SquidRouterSwapContext).quoteId; + + const text = [ + `squidRouterPay stuck for ${Math.round(elapsedMs / 60000)} minutes`, + `- ramp: ${state.id}`, + `- classification: ${classification} (${guidanceByClassification[classification]})`, + `- axelar status: ${axelarScanStatus?.status ?? "unavailable"} (confirm_failed=${axelarScanStatus?.confirm_failed ?? "n/a"}, is_insufficient_fee=${axelarScanStatus?.is_insufficient_fee ?? "n/a"}, gas_status=${axelarScanStatus?.gas_status ?? "n/a"})`, + `- source tx: ${swapHash}`, + `- squid quote id: ${squidRouterQuoteId}`, + `- axelarscan: https://axelarscan.io/gmp/${swapHash}`, + `- gas payment tx: ${state.state.squidRouterPayTxHash ?? "none"}`, + `- action taken: ${actionTaken}`, + `- last error: ${lastErrorText}` + ].join("\n"); + + logger.warn(`SQUIDROUTER_PAY_STUCK: ${text}`); + + const notifier = this.getSlackNotifier(); + if (notifier) { + try { + await notifier.sendMessage({ text }); + } catch (error) { + logger.warn( + `SquidRouterPayExecutor: Failed to send stuck-GMP Slack alert for ramp ${state.id}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + private getSlackNotifier(): SlackNotifier | null { + if (this.slackNotifier === undefined) { + try { + this.slackNotifier = new SlackNotifier(); + } catch { + logger.warn( + "SquidRouterPayExecutor: Slack notifier unavailable (SLACK_WEB_HOOK_TOKEN not set); stuck-GMP alerts will only be logged." + ); + this.slackNotifier = null; + } + } + return this.slackNotifier; + } + + private async executeFundTransaction( + state: RampState, + fromChain: EvmNetworks, + tokenValueRaw: string, + swapHash: `0x${string}`, + logIndex: number, + attemptClass: string, + signal?: AbortSignal + ): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const fundingAccount = getEvmFundingAccount(fromChain); + const walletClient = evmClientManager.getWalletClient(fromChain, fundingAccount); + const publicClient = evmClientManager.getClient(fromChain); + + const walletClientAccount = walletClient.account; + if (!walletClientAccount) { + throw new Error(`SquidRouterPayExecutor: ${fromChain} wallet client account not found.`); + } + + const transactionData = encodeFunctionData({ + abi: axelarGasServiceAbi, + args: [swapHash, logIndex, walletClientAccount.address], + functionName: "addNativeGas" + }); + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const nonce = await publicClient.getTransactionCount({ address: walletClientAccount.address, blockTag: "pending" }); + const { hash: gasPaymentHash } = await this.runFinancialOperation(state, { + attemptClass, + externalId: operation => operation.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + walletClient.sendTransaction({ + account: walletClientAccount, + chain: publicClient.chain, + data: transactionData, + maxFeePerGas: fromChain === Networks.Polygon ? maxFeePerGas : maxFeePerGas * 2n, + maxPriorityFeePerGas: fromChain === Networks.Polygon ? maxPriorityFeePerGas : maxPriorityFeePerGas * 2n, + nonce, + to: AXL_GAS_SERVICE_EVM as `0x${string}`, + value: BigInt(tokenValueRaw) + }) + ); + const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") { + throw new FinancialOperationRejectedError(`Axelar gas payment ${hash} failed`); + } + return { hash }; + }, + provider: fromChain, + request: { amountRaw: tokenValueRaw, logIndex, network: fromChain, nonce, swapHash }, + signal + }); + + logger.info(`SquidRouterPayExecutor: ${fromChain} fund transaction sent with hash: ${gasPaymentHash}`); + return gasPaymentHash; + } catch (error) { + logger.error(`SquidRouterPayExecutor: Error funding gas to Axelar gas service on ${fromChain}: `, error); + if (error instanceof PhaseError) throw error; + throw new Error(`SquidRouterPayExecutor: Failed to send ${fromChain} transaction`); + } + } + + private async getSquidrouterStatus( + swapHash: string, + state: RampState, + quote: QuoteTicket, + signal?: AbortSignal + ): Promise { + try { + const fromChain = getBlockMetadata(quote.metadata, SquidRouterSwapContext).fromNetwork; + const fromChainId = getNetworkId(fromChain)?.toString(); + // Axelar routes through Moonbeam for AssetHub destinations, so the Squid status API + // expects Moonbeam's chain id when the destination is AssetHub. + const resolvedToChain = this.resolveBridgeToChain(quote); + const toChain = resolvedToChain === Networks.AssetHub ? Networks.Moonbeam : resolvedToChain; + const toChainId = toChain ? getNetworkId(toChain)?.toString() : undefined; + + if (!fromChainId || !toChainId) { + throw new Error("SquidRouterPayExecutor: Invalid from or to network for Squidrouter status check"); + } + + const squidRouterQuoteId = getBlockState<{ quoteId: string }>(state.state, SquidRouterSwapContext).quoteId; + const squidRouterStatus = await getStatus( + swapHash, + fromChainId, + toChainId, + squidRouterQuoteId, + this.statusRequestSignal(signal) + ); + return { ...squidRouterStatus, evidenceProvider: "squid" }; + } catch (squidRouterError) { + logger.warn( + `SquidRouterPayExecutor: SquidRouter status check failed for swap hash ${swapHash}, attempting Axelar fallback: ${squidRouterError instanceof Error ? squidRouterError.message : String(squidRouterError)}` + ); + + try { + const axelarScanStatus = await getStatusAxelarScan(swapHash, this.statusRequestSignal(signal)); + + if (!axelarScanStatus) { + throw new Error( + `SquidRouterPayExecutor: Axelar scan status not found for swap hash ${swapHash} during fallback attempt.` + ); + } + + const mappedStatus = + axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed" + ? "success" + : axelarScanStatus.status; + + return { + evidenceProvider: "axelar", + id: "", + isGMPTransaction: true, + routeStatus: [], + squidTransactionStatus: "", + status: mappedStatus + } as SquidRouterStatusWithSource; + } catch (axelarError) { + logger.error( + `SquidRouterPayExecutor: Both SquidRouter and Axelar fallback failed for swap hash ${swapHash}. Axelar fallback error: ${axelarError instanceof Error ? axelarError.message : String(axelarError)}` + ); + throw new Error(`SquidRouterPayExecutor: Failed to fetch Squidrouter status for swap hash ${swapHash}`); + } + } + } + + // For onramps, quote.to is the EVM network the bridge delivers to. For offramps to a payment + // method, fall back to the bridge metadata recorded at quote time. + private resolveBridgeToChain(quote: QuoteTicket): Networks | undefined { + const directNetwork = getNetworkFromDestination(quote.to); + if (directNetwork) { + return directNetwork; + } + return getBlockMetadata(quote.metadata, SquidRouterSwapContext).toNetwork; + } + + private calculateGasFeeInUnits(feeResponse: AxelarScanStatusFees, estimatedGas: string | number): string { + const baseFeeInUnitsBig = Big(feeResponse.source_base_fee); + + // Execution fee: cost to execute the transaction on the destination chain. + const estimatedGasBig = Big(estimatedGas); + const sourceGasPriceBig = Big(feeResponse.source_token.gas_price); + + const executionFeeUnits = estimatedGasBig.mul(sourceGasPriceBig); + const multiplier = feeResponse.execute_gas_multiplier; + const executionFeeWithMultiplier = executionFeeUnits.mul(multiplier); + + const totalGasFee = baseFeeInUnitsBig.add(executionFeeWithMultiplier); + + const sourceDecimals = feeResponse.source_token.gas_price_in_units.decimals; + const totalGasFeeRaw = totalGasFee.mul(Big(10).pow(sourceDecimals)); + + return totalGasFeeRaw.lt(0) ? "0" : totalGasFeeRaw.toFixed(0, 0); + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/index.ts b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/index.ts new file mode 100644 index 000000000..a45e5e3cb --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/index.ts @@ -0,0 +1,60 @@ +import type { ChainBrand, Phase, PhaseIO, PrepareCtx, TokenBrand } from "../../core/types"; +import { SquidRouterPayExecutor, SquidRouterSwapExecutor } from "./execution"; +import { + SquidRouterSwapContext, + type SquidRouterSwapMetadata, + simulateSquidRouterPassthrough, + simulateSquidRouterSwap +} from "./simulation"; +import { prepareSameChainSquidRouterSwapTxs, prepareSquidRouterSwapTxs } from "./transactions"; + +export function SquidRouterSwap< + FromChain extends ChainBrand, + ToChain extends ChainBrand, + FromToken extends TokenBrand, + ToToken extends TokenBrand +>( + fromChain: FromChain, + toChain: ToChain, + fromToken: FromToken, + toToken: ToToken +): Phase, PhaseIO> { + return { + context: SquidRouterSwapContext, + executors: [new SquidRouterSwapExecutor(), new SquidRouterPayExecutor()], + name: `SquidRouterSwap(${fromChain}/${fromToken}->${toChain}/${toToken})`, + phases: ["squidRouterSwap", "squidRouterPay"], + prepareTxs: (ctx: PrepareCtx) => + prepareSquidRouterSwapTxs(fromChain, toChain, fromToken, toToken, ctx), + simulate: (input, ctx) => simulateSquidRouterSwap(fromChain, toChain, fromToken, toToken, input, ctx) + }; +} + +export function SquidRouterPassthrough( + token: Token, + chain: Chain +): Phase, PhaseIO> { + return { + context: SquidRouterSwapContext, + executors: [new SquidRouterSwapExecutor()], + name: `SquidRouterPassthrough(${chain}/${token})`, + phases: ["squidRouterSwap"], + simulate: (input, ctx) => simulateSquidRouterPassthrough(token, chain, input, ctx) + }; +} + +export function SameChainSquidRouterSwap( + chain: Chain, + fromToken: FromToken, + toToken: ToToken +): Phase, PhaseIO> { + return { + context: SquidRouterSwapContext, + executors: [new SquidRouterSwapExecutor()], + name: `SameChainSquidRouterSwap(${chain}/${fromToken}->${toToken})`, + phases: ["squidRouterSwap"], + prepareTxs: (ctx: PrepareCtx) => + prepareSameChainSquidRouterSwapTxs(chain, chain, fromToken, toToken, ctx), + simulate: (input, ctx) => simulateSquidRouterSwap(chain, chain, fromToken, toToken, input, ctx) + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/simulation.ts new file mode 100644 index 000000000..a5abb6083 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/simulation.ts @@ -0,0 +1,97 @@ +import { EvmTokenDetails, getOnChainTokenDetails, Networks, OnChainToken } from "@vortexfi/shared"; +import { Big } from "big.js"; +import { evmIO } from "../../core/io"; +import { defineContext, type SerializableBig } from "../../core/metadata"; +import { calculateEvmBridgeAndNetworkFee, getBridgeTargetTokenDetails } from "../../core/squidrouter"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; + +export interface SquidRouterSwapMetadata { + effectiveExchangeRate?: string; + fromNetwork: Networks; + fromToken: string; + inputAmountDecimal: SerializableBig; + inputAmountRaw: string; + networkFeeUSD: string; + outputAmountDecimal: SerializableBig; + outputAmountRaw: string; + toNetwork: Networks; + toToken: string; +} + +export const SquidRouterSwapContext = defineContext()("squidRouterSwap"); + +export async function simulateSquidRouterPassthrough( + token: Token, + chain: Chain, + input: PhaseIO, + ctx: PhaseCtx +): Promise, SquidRouterSwapMetadata>> { + const tokenDetails = getOnChainTokenDetails(chain as Networks, token as OnChainToken) as EvmTokenDetails; + ctx.addNote(`SquidRouterSwap: passthrough ${input.amount.toFixed()} ${token} on ${chain}`); + return { + metadata: { + effectiveExchangeRate: "1", + fromNetwork: chain as Networks, + fromToken: tokenDetails.erc20AddressSourceChain, + inputAmountDecimal: input.amount, + inputAmountRaw: input.amountRaw, + networkFeeUSD: "0", + outputAmountDecimal: input.amount, + outputAmountRaw: input.amountRaw, + toNetwork: chain as Networks, + toToken: tokenDetails.erc20AddressSourceChain + }, + output: input + }; +} + +export async function simulateSquidRouterSwap< + FromChain extends ChainBrand, + ToChain extends ChainBrand, + FromToken extends TokenBrand, + ToToken extends TokenBrand +>( + fromChain: FromChain, + toChain: ToChain, + fromToken: FromToken, + toToken: ToToken, + input: PhaseIO, + ctx: PhaseCtx +): Promise, SquidRouterSwapMetadata>> { + if (!ctx.fees?.usd) { + throw new Error("SquidRouterSwap: Missing ctx.fees.usd - ensure computeFees ran successfully"); + } + const inputTokenDetails = getOnChainTokenDetails(fromChain as Networks, fromToken) as EvmTokenDetails; + const bridgeSourceToken = inputTokenDetails.erc20AddressSourceChain; + const toTokenDetails = getBridgeTargetTokenDetails(toToken as OnChainToken, toChain as Networks); + const bridgeTargetToken = toTokenDetails.erc20AddressSourceChain; + const bridgeResult = await calculateEvmBridgeAndNetworkFee({ + amountRaw: input.amountRaw, + fromNetwork: fromChain as Networks, + fromToken: bridgeSourceToken, + originalInputAmountForRateCalc: input.amountRaw, + toNetwork: toChain as Networks, + toToken: bridgeTargetToken + }); + const outputAmountRaw = new Big(bridgeResult.finalGrossOutputAmountDecimal) + .times(new Big(10).pow(bridgeResult.outputTokenDecimals)) + .toFixed(0, 0); + ctx.addNote( + `SquidRouterSwap: ${input.amount} ${fromToken} on ${fromChain} -> ${bridgeResult.finalGrossOutputAmountDecimal.toFixed()} ${toToken} on ${toChain}` + ); + return { + metadata: { + effectiveExchangeRate: bridgeResult.finalEffectiveExchangeRate, + fromNetwork: fromChain as Networks, + fromToken: bridgeSourceToken, + inputAmountDecimal: input.amount, + inputAmountRaw: input.amountRaw, + networkFeeUSD: bridgeResult.networkFeeUSD, + outputAmountDecimal: bridgeResult.finalGrossOutputAmountDecimal, + outputAmountRaw, + toNetwork: toChain as Networks, + toToken: bridgeTargetToken + }, + output: evmIO(toToken, toChain, bridgeResult.finalGrossOutputAmountDecimal, outputAmountRaw) + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/transactions.ts new file mode 100644 index 000000000..61334b1a9 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/transactions.ts @@ -0,0 +1,216 @@ +import { + createOnrampSquidrouterTransactionsFromBaseToEvm, + createOnrampSquidrouterTransactionsFromPolygonToEvm, + createOnrampSquidrouterTransactionsOnDestinationChain, + EphemeralAccountType, + EvmNetworks, + EvmToken, + EvmTokenDetails, + EvmTransactionData, + evmTokenConfig, + getOnChainTokenDetails, + getOnChainTokenDetailsOrDefault, + Networks, + OnChainToken +} from "@vortexfi/shared"; +import Big from "big.js"; +import { requireAccount } from "../../core/accounts"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { createDestinationApprovalTransaction, encodeEvmTransactionData } from "../../core/evm-transactions"; +import type { ChainBrand, PrepareCtx, PreparedPhaseTxs, TokenBrand } from "../../core/types"; +import type { SquidRouterSwapMetadata } from "./simulation"; + +export interface SquidRouterSwapPreparation { + quoteId: string; + receiverHash?: string; + receiverId?: string; +} + +// Bound the backup approval to the bridged amount + 5% slippage cushion (replaces unbounded maxUint256). +const BACKUP_APPROVE_SLIPPAGE_FACTOR = "1.05"; + +// The presigned bridge approve+swap the SquidRouterSwapExecutor broadcasts, plus the bridge's +// contingency lane on the destination chain: a re-swap of the bridged fallback token to the target +// token and an approval letting the funding account recover it. Reads only this phase's own +// simulated metadata. +export async function prepareSquidRouterSwapTxs( + fromChain: ChainBrand, + toChain: ChainBrand, + fromToken: TokenBrand, + toToken: TokenBrand, + ctx: PrepareCtx +): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const { ownMetadata } = ctx; + + const bridgeInputAmountRaw = ownMetadata.inputAmountRaw; + + const fromTokenDetails = evmTokenConfig[fromChain as EvmNetworks]?.[fromToken as EvmToken]; + if (!fromTokenDetails) { + throw new Error(`prepareSquidRouterSwapTxs: Missing token config for ${fromToken} on ${fromChain}`); + } + + const toTokenDetails = getOnChainTokenDetails(toChain as Networks, toToken as OnChainToken) as EvmTokenDetails | undefined; + if (!toTokenDetails) { + throw new Error(`prepareSquidRouterSwapTxs: Missing token details for ${toToken} on ${toChain}`); + } + + const createSourceTransactions = + fromChain === Networks.Polygon + ? createOnrampSquidrouterTransactionsFromPolygonToEvm + : createOnrampSquidrouterTransactionsFromBaseToEvm; + const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = + await createSourceTransactions({ + destinationAddress: evmEphemeral.address, + fromAddress: evmEphemeral.address, + fromToken: fromTokenDetails.erc20AddressSourceChain, + rawAmount: bridgeInputAmountRaw, + toNetwork: toChain as Networks, + toToken: toTokenDetails.erc20AddressSourceChain + }); + if (!squidRouterQuoteId) { + throw new Error("prepareSquidRouterSwapTxs: Squid quote ID is missing"); + } + + // Fallback re-swap input depends on the destination chain: the bridge delivers USDC on Ethereum + // and axlUSDC everywhere else. + let bridgedTokenForFallback: `0x${string}`; + if (toChain === Networks.Ethereum) { + const ethereumUsdc = evmTokenConfig.ethereum.USDC; + if (!ethereumUsdc) { + throw new Error("prepareSquidRouterSwapTxs: USDC config missing for Ethereum"); + } + bridgedTokenForFallback = ethereumUsdc.erc20AddressSourceChain as `0x${string}`; + } else { + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toChain as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; + bridgedTokenForFallback = destinationAxlUsdcDetails.erc20AddressSourceChain as `0x${string}`; + } + + const { approveData: backupApproveData, swapData: backupSwapData } = + await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeral.address, + fromAddress: evmEphemeral.address, + fromToken: bridgedTokenForFallback, + network: toChain as EvmNetworks, + rawAmount: bridgeInputAmountRaw, + toToken: toTokenDetails.erc20AddressSourceChain + }); + + const fundingAccountAddress = getEvmFundingAccount(fromChain as EvmNetworks).address; + const backupApproveAmountRaw = + fromChain === Networks.Polygon + ? (2n ** 256n - 1n).toString() + : new Big(bridgeInputAmountRaw).mul(BACKUP_APPROVE_SLIPPAGE_FACTOR).toFixed(0, 0); + const backupApproveTransaction = await createDestinationApprovalTransaction({ + amountRaw: backupApproveAmountRaw, + destinationNetwork: toChain as EvmNetworks, + spenderAddress: fundingAccountAddress, + tokenAddress: bridgedTokenForFallback + }); + + return { + intents: [ + { + lane: "main", + network: fromChain as Networks, + phase: "squidRouterApprove", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }, + { + lane: "main", + network: fromChain as Networks, + phase: "squidRouterSwap", + prefundNativeValueRaw: swapData.value?.toString() ?? "0", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }, + { + lane: "backup", + network: toChain as Networks, + phase: "backupSquidRouterApprove", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(backupApproveData) as EvmTransactionData + }, + { + lane: "backup", + network: toChain as Networks, + phase: "backupSquidRouterSwap", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(backupSwapData) as EvmTransactionData + }, + { + // Pinned to the first main nonce on the destination chain so the approval can always + // broadcast even if the txs between never do. + lane: "backup", + network: toChain as Networks, + phase: "backupApprove", + reuseFirstMainNonce: true, + signer: evmEphemeral.address, + txData: backupApproveTransaction + } + ], + state: { + quoteId: squidRouterQuoteId, + receiverHash: squidRouterReceiverHash, + receiverId: squidRouterReceiverId + } + }; +} + +export async function prepareSameChainSquidRouterSwapTxs( + fromChain: ChainBrand, + toChain: ChainBrand, + fromToken: TokenBrand, + toToken: TokenBrand, + ctx: PrepareCtx +): Promise { + const evmEphemeral = requireAccount(ctx.accounts, EphemeralAccountType.EVM); + const fromTokenDetails = evmTokenConfig[fromChain as EvmNetworks]?.[fromToken as EvmToken]; + const toTokenDetails = getOnChainTokenDetails(toChain as Networks, toToken as OnChainToken) as EvmTokenDetails | undefined; + if (!fromTokenDetails || !toTokenDetails) { + throw new Error(`prepareSameChainSquidRouterSwapTxs: Missing token details for ${fromToken}/${toToken} on ${fromChain}`); + } + + const createSourceTransactions = + fromChain === Networks.Polygon + ? createOnrampSquidrouterTransactionsFromPolygonToEvm + : createOnrampSquidrouterTransactionsFromBaseToEvm; + const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = + await createSourceTransactions({ + destinationAddress: evmEphemeral.address, + fromAddress: evmEphemeral.address, + fromToken: fromTokenDetails.erc20AddressSourceChain, + rawAmount: ctx.ownMetadata.inputAmountRaw, + toNetwork: toChain as Networks, + toToken: toTokenDetails.erc20AddressSourceChain + }); + if (!squidRouterQuoteId) { + throw new Error("prepareSameChainSquidRouterSwapTxs: Squid quote ID is missing"); + } + + return { + intents: [ + { + lane: "main", + network: fromChain as Networks, + phase: "squidRouterApprove", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }, + { + lane: "main", + network: fromChain as Networks, + phase: "squidRouterSwap", + prefundNativeValueRaw: swapData.value?.toString() ?? "0", + signer: evmEphemeral.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + } + ], + state: { + quoteId: squidRouterQuoteId, + receiverHash: squidRouterReceiverHash, + receiverId: squidRouterReceiverId + } + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/subsidize-post/execution.ts b/apps/api/src/api/services/phases/blocks/phases/subsidize-post/execution.ts new file mode 100644 index 000000000..29a433995 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/subsidize-post/execution.ts @@ -0,0 +1,268 @@ +import { + ApiManager, + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + getOnChainTokenDetails, + Networks, + nativeToDecimal, + RampCurrency, + RampPhase, + sleep, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import logger from "../../../../../../config/logger"; +import { config } from "../../../../../../config/vars"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { SubsidyToken } from "../../../../../../models/subsidy.model"; +import { getFundingAccount } from "../../../../../controllers/subsidize.controller"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { calculatePostSwapSubsidyComponents } from "../../../../phases/helpers/post-swap-subsidy-breakdown"; +import { StateMetadata } from "../../../../phases/meta-state-types"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { getBlockMetadata } from "../../core/metadata"; +import { SubsidizePostContext } from "./simulation"; + +const EVM_SETTLEMENT_DELAY_MS = parseInt(process.env.SUBSIDY_SETTLEMENT_DELAY_MS || "15000", 10); + +// EVM slice of the production SubsidizePostSwapPhaseHandler: tops up the ephemeral's Nabla output +// token on Base until it matches the amount the next phase expects (the simulated Squid bridge +// input for BUY ramps). The substrate branch is not ported. +export class SubsidizePostSwapExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "subsidizePostSwap"; + } + + public getMaxRetries(): number { + return 200; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const metadata = getBlockMetadata(quote.metadata, SubsidizePostContext); + + if (metadata.network === Networks.Pendulum) { + try { + const substrateAddress = state.state.substrateEphemeralAddress; + if (!substrateAddress || !metadata.outputCurrencyId) { + throw new Error("SubsidizePostSwapExecutor: missing Pendulum state"); + } + const manager = ApiManager.getInstance(); + const pendulum = await manager.getApi("pendulum"); + const getBalance = async (address: string) => { + const balance = await pendulum.api.query.tokens.accounts(address, metadata.outputCurrencyId); + return new Big((balance as unknown as { free?: { toString(): string } }).free?.toString() ?? "0"); + }; + const current = await getBalance(substrateAddress); + if (current.eq(0)) throw this.createRecoverableError("Swap output did not arrive on Pendulum"); + const required = new Big(metadata.targetOutputAmountRaw).minus(current); + if (required.gt(0)) { + const funding = getFundingAccount(); + const available = await getBalance(funding.address); + if (available.lt(required)) throw this.createUnrecoverableError("Pendulum post-swap funding balance too low"); + const result = await this.runFinancialOperation(state, { + attemptClass: "substrate-subsidy-transfer", + externalId: operation => operation.hash, + perform: async () => { + throwIfAborted(signal); + const sent = await abortableCall(signal, () => + manager.executeApiCall( + api => api.tx.tokens.transfer(substrateAddress, metadata.outputCurrencyId, required.toFixed(0, 0)), + funding, + "pendulum" + ) + ); + await waitUntilTrueWithTimeout( + async () => (await getBalance(substrateAddress)).gte(metadata.targetOutputAmountRaw), + 2000, + 180000, + signal + ); + return { hash: sent.hash }; + }, + provider: Networks.Pendulum, + request: { + amountRaw: required.toFixed(0, 0), + currencyId: metadata.outputCurrencyId, + destination: substrateAddress, + source: funding.address + }, + signal + }); + await this.createSubsidy( + state, + nativeToDecimal(required, metadata.outputDecimals).toNumber(), + metadata.outputCurrency as SubsidyToken, + funding.address, + result.hash + ); + } + return state; + } catch (e) { + logger.error("Error in subsidizePostSwap (Pendulum):", e); + if (e instanceof PhaseError) throw e; + throw this.createRecoverableError("SubsidizePostSwapExecutor: Failed to subsidize post swap on Pendulum."); + } + } + + const { evmEphemeralAddress } = state.state as StateMetadata; + if (!evmEphemeralAddress) { + throw new Error("SubsidizePostSwapExecutor: State metadata corrupted. This is a bug."); + } + + try { + const outputToken = metadata.outputCurrency as EvmToken; + + const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outputToken) as EvmTokenDetails; + if (!outputTokenDetails) { + throw new Error( + `Could not find token details for output token ${outputToken} on network ${Networks.Base}. Invalid quote metadata.` + ); + } + + // Wait for token settlement before checking balance + await sleep(EVM_SETTLEMENT_DELAY_MS, signal); + + const currentBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: "1", + chain: outputTokenDetails.network as EvmNetworks, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + signal, + timeoutMs: 5000, + tokenDetails: outputTokenDetails + }); + + if (currentBalance.eq(Big(0))) { + throw new Error("Invalid phase: input token did not arrive yet on EVM"); + } + + // For BUY operations, top up to the simulated Squid bridge input; for SELL, to the + // simulated Nabla output. + const expectedSwapOutputAmountRaw = Big(metadata.targetOutputAmountRaw); + + const subsidyComponents = calculatePostSwapSubsidyComponents({ + currentBalanceRaw: currentBalance, + discountSubsidyAmountRaw: String(metadata.subsidyAmountInOutputTokenRaw), + expectedOutputAmountRaw: expectedSwapOutputAmountRaw, + quotedActualOutputAmountRaw: String(metadata.actualOutputAmountRaw) + }); + const requiredAmount = subsidyComponents.requiredAmountRaw; + logger.debug(`SubsidizePostSwapExecutor: requiredAmount ${requiredAmount.toString()}`); + + if (requiredAmount.gt(Big(0))) { + const quoteOutputUsd = await priceFeedService.convertCurrency( + quote.outputAmount, + quote.outputCurrency as RampCurrency, + EvmToken.USDC as RampCurrency + ); + const discrepancyRaw = subsidyComponents.discrepancyAmountRaw; + const discountRaw = subsidyComponents.discountAmountRaw; + const discrepancyUsd = discrepancyRaw.gt(0) + ? await priceFeedService.convertCurrency( + nativeToDecimal(discrepancyRaw, metadata.outputDecimals).toString(), + outputToken as RampCurrency, + EvmToken.USDC as RampCurrency + ) + : "0"; + const discountUsd = discountRaw.gt(0) + ? await priceFeedService.convertCurrency( + nativeToDecimal(discountRaw, metadata.outputDecimals).toString(), + outputToken as RampCurrency, + EvmToken.USDC as RampCurrency + ) + : "0"; + const discrepancyCapFraction = config.subsidy.evmSwapSubsidyQuoteFraction; + const discrepancyPercentageCap = Big(quoteOutputUsd).mul(discrepancyCapFraction); + const discrepancyCapUsd = discrepancyPercentageCap.gt("1") ? discrepancyPercentageCap : Big("1"); + if (Big(discrepancyUsd).gt(discrepancyCapUsd)) { + // Pause for operator intervention without moving the ramp to failed. + throw this.createRecoverableError( + `SubsidizePostSwapExecutor: Required swap discrepancy subsidy $${discrepancyUsd} exceeds cap $${discrepancyCapUsd.toFixed(2)} (max of $1.00 and ${discrepancyCapFraction} of quote output $${quoteOutputUsd}).` + ); + } + const discountCapFraction = config.subsidy.evmPostSwapDiscountSubsidyQuoteFraction; + const discountCapUsd = Big(quoteOutputUsd).mul(discountCapFraction); + if (Big(discountUsd).gte(1) && Big(discountUsd).gt(discountCapUsd)) { + throw this.createRecoverableError( + `SubsidizePostSwapExecutor: Required discount subsidy $${discountUsd} exceeds cap $${discountCapUsd.toFixed(2)} (${discountCapFraction} of quote output $${quoteOutputUsd}).` + ); + } + + logger.info( + `Subsidizing post-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedSwapOutputAmountRaw}` + ); + + const evmClientManager = EvmClientManager.getInstance(); + const destinationNetwork = outputTokenDetails.network as EvmNetworks; + const fundingAccount = getEvmFundingAccount(destinationNetwork); + + const publicClient = evmClientManager.getClient(destinationNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const nonce = await publicClient.getTransactionCount({ address: fundingAccount.address, blockTag: "pending" }); + + const data = encodeFunctionData({ + abi: erc20Abi, + args: [evmEphemeralAddress as `0x${string}`, BigInt(requiredAmount.toFixed(0))], + functionName: "transfer" + }); + + const { hash: txHash } = await this.runFinancialOperation(state, { + attemptClass: "evm-subsidy-transfer", + externalId: operation => operation.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + to: outputTokenDetails.erc20AddressSourceChain as `0x${string}`, + value: 0n + }); + const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") { + throw new Error(`SubsidizePostSwapExecutor: Subsidy transaction ${hash} failed`); + } + return { hash }; + }, + provider: destinationNetwork, + request: { + amountRaw: requiredAmount.toFixed(0), + destination: evmEphemeralAddress, + network: destinationNetwork, + nonce, + source: fundingAccount.address, + token: outputTokenDetails.erc20AddressSourceChain + }, + signal + }); + + const subsidyAmount = nativeToDecimal(requiredAmount, metadata.outputDecimals).toNumber(); + const subsidyToken = metadata.outputCurrency as unknown as SubsidyToken; + + await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); + } + + return state; + } catch (e) { + logger.error("Error in subsidizePostSwap (EVM):", e); + if (e instanceof PhaseError) { + throw e; + } + throw this.createRecoverableError("SubsidizePostSwapExecutor: Failed to subsidize post swap on EVM."); + } + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/subsidize-post/index.ts b/apps/api/src/api/services/phases/blocks/phases/subsidize-post/index.ts new file mode 100644 index 000000000..848012748 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/subsidize-post/index.ts @@ -0,0 +1,31 @@ +import type { ChainBrand, Phase, PhaseIO, TokenBrand } from "../../core/types"; +import { SubsidizePostSwapExecutor } from "./execution"; +import { SubsidizePostContext, simulateOfframpSubsidizePost, simulateSubsidizePost } from "./simulation"; + +export function SubsidizePost(): Phase< + typeof SubsidizePostContext, + PhaseIO, + PhaseIO +> { + return { + context: SubsidizePostContext, + executors: [new SubsidizePostSwapExecutor()], + name: "SubsidizePost", + phases: ["subsidizePostSwap"], + simulate: simulateSubsidizePost + }; +} + +export function OfframpSubsidizePost(): Phase< + typeof SubsidizePostContext, + PhaseIO, + PhaseIO +> { + return { + context: SubsidizePostContext, + executors: [new SubsidizePostSwapExecutor()], + name: "OfframpSubsidizePost", + phases: ["subsidizePostSwap"], + simulate: simulateOfframpSubsidizePost + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/subsidize-post/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/subsidize-post/simulation.ts new file mode 100644 index 000000000..e308983f6 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/subsidize-post/simulation.ts @@ -0,0 +1,161 @@ +import { + EvmToken, + getNetworkFromDestination, + getOnChainTokenDetails, + multiplyByPowerOfTen, + Networks, + OnChainToken +} from "@vortexfi/shared"; +import Big from "big.js"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { + calculateExpectedOutput, + calculateSubsidyAmount, + getUsdDenominatedInputAmount, + resolveDiscountPartner +} from "../../core/discount"; +import { defineContext } from "../../core/metadata"; +import { getEvmBridgeQuote } from "../../core/squidrouter"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; +import { buildFullSubsidy, computeExpectedOutput, type SubsidyMetadata } from "../subsidize-pre/simulation"; + +export interface SubsidizePostMetadata extends SubsidyMetadata { + network?: string; + outputCurrency: string; + outputCurrencyId?: ReturnType["currencyId"]; + outputDecimals: number; +} + +export const SubsidizePostContext = defineContext()("subsidizePostSwap"); + +export async function simulateSubsidizePost( + input: PhaseIO, + ctx: PhaseCtx +): Promise, SubsidizePostMetadata>> { + const tokenDetails = getOnChainTokenDetails(input.chain as Networks, input.token as OnChainToken); + if (!tokenDetails) { + throw new Error(`SubsidizePost: Missing token details for ${input.token} on ${input.chain}`); + } + const partner = await resolveDiscountPartner(ctx, ctx.request.rampType); + const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate(ctx.request.inputCurrency); + const { expectedOutput, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput( + ctx.request.inputAmount, + oraclePrice, + partner?.targetDiscount ?? 0, + false, + partner + ); + let adjustedExpectedOutput = expectedOutput; + const toNetwork = getNetworkFromDestination(ctx.request.to); + if (toNetwork && !(toNetwork === Networks.Base && ctx.request.outputCurrency === EvmToken.USDC)) { + try { + const bridge = await getEvmBridgeQuote({ + amountDecimal: expectedOutput.toString(), + fromNetwork: Networks.Base, + inputCurrency: EvmToken.USDC, + outputCurrency: ctx.request.outputCurrency as OnChainToken, + toNetwork + }); + if (expectedOutput.gt(0) && bridge.outputAmountDecimal.gt(0)) { + const conversionRate = bridge.outputAmountDecimal.div(expectedOutput); + adjustedExpectedOutput = expectedOutput.div(conversionRate); + } + } catch (error) { + ctx.addNote(`SubsidizePost: Squid conversion unavailable, using 1:1. Error: ${error}`); + } + } + const expectedRaw = multiplyByPowerOfTen(adjustedExpectedOutput, tokenDetails.decimals).toFixed(0, 0); + const idealSubsidy = input.amount.gte(adjustedExpectedOutput) ? new Big(0) : adjustedExpectedOutput.minus(input.amount); + const subsidyAmount = new Big(partner?.targetDiscount ?? 0).gt(0) + ? calculateSubsidyAmount(adjustedExpectedOutput, input.amount, partner?.maxSubsidy ?? 0) + : new Big(0); + const subsidyRaw = multiplyByPowerOfTen(subsidyAmount, tokenDetails.decimals).toFixed(0, 0); + const newAmount = input.amount.plus(subsidyAmount); + const newAmountRaw = new Big(input.amountRaw).plus(subsidyRaw).toFixed(0, 0); + const subsidy: SubsidyMetadata = { + actualOutputAmountDecimal: input.amount, + actualOutputAmountRaw: input.amountRaw, + adjustedDifference, + adjustedTargetDiscount, + applied: subsidyAmount.gt(0), + expectedOutputAmountDecimal: adjustedExpectedOutput, + expectedOutputAmountRaw: expectedRaw, + idealSubsidyAmountInOutputTokenDecimal: idealSubsidy, + idealSubsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(idealSubsidy, tokenDetails.decimals).toFixed(0, 0), + partnerId: partner?.id ?? null, + subsidyAmountInOutputTokenDecimal: subsidyAmount, + subsidyAmountInOutputTokenRaw: subsidyRaw, + subsidyRate: adjustedExpectedOutput.gt(0) ? subsidyAmount.div(adjustedExpectedOutput) : new Big(0), + targetOutputAmountDecimal: newAmount, + targetOutputAmountRaw: newAmountRaw + }; + ctx.addNote( + `SubsidizePost: applied=${subsidy.applied}, subsidy=${Big(subsidy.subsidyAmountInOutputTokenDecimal).toFixed()}, newAmount=${newAmount.toFixed()}` + ); + return { + metadata: { ...subsidy, outputCurrency: input.token, outputDecimals: tokenDetails.decimals }, + output: { ...input, amount: newAmount, amountRaw: newAmountRaw } + }; +} + +export async function simulateOfframpSubsidizePost( + input: PhaseIO, + ctx: PhaseCtx +): Promise, SubsidizePostMetadata>> { + const tokenDetails = getOnChainTokenDetails(input.chain as Networks, input.token as OnChainToken); + if (!tokenDetails) { + throw new Error(`OfframpSubsidizePost: Missing token details for ${input.token} on ${input.chain}`); + } + const partner = await resolveDiscountPartner(ctx, ctx.request.rampType); + const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate(ctx.request.outputCurrency); + const inputAmountUsd = await getUsdDenominatedInputAmount( + Object.assign( + {}, + ctx, + input.requestInputAmountUsd ? { evmToEvm: { outputAmountDecimal: input.requestInputAmountUsd } } : {} + ) as never + ); + if (!inputAmountUsd.eq(ctx.request.inputAmount)) { + ctx.addNote( + `OfframpSubsidizePost: valued input ${ctx.request.inputAmount} ${ctx.request.inputCurrency} at ${inputAmountUsd.toFixed(6)} USD for discount calculation` + ); + } + const { expectedOutput, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput( + inputAmountUsd.toString(), + oraclePrice, + partner?.targetDiscount ?? 0, + true, + partner + ); + const expectedWithAnchor = expectedOutput.plus(ctx.fees?.displayFiat?.anchor ?? 0); + const expectedRaw = multiplyByPowerOfTen(expectedWithAnchor, tokenDetails.decimals).toFixed(0, 0); + const actualRaw = multiplyByPowerOfTen(input.amount, tokenDetails.decimals).toFixed(0, 0); + const idealSubsidy = input.amount.gte(expectedWithAnchor) ? new Big(0) : expectedWithAnchor.minus(input.amount); + const subsidyUnrounded = new Big(partner?.targetDiscount ?? 0).gt(0) + ? calculateSubsidyAmount(expectedWithAnchor, input.amount, partner?.maxSubsidy ?? 0) + : new Big(0); + const subsidy = new Big(subsidyUnrounded.toFixed(6, 0)); + const subsidyRaw = multiplyByPowerOfTen(subsidy, tokenDetails.decimals).toFixed(0, 0); + const targetAmount = input.amount.plus(subsidy); + const targetRaw = new Big(actualRaw).plus(subsidyRaw).toFixed(0, 0); + const metadata: SubsidizePostMetadata = { + actualOutputAmountDecimal: input.amount, + actualOutputAmountRaw: actualRaw, + adjustedDifference, + adjustedTargetDiscount, + applied: subsidy.gt(0), + expectedOutputAmountDecimal: expectedWithAnchor, + expectedOutputAmountRaw: expectedRaw, + idealSubsidyAmountInOutputTokenDecimal: new Big(idealSubsidy.toFixed(6, 0)), + idealSubsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(idealSubsidy, tokenDetails.decimals).toFixed(0, 0), + outputCurrency: input.token, + outputDecimals: tokenDetails.decimals, + partnerId: partner?.id ?? null, + subsidyAmountInOutputTokenDecimal: subsidy, + subsidyAmountInOutputTokenRaw: subsidyRaw, + subsidyRate: expectedWithAnchor.gt(0) ? subsidy.div(expectedWithAnchor) : new Big(0), + targetOutputAmountDecimal: targetAmount, + targetOutputAmountRaw: targetRaw + }; + return { metadata, output: { ...input, amount: targetAmount, amountRaw: targetRaw } }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/execution.ts b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/execution.ts new file mode 100644 index 000000000..8028c0bcf --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/execution.ts @@ -0,0 +1,239 @@ +import { + ApiManager, + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmToken, + EvmTokenDetails, + getOnChainTokenDetails, + getPendulumDetails, + Networks, + nativeToDecimal, + RampCurrency, + RampDirection, + RampPhase, + sleep, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; +import { Big } from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import logger from "../../../../../../config/logger"; +import { config } from "../../../../../../config/vars"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; +import RampState from "../../../../../../models/rampState.model"; +import { SubsidyToken } from "../../../../../../models/subsidy.model"; +import { getFundingAccount } from "../../../../../controllers/subsidize.controller"; +import { PhaseError } from "../../../../../errors/phase-error"; +import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; +import { StateMetadata } from "../../../../phases/meta-state-types"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { abortableCall, throwIfAborted } from "../../core/cancellation"; +import { getEvmFundingAccount } from "../../core/evm-funding"; +import { getBlockMetadata } from "../../core/metadata"; +import { SubsidizePreContext } from "./simulation"; + +const EVM_SETTLEMENT_DELAY_MS = parseInt(process.env.SUBSIDY_SETTLEMENT_DELAY_MS || "15000", 10); + +export class SubsidizePreSwapExecutor extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "subsidizePreSwap"; + } + + public getMaxRetries(): number { + return 200; + } + + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("Quote not found for the given state"); + } + + const metadata = getBlockMetadata(quote.metadata, SubsidizePreContext); + + if (metadata.network === Networks.Pendulum) { + try { + const substrateAddress = state.state.substrateEphemeralAddress; + if (!substrateAddress) throw new Error("SubsidizePreSwapExecutor: missing Substrate ephemeral"); + const manager = ApiManager.getInstance(); + const pendulum = await manager.getApi("pendulum"); + const currencyId = metadata.inputCurrencyId ?? getPendulumDetails(metadata.inputCurrency as RampCurrency).currencyId; + const getBalance = async (address: string) => { + const balance = await pendulum.api.query.tokens.accounts(address, currencyId); + return new Big((balance as unknown as { free?: { toString(): string } }).free?.toString() ?? "0"); + }; + const current = await getBalance(substrateAddress); + if (current.eq(0)) throw this.createRecoverableError("Input token did not arrive on Pendulum"); + const required = new Big(metadata.targetInputAmountRaw).minus(current); + if (required.gt(0)) { + const funding = getFundingAccount(); + const available = await getBalance(funding.address); + if (available.lt(required)) throw this.createUnrecoverableError("Pendulum pre-swap funding balance too low"); + const result = await this.runFinancialOperation(state, { + attemptClass: "substrate-subsidy-transfer", + externalId: operation => operation.hash, + perform: async () => { + throwIfAborted(signal); + const sent = await abortableCall(signal, () => + manager.executeApiCall( + api => api.tx.tokens.transfer(substrateAddress, currencyId, required.toFixed(0, 0)), + funding, + "pendulum" + ) + ); + await waitUntilTrueWithTimeout( + async () => (await getBalance(substrateAddress)).gte(metadata.targetInputAmountRaw), + 5000, + 180000, + signal + ); + return { hash: sent.hash }; + }, + provider: Networks.Pendulum, + request: { + amountRaw: required.toFixed(0, 0), + currencyId, + destination: substrateAddress, + source: funding.address + }, + signal + }); + await this.createSubsidy( + state, + nativeToDecimal(required, metadata.inputDecimals).toNumber(), + metadata.inputCurrency as SubsidyToken, + funding.address, + result.hash + ); + } + return state; + } catch (e) { + logger.error("Error in subsidizePreSwap (Pendulum):", e); + if (e instanceof PhaseError) throw e; + throw this.createRecoverableError("SubsidizePreSwapExecutor: Failed to subsidize pre swap on Pendulum."); + } + } + + const { evmEphemeralAddress } = state.state as StateMetadata; + if (!evmEphemeralAddress) { + throw new Error("SubsidizePreSwapExecutor: State metadata corrupted. This is a bug."); + } + + try { + const inputToken = metadata.inputCurrency as EvmToken; + const inputNetwork = metadata.network as Networks; + const inputTokenDetails = getOnChainTokenDetails(inputNetwork, inputToken) as EvmTokenDetails; + if (!inputTokenDetails) { + throw new Error( + `Could not find token details for input token ${inputToken} on network ${inputNetwork}. Invalid quote metadata.` + ); + } + const expectedInputAmountForSwapRaw = metadata.targetInputAmountRaw; + + // Wait for token settlement before checking balance + await sleep(EVM_SETTLEMENT_DELAY_MS, signal); + + const currentBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: "1", + chain: inputTokenDetails.network as EvmNetworks, + intervalMs: 1000, + ownerAddress: evmEphemeralAddress, + signal, + timeoutMs: 5000, + tokenDetails: inputTokenDetails + }); + + if (currentBalance.eq(Big(0))) { + throw new Error("Invalid phase: input token did not arrive yet on EVM"); + } + + const requiredAmount = Big(expectedInputAmountForSwapRaw).sub(currentBalance); + logger.debug(`SubsidizePreSwapExecutor: requiredAmount ${requiredAmount.toString()}`); + + if (requiredAmount.gt(Big(0))) { + const subsidyDecimal = nativeToDecimal(requiredAmount, metadata.inputDecimals).toString(); + const subsidyUsd = await priceFeedService.convertCurrency( + subsidyDecimal, + inputToken as RampCurrency, + EvmToken.USDC as RampCurrency + ); + const quoteOutputUsd = await priceFeedService.convertCurrency( + quote.outputAmount, + quote.outputCurrency as RampCurrency, + EvmToken.USDC as RampCurrency + ); + const subsidyCapFraction = config.subsidy.evmSwapSubsidyQuoteFraction; + const percentageCap = Big(quoteOutputUsd).mul(subsidyCapFraction); + const subsidyCapUsd = percentageCap.gt("1") ? percentageCap : Big("1"); + if (Big(subsidyUsd).gt(subsidyCapUsd)) { + // Pause for operator intervention without moving the ramp to failed. + throw this.createRecoverableError( + `SubsidizePreSwapExecutor: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (max of $1.00 and ${subsidyCapFraction} of quote output $${quoteOutputUsd}).` + ); + } + + logger.info( + `Subsidizing pre-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedInputAmountForSwapRaw}` + ); + + const evmClientManager = EvmClientManager.getInstance(); + const destinationNetwork = inputTokenDetails.network as EvmNetworks; + const fundingAccount = getEvmFundingAccount(destinationNetwork); + + const publicClient = evmClientManager.getClient(destinationNetwork); + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const nonce = await publicClient.getTransactionCount({ address: fundingAccount.address, blockTag: "pending" }); + + const data = encodeFunctionData({ + abi: erc20Abi, + args: [evmEphemeralAddress as `0x${string}`, BigInt(requiredAmount.toFixed(0))], + functionName: "transfer" + }); + + const { hash: txHash } = await this.runFinancialOperation(state, { + attemptClass: "evm-subsidy-transfer", + externalId: operation => operation.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + to: inputTokenDetails.erc20AddressSourceChain as `0x${string}`, + value: 0n + }); + const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") { + throw new Error(`SubsidizePreSwapExecutor: Subsidy transaction ${hash} failed`); + } + return { hash }; + }, + provider: destinationNetwork, + request: { + amountRaw: requiredAmount.toFixed(0), + destination: evmEphemeralAddress, + network: destinationNetwork, + nonce, + source: fundingAccount.address, + token: inputTokenDetails.erc20AddressSourceChain + }, + signal + }); + + const subsidyAmount = nativeToDecimal(requiredAmount, metadata.inputDecimals).toNumber(); + const subsidyToken = metadata.inputCurrency as unknown as SubsidyToken; + + await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); + } + + return state; + } catch (e) { + logger.error("Error in subsidizePreSwap (EVM):", e); + if (e instanceof PhaseError) { + throw e; + } + throw this.createRecoverableError("SubsidizePreSwapExecutor: Failed to subsidize pre swap on EVM."); + } + } +} diff --git a/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/index.ts b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/index.ts new file mode 100644 index 000000000..2431c3892 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/index.ts @@ -0,0 +1,34 @@ +import type { ChainBrand, Phase, PhaseIO, TokenBrand } from "../../core/types"; +import { SubsidizePreSwapExecutor } from "./execution"; +import { SubsidizePreContext, simulateAlfredpaySubsidizePre, simulateSubsidizePre } from "./simulation"; + +export type { SubsidyMetadata as SubsidyMeta } from "./simulation"; +export { buildFullSubsidy, computeExpectedOutput } from "./simulation"; + +export function SubsidizePre(): Phase< + typeof SubsidizePreContext, + PhaseIO, + PhaseIO +> { + return { + context: SubsidizePreContext, + executors: [new SubsidizePreSwapExecutor()], + name: "SubsidizePre", + phases: ["subsidizePreSwap"], + simulate: simulateSubsidizePre + }; +} + +export function AlfredpaySubsidizePre(): Phase< + typeof SubsidizePreContext, + PhaseIO, + PhaseIO +> { + return { + context: SubsidizePreContext, + executors: [new SubsidizePreSwapExecutor()], + name: "AlfredpaySubsidizePre", + phases: ["subsidizePreSwap"], + simulate: simulateAlfredpaySubsidizePre + }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts new file mode 100644 index 000000000..2d350e8fb --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts @@ -0,0 +1,189 @@ +import { getOnChainTokenDetails, multiplyByPowerOfTen, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; +import { Big } from "big.js"; +import { findPartnerWithPricing } from "../../../../partners/partner-pricing.service"; +import { priceFeedService } from "../../../../priceFeed.service"; +import { + calculateExpectedOutput, + calculateSubsidyAmount, + DEFAULT_PARTNER_NAME, + resolveActivePartnerById, + toActivePartner +} from "../../core/discount"; +import { getTargetFiatCurrency } from "../../core/helpers"; +import { evmIO } from "../../core/io"; +import { defineContext, type SerializableBig } from "../../core/metadata"; +import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; + +export interface SubsidyMetadata { + actualOutputAmountDecimal: SerializableBig; + actualOutputAmountRaw: string; + adjustedDifference: SerializableBig; + adjustedTargetDiscount: SerializableBig; + applied: boolean; + expectedOutputAmountDecimal: SerializableBig; + expectedOutputAmountRaw: string; + idealSubsidyAmountInOutputTokenDecimal: SerializableBig; + idealSubsidyAmountInOutputTokenRaw: string; + partnerId: string | null; + subsidyAmountInOutputTokenDecimal: SerializableBig; + subsidyAmountInOutputTokenRaw: string; + subsidyRate: SerializableBig; + targetOutputAmountDecimal: SerializableBig; + targetOutputAmountRaw: string; +} + +export interface SubsidizePreMetadata { + applied?: boolean; + expectedOutputAmountDecimal: SerializableBig; + expectedOutputAmountRaw: string; + inputCurrency: string; + inputCurrencyId?: ReturnType["currencyId"]; + inputDecimals: number; + network: string; + outputCurrency?: string; + subsidyAmountInOutputTokenDecimal?: SerializableBig; + targetInputAmountRaw: string; +} + +export const SubsidizePreContext = defineContext()("subsidizePreSwap"); + +export function buildFullSubsidy( + actualOutputAmountDecimal: Big, + actualOutputAmountRaw: string, + expectedOutputAmountDecimal: Big, + expectedOutputAmountRaw: string, + ctx: PhaseCtx +): SubsidyMetadata { + const partner = ctx.partner; + const targetDiscount = partner?.targetDiscount ?? 0; + const maxSubsidy = partner?.maxSubsidy ?? 0; + const idealSubsidyAmountInOutputTokenDecimal = actualOutputAmountDecimal.gte(expectedOutputAmountDecimal) + ? new Big(0) + : expectedOutputAmountDecimal.minus(actualOutputAmountDecimal); + const subsidyAmountInOutputTokenDecimal = + targetDiscount !== 0 + ? capSubsidy(idealSubsidyAmountInOutputTokenDecimal, expectedOutputAmountDecimal, maxSubsidy) + : new Big(0); + const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(subsidyAmountInOutputTokenDecimal); + const subsidyRate = expectedOutputAmountDecimal.gt(0) + ? subsidyAmountInOutputTokenDecimal.div(expectedOutputAmountDecimal) + : new Big(0); + const toRaw = (decimal: Big): string => + actualOutputAmountDecimal.gt(0) + ? new Big(actualOutputAmountRaw).times(decimal).div(actualOutputAmountDecimal).toFixed(0, 0) + : "0"; + + return { + actualOutputAmountDecimal, + actualOutputAmountRaw, + adjustedDifference: new Big(0), + adjustedTargetDiscount: new Big(0), + applied: subsidyAmountInOutputTokenDecimal.gt(0), + expectedOutputAmountDecimal, + expectedOutputAmountRaw, + idealSubsidyAmountInOutputTokenDecimal, + idealSubsidyAmountInOutputTokenRaw: toRaw(idealSubsidyAmountInOutputTokenDecimal), + partnerId: partner?.id ?? null, + subsidyAmountInOutputTokenDecimal, + subsidyAmountInOutputTokenRaw: toRaw(subsidyAmountInOutputTokenDecimal), + subsidyRate, + targetOutputAmountDecimal, + targetOutputAmountRaw: toRaw(targetOutputAmountDecimal) + }; +} + +function capSubsidy(idealSubsidy: Big, expectedOutput: Big, maxSubsidy: number): Big { + if (maxSubsidy <= 0) { + return new Big(0); + } + const maxAllowed = expectedOutput.mul(maxSubsidy); + return idealSubsidy.gt(maxAllowed) ? maxAllowed : idealSubsidy; +} + +export async function computeExpectedOutput(ctx: PhaseCtx): Promise<{ decimal: Big; raw: string }> { + let expectedOutputAmount = new Big(ctx.request.inputAmount); + try { + const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate(ctx.request.inputCurrency); + const isOfframp = ctx.request.rampType === RampDirection.SELL; + const effectivePrice = isOfframp ? new Big(1).div(oraclePrice) : oraclePrice; + const targetDiscount = ctx.partner?.targetDiscount ?? 0; + const discountedRate = effectivePrice.mul(new Big(1).plus(targetDiscount)); + expectedOutputAmount = new Big(ctx.request.inputAmount).mul(discountedRate); + } catch (error) { + ctx.addNote(`computeExpectedOutput: oracle price unavailable, using input amount. Error: ${error}`); + } + const expectedOutputAmountRaw = expectedOutputAmount.times(new Big(10).pow(6)).toFixed(0, 0); + return { decimal: expectedOutputAmount, raw: expectedOutputAmountRaw }; +} + +export async function simulateSubsidizePre( + input: PhaseIO, + ctx: PhaseCtx +): Promise, SubsidizePreMetadata>> { + const expected = await computeExpectedOutput(ctx); + const tokenDetails = getOnChainTokenDetails(input.chain as Networks, input.token as OnChainToken); + if (!tokenDetails) { + throw new Error(`SubsidizePre: Missing token details for ${input.token} on ${input.chain}`); + } + ctx.addNote(`SubsidizePre: expected output ${expected.decimal.toFixed()} ${input.token}`); + return { + metadata: { + expectedOutputAmountDecimal: expected.decimal, + expectedOutputAmountRaw: expected.raw, + inputCurrency: input.token, + inputDecimals: tokenDetails.decimals, + network: input.chain, + targetInputAmountRaw: input.amountRaw + }, + output: input + }; +} + +export async function simulateAlfredpaySubsidizePre( + input: PhaseIO, + ctx: PhaseCtx +): Promise, SubsidizePreMetadata>> { + if (!ctx.fees?.usd) { + throw new Error("AlfredpaySubsidizePre: Missing provider-adjusted fees"); + } + const tokenDetails = getOnChainTokenDetails(input.chain as Networks, input.token as OnChainToken); + if (!tokenDetails) { + throw new Error(`AlfredpaySubsidizePre: Missing token details for ${input.token} on ${input.chain}`); + } + const fiatCurrency = getTargetFiatCurrency(ctx.request.rampType, ctx.request.inputCurrency, ctx.request.outputCurrency); + const activePartner = ctx.partner?.id + ? await resolveActivePartnerById(ctx.partner.id, ctx.request.rampType, fiatCurrency) + : await findPartnerWithPricing({ name: DEFAULT_PARTNER_NAME }, ctx.request.rampType, fiatCurrency).then(partner => + partner ? toActivePartner(partner) : null + ); + const targetDiscount = activePartner?.targetDiscount ?? 0; + const maxSubsidy = activePartner?.maxSubsidy ?? 0; + const effectiveRate = input.amount.div(ctx.request.inputAmount); + const actualOutput = input.amount.minus(ctx.fees.usd.vortex).minus(ctx.fees.usd.partnerMarkup); + const { expectedOutput } = calculateExpectedOutput( + ctx.request.inputAmount, + effectiveRate, + targetDiscount, + false, + activePartner + ); + const subsidy = targetDiscount !== 0 ? calculateSubsidyAmount(expectedOutput, actualOutput, maxSubsidy) : new Big(0); + const targetOutput = actualOutput.plus(subsidy); + const toRaw = (amount: Big) => multiplyByPowerOfTen(amount, tokenDetails.decimals).toFixed(0, 0); + + ctx.addNote(`AlfredpaySubsidizePre: bridge target ${targetOutput.toFixed()} ${input.token}`); + return { + metadata: { + applied: subsidy.gt(0), + expectedOutputAmountDecimal: expectedOutput, + expectedOutputAmountRaw: toRaw(expectedOutput), + inputCurrency: input.token, + inputDecimals: tokenDetails.decimals, + network: input.chain, + outputCurrency: input.token, + subsidyAmountInOutputTokenDecimal: subsidy, + targetInputAmountRaw: toRaw(targetOutput) + }, + output: evmIO(input.token, input.chain, targetOutput, toRaw(targetOutput)) + }; +} diff --git a/apps/api/src/api/services/phases/blocks/register-handlers.ts b/apps/api/src/api/services/phases/blocks/register-handlers.ts new file mode 100644 index 000000000..492695232 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/register-handlers.ts @@ -0,0 +1,117 @@ +import logger from "../../../../config/logger"; +import { config } from "../../../../config/vars"; +import QuoteTicket from "../../../../models/quoteTicket.model"; +import RampState from "../../../../models/rampState.model"; +import type { PhaseHandler } from "../../phases/base-phase-handler"; +import phaseRegistry from "../../phases/phase-registry"; +import { getPersistedBlockFlowCompatibilityScope } from "./core/compatibility-scope"; +import { BlockInitialExecutor } from "./core/initial-executor"; +import { getFlowMetadata } from "./core/metadata"; +import { getBlockExecutorFlows, getBlockFlowByIdentity, resolvePersistedBlockFlow } from "./flows/catalog"; + +export function getBlockFlowHandlers(): PhaseHandler[] { + const handlers = new Map(); + const initial = new BlockInitialExecutor(); + handlers.set(initial.getPhaseName(), initial); + + for (const flow of getBlockExecutorFlows()) { + if (flow.phases.length !== flow.executors.length) { + throw new Error( + `Block flow ${flow.identity.id}@${flow.identity.version} has ${flow.phases.length} phases but ${flow.executors.length} executors` + ); + } + for (const [index, phaseName] of flow.phases.entries()) { + if (flow.executors[index]?.getPhaseName() !== phaseName) { + throw new Error( + `Block flow ${flow.identity.id}@${flow.identity.version} executor ${index} does not match phase ${phaseName}` + ); + } + } + for (const executor of flow.executors) { + const phase = executor.getPhaseName(); + const existing = handlers.get(phase); + if (existing && existing.constructor !== executor.constructor) { + throw new Error(`Block flows define conflicting executors for phase ${phase}`); + } + handlers.set(phase, existing ?? executor); + } + } + return [...handlers.values()]; +} + +export function registerBlockFlowHandlers(): void { + logger.info("Registering block flow handlers"); + for (const handler of getBlockFlowHandlers()) { + phaseRegistry.registerHandler(handler); + } + for (const flow of getBlockExecutorFlows()) { + for (const phase of flow.phases) { + if (!phaseRegistry.getHandler(phase)) { + throw new Error(`No registered handler for ${flow.identity.id}@${flow.identity.version} phase ${phase}`); + } + } + } + logger.info("Block flow handlers registered"); +} + +export async function assertPersistedBlockFlowVersionsSupported(): Promise { + const { pendingQuoteWhere, resumableRampWhere } = getPersistedBlockFlowCompatibilityScope(config.flowVariant); + const [pendingQuotes, activeRamps] = await Promise.all([ + QuoteTicket.findAll({ + attributes: ["id", "metadata"], + where: pendingQuoteWhere + }), + RampState.findAll({ + attributes: ["id", "quoteId", "state"], + where: resumableRampWhere + }) + ]); + + for (const quote of pendingQuotes) { + const flow = resolvePersistedBlockFlow(quote.metadata); + if (!getFlowMetadata(quote.metadata).flow) { + await quote.update({ + metadata: { ...getFlowMetadata(quote.metadata), flow: flow.identity } as unknown as QuoteTicket["metadata"] + }); + } + } + + for (const ramp of activeRamps) { + const quote = await QuoteTicket.findByPk(ramp.quoteId, { attributes: ["flowVariant", "id", "metadata"] }); + if (!quote) { + throw new Error(`Active ramp ${ramp.id} references missing quote ${ramp.quoteId}`); + } + if (quote.flowVariant !== config.flowVariant) { + throw new Error( + `Active ramp ${ramp.id} belongs to flow ${config.flowVariant} but references quote ${ramp.quoteId} from flow ${quote.flowVariant}` + ); + } + const quoteFlow = resolvePersistedBlockFlow(quote.metadata); + const flow = ramp.state.flow ? getBlockFlowByIdentity(ramp.state.flow) : quoteFlow; + if (ramp.state.flow) { + if ( + flow.identity.id !== quoteFlow.identity.id || + flow.identity.version !== quoteFlow.identity.version || + flow.identity.topologyHash !== quoteFlow.identity.topologyHash + ) { + throw new Error(`Ramp ${ramp.id} flow identity does not match quote ${ramp.quoteId}`); + } + flow.assertState(ramp.state); + continue; + } + const expectedPhaseFlow = ["initial", ...flow.phases, "complete"]; + if (JSON.stringify(ramp.state.phaseFlow) !== JSON.stringify(expectedPhaseFlow)) { + throw new Error(`Legacy ramp ${ramp.id} does not match supported flow ${flow.identity.id}@${flow.identity.version}`); + } + if (!getFlowMetadata(quote.metadata).flow) { + await quote.update({ + metadata: { ...getFlowMetadata(quote.metadata), flow: flow.identity } as unknown as QuoteTicket["metadata"] + }); + } + await ramp.update({ state: { ...ramp.state, flow: flow.identity } }); + } + + logger.info( + `Validated persisted block-flow support for ${pendingQuotes.length} pending quotes and ${activeRamps.length} resumable ramps in flow ${config.flowVariant}` + ); +} 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 deleted file mode 100644 index 1673a60f8..000000000 --- a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { - ALFREDPAY_ONCHAIN_CURRENCY, - AlfredpayApiService, - AlfredpayChain, - AlfredpayFiatCurrency, - AlfredpayOfframpStatus, - AlfredpayPaymentMethodType, - EvmClientManager, - EvmNetworks, - Networks, - RampPhase -} from "@vortexfi/shared"; -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 - -type AlfredpayFailedStatusError = { - failureReason?: string; - kind: "failed"; -}; - -function isAlfredpayFailedStatusError(error: unknown): error is AlfredpayFailedStatusError { - return !!error && typeof error === "object" && "kind" in error && error.kind === "failed"; -} - -function getErrorName(error: unknown): string | undefined { - return error && typeof error === "object" && "name" in error ? String(error.name) : undefined; -} - -export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "alfredpayOfframpTransfer"; - } - - protected async executePhase(state: RampState): Promise { - const { alfredpayTransactionId, alfredpayOfframpTransferTxHash } = state.state as StateMetadata; - - if (!alfredpayTransactionId) { - throw new Error("AlfredpayOfframpTransferHandler: Missing alfredpayTransactionId in state."); - } - - const alfredpayApiService = AlfredpayApiService.getInstance(); - const evmClientManager = EvmClientManager.getInstance(); - - let alfredpayTx = await alfredpayApiService.getOfframpTransaction(alfredpayTransactionId); - if (!alfredpayTx) { - throw new Error(`AlfredpayOfframpTransferHandler: Transaction ${alfredpayTransactionId} not found in Alfredpay.`); - } - - // Only attempt expiration recovery if we haven't sent the final transfer yet. - if (!alfredpayOfframpTransferTxHash && new Date(alfredpayTx.expiration) < new Date()) { - logger.warn( - `AlfredpayOfframpTransferHandler: Alfredpay transaction ${alfredpayTransactionId} expired before transfer. Attempting recovery.` - ); - - const recovered = await this.recreateAlfredpayOfframp(state, alfredpayTx); - if (!recovered) { - logger.error( - `AlfredpayOfframpTransferHandler: Recovery failed for ${alfredpayTransactionId} (deposit address changed or API error).` - ); - return this.transitionToNextPhase(state, "failed"); - } - - alfredpayTx = recovered.alfredpayTx; - state = recovered.state; - } - - if (!alfredpayOfframpTransferTxHash) { - logger.info( - `AlfredpayOfframpTransferHandler: Executing final transfer for Alfredpay offramp ${alfredpayTx.transactionId}` - ); - - 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}` - ); - - await state.update({ - state: { - ...state.state, - alfredpayOfframpTransferTxHash: txHash - } - }); - - logger.info(`AlfredpayOfframpTransferHandler: Final transfer sent. Hash: ${txHash}`); - } else { - try { - const client = evmClientManager.getClient(Networks.Polygon as EvmNetworks); - const receipt = await client.getTransactionReceipt({ hash: alfredpayOfframpTransferTxHash as `0x${string}` }); - if (receipt.status !== "success") { - throw new Error( - `AlfredpayOfframpTransferHandler: Final transfer transaction ${alfredpayOfframpTransferTxHash} failed on chain.` - ); - } - } catch (error) { - if (getErrorName(error) !== "TransactionReceiptNotFoundError") { - throw error; - } - } - } - - try { - await this.pollAlfredpayOfframpStatus(alfredpayTx.transactionId, ALFREDPAY_POLL_INTERVAL_MS); - } catch (error) { - if (isAlfredpayFailedStatusError(error)) { - logger.error(`AlfredpayOfframpTransferHandler: Alfredpay offramp FAILED. Reason: ${error.failureReason ?? "unknown"}`); - return this.transitionToNextPhase(state, "failed"); - } - - throw this.createRecoverableError( - `AlfredpayOfframpTransferHandler: Error polling Alfredpay status: ${error instanceof Error ? error.message : String(error)}` - ); - } - - return this.transitionToNextPhase(state, "complete"); - } - - private async recreateAlfredpayOfframp( - state: RampState, - expiredTx: Awaited> - ): Promise<{ state: RampState; alfredpayTx: Awaited> } | null> { - const { alfredpayUserId, fiatAccountId, walletAddress } = state.state as StateMetadata; - - if (!alfredpayUserId || !fiatAccountId || !walletAddress) { - logger.error("AlfredpayOfframpTransferHandler: Missing fields required for recovery of expired offramp order."); - return null; - } - - const alfredpayApiService = AlfredpayApiService.getInstance(); - - try { - const toCurrency = expiredTx.toCurrency as AlfredpayFiatCurrency; - - const freshQuote = await alfredpayApiService.createOfframpQuote({ - chain: AlfredpayChain.MATIC, - fromAmount: expiredTx.fromAmount, - fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - metadata: { businessId: "vortex", customerId: alfredpayUserId }, - paymentMethodType: AlfredpayPaymentMethodType.BANK, - toCurrency - }); - - const newOrder = await alfredpayApiService.createOfframp({ - amount: expiredTx.fromAmount, - chain: AlfredpayChain.MATIC, - customerId: alfredpayUserId, - fiatAccountId, - fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - originAddress: walletAddress, - quoteId: freshQuote.quoteId, - toCurrency - }); - - if (newOrder.depositAddress.toLowerCase() !== expiredTx.depositAddress.toLowerCase()) { - logger.error( - `AlfredpayOfframpTransferHandler: New deposit address ${newOrder.depositAddress} does not match expired ${expiredTx.depositAddress}. Cannot reuse presigned final transfer.` - ); - return null; - } - - await state.update({ - state: { - ...state.state, - alfredpayTransactionId: newOrder.transactionId - } - }); - - logger.info( - `AlfredpayOfframpTransferHandler: Recovery successful. New Alfredpay transactionId: ${newOrder.transactionId}` - ); - - const refreshedTx = await alfredpayApiService.getOfframpTransaction(newOrder.transactionId); - return { alfredpayTx: refreshedTx, state }; - } catch (error) { - logger.error( - `AlfredpayOfframpTransferHandler: Error during recovery: ${error instanceof Error ? error.message : String(error)}` - ); - return null; - } - } - - private async pollAlfredpayOfframpStatus(transactionId: string, intervalMs: number): Promise { - const alfredpayApiService = AlfredpayApiService.getInstance(); - const startTime = Date.now(); - - return new Promise((resolve, reject) => { - const poll = async () => { - if (Date.now() - startTime > ALFREDPAY_OFFRAMP_TIMEOUT_MS) { - reject(new Error(`AlfredpayOfframpTransferHandler: Polling timed out after ${ALFREDPAY_OFFRAMP_TIMEOUT_MS}ms`)); - return; - } - - try { - const response = await alfredpayApiService.getOfframpTransaction(transactionId); - const { status } = response; - - if (status === AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED) { - resolve(); - return; - } - - if (status === AlfredpayOfframpStatus.FAILED) { - reject({ failureReason: "Alfredpay reported FAILED status", kind: "failed" as const }); - return; - } - - logger.debug(`AlfredpayOfframpTransferHandler: Alfredpay offramp ${transactionId} status: ${status}`); - } catch (error) { - logger.warn( - `AlfredpayOfframpTransferHandler: Error polling Alfredpay status for ${transactionId}: ${error instanceof Error ? error.message : String(error)}` - ); - } - - setTimeout(poll, intervalMs); - }; - - poll(); - }); - } -} - -export default new AlfredpayOfframpTransferHandler(); diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts deleted file mode 100644 index 2b93aa10f..000000000 --- a/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - ALFREDPAY_ERC20_DECIMALS, - ALFREDPAY_ERC20_TOKEN, - AlfredpayApiService, - AlfredpayOnrampStatus, - BalanceCheckError, - BalanceCheckErrorType, - checkEvmBalancePeriodically, - Networks, - RampPhase -} from "@vortexfi/shared"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -const ALFREDPAY_ONRAMP_MINT_TIMEOUT_MS = 5 * 60 * 1000; -const BALANCE_POLL_INTERVAL_MS = 5000; -const ALFREDPAY_POLL_INTERVAL_MS = 5000; - -type AlfredpayFailedStatusError = { - failureReason?: string; - kind: "failed"; -}; - -function isAlfredpayFailedStatusError(error: unknown): error is AlfredpayFailedStatusError { - return !!error && typeof error === "object" && "kind" in error && error.kind === "failed"; -} - -export class AlfredpayOnrampMintHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "alfredpayOnrampMint"; - } - - protected async executePhase(state: RampState): Promise { - const { evmEphemeralAddress, alfredpayTransactionId } = state.state as StateMetadata; - - if (!evmEphemeralAddress) { - throw new Error("AlfredpayOnrampMintHandler: Missing evmEphemeralAddress in state. This is a bug."); - } - - if (!alfredpayTransactionId) { - throw new Error("AlfredpayOnrampMintHandler: Missing alfredpayTransactionId in state. This is a bug."); - } - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("AlfredpayOnrampMintHandler: Quote not found for the given state."); - } - - if (!quote.metadata.alfredpayMint?.outputAmountRaw) { - throw new Error("AlfredpayOnrampMintHandler: Missing 'alfredpayMint.outputAmountRaw' in quote metadata."); - } - - const expectedAmountRaw = quote.metadata.alfredpayMint.outputAmountRaw; - - logger.info( - `AlfredpayOnrampMintHandler: Waiting for ${expectedAmountRaw} (raw, ${ALFREDPAY_ERC20_DECIMALS} decimals) ` + - `on Polygon at ephemeral address ${evmEphemeralAddress}. Alfredpay transactionId: ${alfredpayTransactionId}` - ); - - const abortController = new AbortController(); - - const balanceCheckPromise = checkEvmBalancePeriodically( - ALFREDPAY_ERC20_TOKEN, - evmEphemeralAddress, - expectedAmountRaw, - BALANCE_POLL_INTERVAL_MS, - ALFREDPAY_ONRAMP_MINT_TIMEOUT_MS, - Networks.Polygon - ); - - const alfredpayPollingPromise = this.pollAlfredpayOnrampStatus( - alfredpayTransactionId, - state, - ALFREDPAY_POLL_INTERVAL_MS, - abortController.signal - ); - - // - balanceCheckPromise resolves when the USDC balance is met → proceed, or rejects if timeout → recoverable error. - // - alfredpayPollingPromise rejects if FAILED → transition to failed. Not recoverable - // (it does NOT resolve on ON_CHAIN_COMPLETED, because we trust the balance check) - try { - await Promise.race([balanceCheckPromise, alfredpayPollingPromise]); - } catch (error) { - if (isAlfredpayFailedStatusError(error)) { - logger.error(`AlfredpayOnrampMintHandler: Alfredpay onramp FAILED. Reason: ${error.failureReason ?? "unknown"}`); - return this.transitionToNextPhase(state, "failed"); - } - - if (error instanceof BalanceCheckError && error.type === BalanceCheckErrorType.Timeout) { - throw this.createRecoverableError( - `AlfredpayOnrampMintHandler: Balance check timed out after ${ALFREDPAY_ONRAMP_MINT_TIMEOUT_MS}ms` - ); - } - - // Safe to make generic recovery. - throw this.createRecoverableError( - `AlfredpayOnrampMintHandler: Failed to check balance or poll Alfredpay status: ${error instanceof Error ? error.message : String(error)}` - ); - } finally { - abortController.abort(); - } - - logger.info( - `AlfredpayOnrampMintHandler: Balance reached on Polygon ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.` - ); - - return this.transitionToNextPhase(state, "fundEphemeral"); - } - - private async pollAlfredpayOnrampStatus( - transactionId: string, - state: RampState, - intervalMs: number, - signal: AbortSignal - ): Promise { - const alfredpayApiService = AlfredpayApiService.getInstance(); - - return new Promise((_, reject) => { - let timeoutId: ReturnType | undefined; - - const onAbort = () => { - if (timeoutId) clearTimeout(timeoutId); - }; - signal.addEventListener("abort", onAbort, { once: true }); - - const poll = async () => { - if (signal.aborted) return; - - try { - const response = await alfredpayApiService.getOnrampTransaction(transactionId); - const { status, metadata } = response; - - if (status === AlfredpayOnrampStatus.FAILED) { - reject({ failureReason: metadata?.failureReason, kind: "failed" as const }); - return; - } - - if (status === AlfredpayOnrampStatus.ON_CHAIN_COMPLETED) { - // Save the txHash into ramp state, but do NOT resolve. - // We trust the balance check as ground truth for proceeding. - const txHash = metadata?.txHash; - if (txHash) { - const currentState = state.state as StateMetadata; - if (!currentState.alfredpayOnrampMintTxHash) { - await state.update({ - state: { - ...currentState, - alfredpayOnrampMintTxHash: txHash - } - }); - logger.info(`AlfredpayOnrampMintHandler: Saved alfredpayOnrampMintTxHash=${txHash} for ramp ${state.id}`); - } - } - return; - } - } catch (error) { - if (isAlfredpayFailedStatusError(error)) { - reject(error); - return; - } - - logger.warn(`AlfredpayOnrampMintHandler: Error polling Alfredpay status for ${transactionId}: ${error}`); - } - - timeoutId = setTimeout(poll, intervalMs); - }; - - poll(); - }); - } -} - -export default new AlfredpayOnrampMintHandler(); diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts deleted file mode 100644 index 2acd46860..000000000 --- a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { - AveniaPaymentMethod, - BalanceCheckError, - BalanceCheckErrorType, - BlockchainSendMethod, - BrlaApiService, - BrlaCurrency, - checkEvmBalancePeriodically, - EvmAddress, - EvmToken, - evmTokenConfig, - getEvmTokenBalance, - multiplyByPowerOfTen, - Networks, - RampPhase, - waitUntilTrueWithTimeout -} from "@vortexfi/shared"; -import Big from "big.js"; -import httpStatus from "http-status"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { APIError } from "../../../errors/api-error"; -import { findAveniaCustomerByTaxId } from "../../avenia/avenia-customer.service"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { syncAveniaOnHoldState } from "../helpers/brla-onramp-hold"; -import { StateMetadata } from "../meta-state-types"; - -// The check loops use a smaller timeout than the overall payment timeout so each execution -// returns to the outer process loop well below the processor's execution timeout and checks -// the operation timestamp there. The previous 30-minute Avenia wait always outlived the -// processor's 10-minute race, so the payment-timeout cancellation below never ran for -// recovered ramps and abandoned executions piled up. -const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes -const AVENIA_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes -const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes -const AVENIA_HOLD_STATUS_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute - -// The pre-computed expected amount stored at quote-creation time can be slightly higher than the -// amount actually transferred due to fee differences at execution time. We allow a 5% tolerance -// in the recovery shortcut so that an already-funded ephemeral is not missed. -const EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95; - -// Phase description: wait for the tokens to arrive at the Base ephemeral address. -// If the timeout is reached, we assume the user has NOT made the payment and we cancel the ramp. -export class BrlaOnrampMintHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "brlaOnrampMint"; - } - - protected async executePhase(state: RampState, signal?: AbortSignal): Promise { - const { evmEphemeralAddress } = state.state as StateMetadata; - - if (!evmEphemeralAddress) { - throw new Error("BrlaOnrampMintHandler: State metadata corrupted. This is a bug."); - } - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (!quote.metadata.aveniaMint) { - throw new Error("Missing 'aveniaMint' in quote metadata"); - } - - if (!quote.metadata.aveniaTransfer) { - throw new Error("Missing 'aveniaTransfer' in quote metadata"); - } - - const aveniaCustomer = await findAveniaCustomerByTaxId(state.state.taxId); - if (!aveniaCustomer) { - throw new APIError({ - message: "Subaccount not found", - status: httpStatus.BAD_REQUEST - }); - } - const aveniaSubAccountId = aveniaCustomer.providerSubaccountId ?? ""; - - const tokenDetails = evmTokenConfig[Networks.Base][EvmToken.BRLA]; - if (!tokenDetails) { - throw new Error("BRLA token details not found for Base network"); - } - - // Used only for the recovery shortcut below: the pre-computed metadata value is a - // reasonable upper-bound estimate of what should arrive at the ephemeral. The actual - // amount is determined by the live Avenia quote created later in this phase. - const preComputedExpectedAmountRaw = quote.metadata.aveniaTransfer.outputAmountRaw; - - // Recovery shortcut: a previous run may have already minted on Avenia and - // transferred to the ephemeral. We accept a balance of at least 95% of the - // pre-computed expected amount to account for fee differences between quote - // creation time and execution time. - const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw).times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0); - - if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw)) { - logger.info( - `BrlaOnrampMintHandler: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${preComputedExpectedAmountRaw} BRLA (threshold: ${recoveryThresholdRaw}). Skipping mint flow.` - ); - return this.transitionToNextPhase(state, "fundEphemeral"); - } - - const brlaApiService = BrlaApiService.getInstance(); - let lastAveniaHoldStatusCheckAt = 0; - try { - logger.info( - `BrlaOnrampMintHandler: Waiting for Avenia balance to have at least ${quote.metadata.aveniaMint.outputAmountDecimal} BRL` - ); - await waitUntilTrueWithTimeout( - async () => { - if (!quote.metadata.aveniaMint) { - return false; - } - - const now = Date.now(); - if (now - lastAveniaHoldStatusCheckAt >= AVENIA_HOLD_STATUS_CHECK_INTERVAL_MS) { - lastAveniaHoldStatusCheckAt = now; - const ticketFound = await syncAveniaOnHoldState( - state.state, - updatedState => - state.update({ - state: { - ...state.state, - ...updatedState - } - }), - brlaApiService, - aveniaSubAccountId - ); - if (!ticketFound) { - logger.warn( - `BrlaOnrampMintHandler: Avenia ticket ${state.state.aveniaTicketId} was not found while checking hold status.` - ); - } - } - - // Check internal balance of Avenia subaccount - const { balances } = await brlaApiService.getAccountBalance(aveniaSubAccountId); - if (!balances || balances.BRLA === undefined || balances.BRLA === null) { - return false; - } - return Number(balances.BRLA) >= Number(Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0)); - }, - 5000, - AVENIA_BALANCE_CHECK_TIMEOUT_MS, - signal - ); - } catch (error) { - const isCheckTimeout = error instanceof Error && error.message.includes("Timeout"); - if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { - logger.error("Payment timeout. Cancelling ramp."); - return this.transitionToNextPhase(state, "failed"); - } - - throw isCheckTimeout - ? this.createRecoverableError( - `BrlaOnrampMintHandler: phase timeout reached waiting for Avenia balance with error: ${error}` - ) - : new Error(`Error checking Avenia balance: ${error}`); - } - - // Transfer the funds from the subaccount to the ephemeral address - const aveniaQuote = await brlaApiService.createPayInQuote({ - blockchainSendMethod: BlockchainSendMethod.PERMIT, - inputAmount: Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0), - inputCurrency: BrlaCurrency.BRLA, - inputPaymentMethod: AveniaPaymentMethod.INTERNAL, - inputThirdParty: false, - outputCurrency: BrlaCurrency.BRLA, - outputPaymentMethod: AveniaPaymentMethod.BASE, - outputThirdParty: false, - subAccountId: aveniaSubAccountId - }); - - logger.info("BrlaOnrampMintHandler: Created Avenia pay-out quote for mint transfer."); - - // Derive the expected on-chain amount from the live quote's outputAmount rather than - // the stale pre-computed metadata value. The live quote accounts for the actual fees - // applied at execution time, so this is the amount that will truly arrive on Base. - const expectedAmountReceived = multiplyByPowerOfTen(new Big(aveniaQuote.outputAmount), tokenDetails.decimals).toFixed(0, 0); - - logger.info( - `BrlaOnrampMintHandler: Live Avenia quote output is ${aveniaQuote.outputAmount} BRLA (raw: ${expectedAmountReceived}). Pre-computed metadata value was ${preComputedExpectedAmountRaw}.` - ); - - const aveniaTicket = await brlaApiService.createPixOutputTicket( - { - quoteToken: aveniaQuote.quoteToken, - ticketBlockchainOutput: { - walletAddress: state.state.evmEphemeralAddress, - walletChain: AveniaPaymentMethod.BASE - } - }, - aveniaSubAccountId - ); - - logger.info( - `BrlaOnrampMintHandler: Created Avenia transfer ticket with id ${aveniaTicket.id} to transfer ${aveniaQuote.outputAmount} BRLA to Base address ${state.state.evmEphemeralAddress}` - ); - - try { - const pollingTimeMs = 1000; - - await checkEvmBalancePeriodically( - tokenDetails.erc20AddressSourceChain, - evmEphemeralAddress, - expectedAmountReceived, - pollingTimeMs, - EVM_BALANCE_CHECK_TIMEOUT_MS, - Networks.Base, - signal - ); - } catch (error) { - if (!(error instanceof BalanceCheckError)) throw error; - - const isCheckTimeout = error.type === BalanceCheckErrorType.Timeout; - if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { - logger.error("Payment timeout. Cancelling ramp."); - return this.transitionToNextPhase(state, "failed"); - } - - throw isCheckTimeout - ? this.createRecoverableError(`BrlaOnrampMintHandler: phase timeout reached with error: ${error}`) - : new Error(`Error checking Base balance: ${error}`); - } - - return this.transitionToNextPhase(state, "fundEphemeral"); - } - - private async ephemeralAlreadyFunded( - tokenAddress: string, - ownerAddress: string, - expectedAmountRaw: string - ): Promise { - try { - const balance = await getEvmTokenBalance({ - chain: Networks.Base, - ownerAddress: ownerAddress as EvmAddress, - tokenAddress: tokenAddress as EvmAddress - }); - return balance.gte(new Big(expectedAmountRaw)); - } catch (error) { - // Treat read failures as "not funded" so we fall through to the regular - // flow rather than aborting the phase on a transient RPC error. - logger.warn( - `BrlaOnrampMintHandler: ephemeral balance pre-check failed for ${ownerAddress}, falling back to Avenia flow: ${error}` - ); - return false; - } - } - - protected isPaymentTimeoutReached(state: RampState): boolean { - const thisPhaseEntry = state.phaseHistory.find(phaseHistoryEntry => phaseHistoryEntry.phase === this.getPhaseName()); - if (!thisPhaseEntry) { - throw new Error("BrlaOnrampMintHandler: Phase not found in history. This is a bug."); - } - - const initialTimestamp = new Date(thisPhaseEntry.timestamp); - if (initialTimestamp.getTime() + PAYMENT_TIMEOUT_MS < Date.now()) { - return true; - } - return false; - } -} - -export default new BrlaOnrampMintHandler(); 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 deleted file mode 100644 index 9e25a384b..000000000 --- a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { - AveniaTicketStatus, - BrlaApiService, - EvmClientManager, - isFiatTokenEnum, - Networks, - PixOutputTicketPayload, - RampPhase -} from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { PhaseError } from "../../../errors/phase-error"; -import { findAveniaCustomerByTaxId } from "../../avenia/avenia-customer.service"; -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); -} - -export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "brlaPayoutOnBase"; - } - - protected async executePhase(state: RampState): Promise { - const { taxId, pixDestination, payOutTicketId, brlaPayoutTxHash } = state.state as StateMetadata; - - if (!taxId || !pixDestination) { - throw new Error("BrlaPayoutOnBasePhaseHandler: State metadata corrupted. This is a bug."); - } - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - const outputAmount = quote.outputAmount; - const outputCurrency = quote.outputCurrency; - - const aveniaCustomer = await findAveniaCustomerByTaxId(taxId); - if (!aveniaCustomer) { - throw new Error("BrlaPayoutOnBasePhaseHandler: SubaccountId must exist at this stage. This is a bug."); - } - const aveniaSubAccountId = aveniaCustomer.providerSubaccountId ?? ""; - - if (!isFiatTokenEnum(outputCurrency)) { - throw new Error("BrlaPayoutOnBasePhaseHandler: Invalid token type."); - } - - if (!quote.metadata.nablaSwapEvm?.outputAmountDecimal) { - throw new Error("BrlaPayoutOnBasePhaseHandler: Missing nablaSwapEvm metadata."); - } - - const amountForPayout = quote.metadata.nablaSwapEvm.outputAmountDecimal; - - const brlaApiService = BrlaApiService.getInstance(); - - // We need to check for existing ticket, recovery scenario - if (payOutTicketId) { - await this.checkTicketStatusPaid({ subAccountId: aveniaSubAccountId, ticketId: payOutTicketId }); - return this.transitionToNextPhase(state, "complete"); - } - - // send the "final destination" - await this.sendBrlaPayoutTransaction(state, brlaPayoutTxHash); - - const pollForSufficientBalance = async () => { - const pollInterval = 5000; // 5 seconds - const timeout = 5 * 60 * 1000; // 5 minutes - const startTime = Date.now(); - let lastError: unknown; - - while (Date.now() - startTime < timeout) { - try { - const balanceResponse = await brlaApiService.getAccountBalance(aveniaSubAccountId); - if (balanceResponse && balanceResponse.balances && balanceResponse.balances.BRLA !== undefined) { - if (new Big(balanceResponse.balances.BRLA).gte(Big(amountForPayout).round(2, 0))) { - // compare with rounded down amount. - logger.info(`Sufficient BRLA balance found: ${balanceResponse.balances.BRLA}`); - return balanceResponse; - } - logger.info( - `Insufficient BRLA balance. Needed units: ${ - amountForPayout - }, have (in units): ${new Big(balanceResponse.balances.BRLA).toString()}. Retrying in 5s...` - ); - } - } catch (error) { - lastError = error; - logger.warn("Polling for balance failed with error. Retrying...", lastError); - } - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - if (lastError) { - logger.error("BrlaPayoutOnBasePhaseHandler: Polling for balance failed: ", lastError); - throw lastError; - } - throw new Error( - `BrlaPayoutOnBasePhaseHandler: Balance check timed out after 5 minutes. Needed ${amountForPayout} units.` - ); - }; - - await pollForSufficientBalance(); - - try { - const amount = new Big(outputAmount); - const subaccount = await brlaApiService.subaccountInfo(aveniaSubAccountId); - if (!subaccount) { - throw new Error("BrlaPayoutOnBasePhaseHandler: Subaccount must exist."); - } - const subaccountEvmAddress = subaccount.wallets.filter(wallet => wallet.chain === "EVM")[0]; - - const amountForQuote = amount.round(2, 0); // Round down to 2 decimal places - const payOutQuote = await brlaApiService.createPayOutQuote({ - outputAmount: amountForQuote.toString(), - outputThirdParty: false, - subAccountId: aveniaSubAccountId - }); - - const payOutTicketParams: PixOutputTicketPayload = { - quoteToken: payOutQuote.quoteToken, - ticketBlockchainInput: { - walletAddress: subaccountEvmAddress.walletAddress - }, - ticketBrlPixOutput: { - pixKey: pixDestination - } - }; - const { id: payOutTicketId } = await brlaApiService.createPixOutputTicket(payOutTicketParams, aveniaSubAccountId); - logger.debug("Debug: payOutTicketId", payOutTicketId); - // Update the state with the transaction hashes - await state.update({ - state: { - ...state.state, - payOutTicketId - } - }); - - await this.checkTicketStatusPaid({ subAccountId: aveniaSubAccountId, ticketId: payOutTicketId }); - return this.transitionToNextPhase(state, "complete"); - } catch (e) { - logger.error("Error in brlaPayoutOnBase", e); - throw this.createUnrecoverableError("BrlaPayoutOnBasePhaseHandler: Failed to trigger BRLA offramp."); - } - } - - private async sendBrlaPayoutTransaction(state: RampState, brlaPayoutTxHash?: `0x${string}`): Promise { - try { - const evmClientManager = EvmClientManager.getInstance(); - const baseClient = evmClientManager.getClient(Networks.Base); - const { txData: brlaPayoutTx } = this.getPresignedTransaction(state, "brlaPayoutOnBase"); - - if (!brlaPayoutTx) { - throw new Error("Missing presigned transaction for brlaPayoutOnBase"); - } - - let txHash: `0x${string}`; - - if (brlaPayoutTxHash) { - // Check existing transaction status - logger.info( - `BrlaPayoutOnBasePhaseHandler: Found existing transaction hash ${brlaPayoutTxHash}. Waiting for receipt...` - ); - const receipt = await baseClient.waitForTransactionReceipt({ hash: brlaPayoutTxHash }); - - if (receipt.status !== "success") { - logger.warn( - `BrlaPayoutOnBasePhaseHandler: Existing transaction ${brlaPayoutTxHash} failed. Sending new transaction...` - ); - - txHash = (await evmClientManager.sendRawTransactionWithRetry( - Networks.Base, - brlaPayoutTx as `0x${string}` - )) as `0x${string}`; - - const newReceipt = await baseClient.waitForTransactionReceipt({ hash: txHash }); - - if (newReceipt.status !== "success") { - throw new Error(`Transaction ${txHash} failed on chain`); - } - logger.info(`BrlaPayoutOnBasePhaseHandler: New transaction ${txHash} succeeded.`); - - await state.update({ - state: { - ...state.state, - brlaPayoutTxHash: txHash - } - }); - } else { - 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}` - )) as `0x${string}`; - logger.info(`BrlaPayoutOnBasePhaseHandler: Transaction sent with hash ${txHash}. Waiting for receipt...`); - const receipt = await baseClient.waitForTransactionReceipt({ hash: txHash }); - - if (receipt.status !== "success") { - throw new Error(`Transaction ${txHash} failed on chain`); - } - logger.info(`BrlaPayoutOnBasePhaseHandler: Transaction ${txHash} succeeded.`); - - // Store hash in state - await state.update({ - state: { - ...state.state, - brlaPayoutTxHash: txHash - } - }); - } - } 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"); - } - } - - protected async checkTicketStatusPaid({ - ticketId, - subAccountId - }: { - ticketId: string; - subAccountId: string; - }): Promise { - const brlaApiService = BrlaApiService.getInstance(); - const pollInterval = 5000; // 5 seconds - const timeout = 5 * 60 * 1000; // 5 minutes - const startTime = Date.now(); - let lastError: unknown; - - while (Date.now() - startTime < timeout) { - try { - const ticket = await brlaApiService.getAveniaPayoutTicket(ticketId, subAccountId); - if (ticket && ticket.status) { - logger.info("Debug: fetched ticket", ticket); - if (ticket.status === AveniaTicketStatus.PAID) { - return AveniaTicketStatus.PAID; - } - if (ticket.status === AveniaTicketStatus.FAILED) { - throw this.createUnrecoverableError("BrlaPayoutOnBasePhaseHandler: Ticket status is FAILED"); - } - } - } catch (error) { - if (error instanceof PhaseError) { - throw error; - } - lastError = error; - logger.warn(`Polling for ticket ${ticketId} status failed with error. Retrying...`, lastError); - } - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - - if (lastError) { - logger.error("BrlaPayoutOnBasePhaseHandler: Polling for ticket status timed out with an error: ", lastError); - throw this.createUnrecoverableError( - `BrlaPayoutOnBasePhaseHandler: Polling for ticket status timed out with an error: ${getErrorMessage(lastError)}` - ); - } - - throw this.createRecoverableError("BrlaPayoutOnBasePhaseHandler: Polling for ticket status timed out."); - } -} - -export default new BrlaPayoutOnBasePhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts deleted file mode 100644 index eeb09cf77..000000000 --- a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { - checkEvmBalanceForToken, - EvmClientManager, - EvmNetworks, - EvmTokenDetails, - getOnChainTokenDetails, - multiplyByPowerOfTen, - RampPhase -} from "@vortexfi/shared"; -import { decodeFunctionData, erc20Abi, parseTransaction } from "viem"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { UnrecoverablePhaseError } from "../../../errors/phase-error"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -const BALANCE_POLLING_TIME_MS = 5000; -const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes - -function validateDestinationTransferRecipient(rawTx: `0x${string}`, expectedDestination: string): void { - const decoded = parseTransaction(rawTx); - - if (!decoded.to) { - throw new Error("DestinationTransferHandler: Presigned transaction has no 'to' address"); - } - - const isNativeTransfer = !decoded.data || decoded.data === "0x"; - - if (isNativeTransfer) { - if (decoded.to.toLowerCase() !== expectedDestination.toLowerCase()) { - throw new Error( - "DestinationTransferHandler: Native transfer recipient mismatch. " + - `Expected ${expectedDestination}, got ${decoded.to}` - ); - } - return; - } - - // ERC-20 transfer: `to` is the token contract, recipient is in calldata - if (!decoded.data) { - throw new Error("DestinationTransferHandler: ERC-20 transfer missing calldata"); - } - const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: decoded.data }); - if (functionName !== "transfer") { - throw new Error(`DestinationTransferHandler: Expected ERC-20 'transfer' call, got '${functionName}'`); - } - - const [recipient] = args as [string, bigint]; - if (recipient.toLowerCase() !== expectedDestination.toLowerCase()) { - throw new Error( - "DestinationTransferHandler: ERC-20 transfer recipient mismatch. " + `Expected ${expectedDestination}, got ${recipient}` - ); - } -} - -/** - * Handler for transferring funds to the destination address on EVM networks (onramp only) - */ -export class DestinationTransferHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "destinationTransfer"; - } - - protected async executePhase(state: RampState): Promise { - const evmClientManager = EvmClientManager.getInstance(); - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; - if (!outTokenDetails) { - throw new Error( - `DestinationTransferHandler: Unsupported output token ${quote.outputCurrency} for network ${quote.network}` - ); - } - - const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); - const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toFixed(0, 0); - const destinationNetwork = quote.network as EvmNetworks; // We can assert this type due to checks before - const { destinationTransferTxHash, destinationAddress } = state.state as StateMetadata; - - if (destinationAddress) { - validateDestinationTransferRecipient(destinationTransfer as `0x${string}`, destinationAddress); - } else { - logger.warn("DestinationTransferHandler: No destinationAddress in state metadata, skipping recipient validation"); - } - if (destinationTransferTxHash) { - try { - const client = evmClientManager.getClient(destinationNetwork); - const receipt = await client.getTransactionReceipt({ hash: destinationTransferTxHash as `0x${string}` }); - - if (receipt.status === "success") { - return this.transitionToNextPhase(state, "complete"); - } else { - throw new Error(`Transaction ${destinationTransferTxHash} failed on chain.`); - } - } catch (error) { - if (error instanceof Error && error.name !== "TransactionReceiptNotFoundError") { - throw error; - } - // If receipt not found, proceed to normal flow - } - } - - // Nonce-gap guard: a presigned nonce ahead of the live ephemeral nonce can never be mined and would - // silently retry until the processor gives up, stranding user funds. Raise it for manual review. - // Reading the live nonce is best-effort: an RPC failure must not block the happy path. - if (!destinationTransferTxHash && state.state.evmEphemeralAddress) { - try { - const presignedNonce = parseTransaction(destinationTransfer as `0x${string}`).nonce; - if (presignedNonce !== undefined) { - try { - const liveNonce = await evmClientManager.getClient(destinationNetwork).getTransactionCount({ - address: state.state.evmEphemeralAddress as `0x${string}`, - blockTag: "pending" - }); - if (presignedNonce > liveNonce) { - throw this.createUnrecoverableError( - `DestinationTransferHandler: presigned nonce ${presignedNonce} is ahead of the ephemeral live nonce ${liveNonce}. ` + - "The transfer can never broadcast (nonce gap); manual review required." - ); - } - } catch (error) { - if (error instanceof UnrecoverablePhaseError) { - throw error; - } - logger.warn( - `DestinationTransferHandler: could not verify ephemeral nonce before broadcast - ${(error as Error).message}` - ); - } - } - } catch (error) { - if (error instanceof UnrecoverablePhaseError) { - throw error; - } - logger.warn( - `DestinationTransferHandler: could not parse presigned destination transfer for nonce check - ${(error as Error).message}` - ); - } - } - - // main phase execution loop: - try { - await checkEvmBalanceForToken({ - amountDesiredRaw: expectedAmountRaw, - chain: destinationNetwork, - intervalMs: BALANCE_POLLING_TIME_MS, - ownerAddress: state.state.evmEphemeralAddress, - timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, - tokenDetails: outTokenDetails - }); - - // send the transaction, log hash in the state for recovery. - const txHash = await evmClientManager.sendRawTransactionWithRetry( - quote.network as EvmNetworks, - destinationTransfer as `0x${string}` - ); - // store in state - await state.update({ - state: { - ...state.state, - destinationTransferTxHash: txHash - } - }); - // (optional) wait for balance to be updated on user - destination - - return this.transitionToNextPhase(state, "complete"); - } catch (error) { - throw this.createRecoverableError( - `DestinationTransferHandler: Error during phase execution - ${(error as Error).message}` - ); - } - } -} - -export default new DestinationTransferHandler(); diff --git a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts deleted file mode 100644 index b88456f7f..000000000 --- a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { ApiPromise } from "@polkadot/api"; -import { SubmittableExtrinsic } from "@polkadot/api/promise/types"; -import { DispatchError, EventRecord } from "@polkadot/types/interfaces"; -import { ISubmittableResult } from "@polkadot/types/types"; -import { - ApiManager, - checkEvmBalanceForToken, - decodeSubmittableExtrinsic, - EvmClientManager, - EvmNetworks, - EvmToken, - EvmTokenDetails, - evmTokenConfig, - getNetworkFromDestination, - multiplyByPowerOfTen, - Networks, - PENDULUM_USDC_ASSETHUB, - PENDULUM_USDC_AXL, - RampDirection, - RampPhase, - TransactionTemporarilyBannedError, - waitUntilTrueWithTimeout -} from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { PhaseError } from "../../../errors/phase-error"; -import { fetchWithTimeout } from "../../../helpers/fetchWithTimeout"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -const FEE_BALANCE_POLL_INTERVAL_MS = 5_000; -const FEE_BALANCE_POLL_TIMEOUT_MS = 60_000; - -/** - * Enum for extrinsic status check results - */ -enum ExtrinsicStatus { - Success = "success", - Fail = "fail", - Undefined = "undefined" -} - -/** - * Handler for distributing Network, Vortex, and Partner fees using a stablecoin on Pendulum or EVM chains - */ -export class DistributeFeesHandler extends BasePhaseHandler { - private apiManager: ApiManager; - - constructor() { - super(); - this.apiManager = ApiManager.getInstance(); - } - - /** - * Get the phase name - */ - public getPhaseName(): RampPhase { - return "distributeFees"; - } - - /** - * Execute the phase - * @param state The current ramp state - * @returns The next phase and any output - */ - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findOne({ where: { id: state.quoteId } }); - if (!quote) { - throw this.createUnrecoverableError(`Quote ticket not found for ID: ${state.quoteId}`); - } - - // Determine next phase - const nextPhase = state.type === RampDirection.BUY ? "subsidizePostSwap" : "subsidizePreSwap"; - - // Check if we already have a hash stored - const existingHash = state.state.distributeFeeHash || null; - - // For EVM-ephemeral flows (BRL, Mykobo EUR, ...), distribution happens on EVM (Base). - const isEvmTransaction = !!quote.metadata.nablaSwapEvm; - const evmNetwork = isEvmTransaction ? (Networks.Base as EvmNetworks) : undefined; - - if (existingHash) { - logger.info(`Found existing distribute fee hash for ramp ${state.id}: ${existingHash}`); - - if (isEvmTransaction && evmNetwork) { - const status = await this.checkEvmTransactionStatus(existingHash, evmNetwork).catch((_: unknown) => { - throw this.createRecoverableError("Failed to check EVM transaction status from existing hash."); - }); - - if (status === ExtrinsicStatus.Success) { - logger.info(`Existing distribute fee EVM transaction was successful for ramp ${state.id}`); - return this.transitionToNextPhase(state, nextPhase); - } else { - logger.info(`Existing distribute fee EVM transaction was not successful (status: ${status}), will retry`); - } - } else { - const status = await this.checkExtrinsicStatus(existingHash).catch((_: unknown) => { - throw this.createRecoverableError("Failed to check extrinsic status from existing hash."); - }); - - if (status === ExtrinsicStatus.Success) { - logger.info(`Existing distribute fee transaction was successful for ramp ${state.id}`); - return this.transitionToNextPhase(state, nextPhase); - } else { - logger.info(`Existing distribute fee transaction was not successful (status: ${status}), will retry`); - } - } - } - - try { - // Get the pre-signed fee distribution transaction. - const distributeFeeTransaction = this.getPresignedTransaction(state, "distributeFees"); - if (distributeFeeTransaction === undefined) { - logger.info("No fee distribution transaction data found. Skipping fee distribution."); - return this.transitionToNextPhase(state, nextPhase); - } - - // The funding token (USDC) may not yet be on the ephemeral when we reach this phase - // (e.g. squidrouter swap can be slow to credit). Poll for it before submitting; if it - // never arrives within the timeout, throw a recoverable error so we retry the phase. - await this.ensureFeeTokenBalance(state, quote, distributeFeeTransaction.signer, isEvmTransaction); - - let actualTxHash: string; - - if (isEvmTransaction) { - logger.info(`Submitting EVM fee distribution transaction for ramp ${state.id}...`); - actualTxHash = await this.submitEvmRawTransaction( - distributeFeeTransaction.txData as string, - distributeFeeTransaction.network as EvmNetworks - ); - } else { - const { api } = await this.apiManager.getApi("pendulum"); - const decodedTx = decodeSubmittableExtrinsic(distributeFeeTransaction.txData as string, api); - - logger.info(`Submitting substrate fee distribution transaction for ramp ${state.id}...`); - actualTxHash = await this.submitTransaction(decodedTx, api); - } - - logger.info(`Transaction broadcast with hash ${actualTxHash}. Persisting hash...`); - - // Persist the hash from the submission result - const updatedState = await state.update({ - state: { - ...state.state, - distributeFeeHash: actualTxHash - } - }); - - // Wait for transaction success - if (isEvmTransaction) { - await this.waitForEvmTransactionSuccess(actualTxHash, distributeFeeTransaction.network as EvmNetworks); - } else { - await this.waitForExtrinsicSuccess(actualTxHash); - } - - logger.info(`Successfully verified fee distribution transaction for ramp ${state.id}: ${actualTxHash}`); - return this.transitionToNextPhase(updatedState, nextPhase); - } catch (e: unknown) { - logger.error(`Error distributing fees for ramp ${state.id}:`, e); - - // If the error is already a PhaseError, propagate it - if (e instanceof PhaseError) { - throw e; - } - - // Wrap as recoverable error - const error = e instanceof Error ? e : new Error(String(e)); - throw this.createRecoverableError(`Failed to distribute fees: ${error.message || "Unknown error"}`); - } - } - - private computeRequiredFeeRaw(quote: QuoteTicket, decimals: number): Big | null { - const usdFeeStructure = quote.metadata.fees?.usd; - if (!usdFeeStructure) { - return null; - } - - const totalUsd = new Big(usdFeeStructure.network).plus(usdFeeStructure.vortex).plus(usdFeeStructure.partnerMarkup); - if (totalUsd.lte(0)) { - return null; - } - - return multiplyByPowerOfTen(totalUsd, decimals); - } - - private async ensureFeeTokenBalance( - state: RampState, - quote: QuoteTicket, - signerAddress: string, - isEvmTransaction: boolean - ): Promise { - if (isEvmTransaction) { - await this.ensureEvmFeeTokenBalance(quote, signerAddress); - } else { - await this.ensureSubstrateFeeTokenBalance(state, quote); - } - } - - private async ensureEvmFeeTokenBalance(quote: QuoteTicket, signerAddress: string): Promise { - const baseUsdcConfig = evmTokenConfig[Networks.Base][EvmToken.USDC] as EvmTokenDetails | undefined; - if (!baseUsdcConfig) { - throw this.createUnrecoverableError("Base USDC configuration not found; cannot verify fee balance."); - } - - const requiredRaw = this.computeRequiredFeeRaw(quote, baseUsdcConfig.decimals); - if (!requiredRaw) { - logger.info("No positive USD fees configured; skipping fee balance precondition check."); - return; - } - - logger.info( - `Checking EVM fee balance: signer=${signerAddress} requires >= ${requiredRaw.toFixed(0)} USDC raw on Base before submitting fee distribution.` - ); - - try { - const balance = await checkEvmBalanceForToken({ - amountDesiredRaw: requiredRaw.toFixed(0), - chain: Networks.Base as EvmNetworks, - intervalMs: FEE_BALANCE_POLL_INTERVAL_MS, - ownerAddress: signerAddress, - timeoutMs: FEE_BALANCE_POLL_TIMEOUT_MS, - tokenDetails: baseUsdcConfig - }); - logger.info(`EVM fee balance precondition met: balance=${balance.toFixed(0)} >= required=${requiredRaw.toFixed(0)}`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw this.createRecoverableError( - `Fee distribution precondition failed: USDC balance not available on ${signerAddress} within ${FEE_BALANCE_POLL_TIMEOUT_MS}ms. ${message}` - ); - } - } - - private async ensureSubstrateFeeTokenBalance(state: RampState, quote: QuoteTicket): Promise { - const { substrateEphemeralAddress } = state.state as StateMetadata; - if (!substrateEphemeralAddress) { - throw this.createUnrecoverableError( - "DistributeFeesHandler: Missing substrateEphemeralAddress in state; cannot verify substrate fee balance." - ); - } - - // Network reference matches the selection in createSubstrateFeeDistributionTransaction: - // offramp uses source network, onramp uses destination network. The chosen stablecoin - // (PENDULUM_USDC_ASSETHUB vs PENDULUM_USDC_AXL) MUST match what the presigned tx transfers. - const networkReference = state.type === RampDirection.SELL ? quote.from : quote.to; - const network = getNetworkFromDestination(networkReference); - if (!network) { - logger.warn(`DistributeFeesHandler: Invalid network for ${networkReference}; skipping balance precondition check.`); - return; - } - - const stablecoinDetails = network === Networks.AssetHub ? PENDULUM_USDC_ASSETHUB : PENDULUM_USDC_AXL; - const requiredRaw = this.computeRequiredFeeRaw(quote, stablecoinDetails.decimals); - if (!requiredRaw) { - logger.info("No positive USD fees configured; skipping fee balance precondition check."); - return; - } - - logger.info( - `Checking substrate fee balance: address=${substrateEphemeralAddress} requires >= ${requiredRaw.toFixed(0)} ${stablecoinDetails.assetSymbol} raw on Pendulum before submitting fee distribution.` - ); - - const apiManager = ApiManager.getInstance(); - const pendulumNode = await apiManager.getApi("pendulum"); - - const isBalanceSufficient = async (): Promise => { - try { - const balanceResponse = await pendulumNode.api.query.tokens.accounts( - substrateEphemeralAddress, - stablecoinDetails.currencyId - ); - const free = new Big((balanceResponse as unknown as { free?: { toString(): string } })?.free?.toString() ?? "0"); - return free.gte(requiredRaw); - } catch (err) { - logger.debug(`DistributeFeesHandler: error reading substrate balance: ${err instanceof Error ? err.message : err}`); - return false; - } - }; - - try { - await waitUntilTrueWithTimeout(isBalanceSufficient, FEE_BALANCE_POLL_INTERVAL_MS, FEE_BALANCE_POLL_TIMEOUT_MS); - logger.info( - `Substrate fee balance precondition met for ${substrateEphemeralAddress} (>= ${requiredRaw.toFixed(0)} ${stablecoinDetails.assetSymbol}).` - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw this.createRecoverableError( - `Fee distribution precondition failed: ${stablecoinDetails.assetSymbol} balance not available on ${substrateEphemeralAddress} within ${FEE_BALANCE_POLL_TIMEOUT_MS}ms. ${message}` - ); - } - } - - /** - * Wait for extrinsic success using Subscan API - * @param extrinsicHash The extrinsic hash to check - */ - private async waitForExtrinsicSuccess(extrinsicHash: string): Promise { - const startTime = Date.now(); - const timeoutMs = 180000; // 3 minutes - const pollIntervalMs = 10000; // 10 seconds - - while (Date.now() - startTime < timeoutMs) { - try { - const status = await this.checkExtrinsicStatus(extrinsicHash); - - if (status === ExtrinsicStatus.Success) { - return; - } else if (status === ExtrinsicStatus.Fail) { - await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); - continue; - } else if (status === ExtrinsicStatus.Undefined) { - await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); - continue; - } - } catch (error: unknown) { - throw error; - } - } - - throw this.createRecoverableError(`Extrinsic status check timed out for hash ${extrinsicHash}`); - } - - /** - * Handle dispatch errors from extrinsic submissions - * @param api The API instance - * @param dispatchError The dispatch error - * @param systemExtrinsicFailedEvent The system extrinsic failed event record - * @param extrinsicCalled The name of the extrinsic that was called - * @returns An error with details about the failure - */ - private async handleDispatchError( - api: ApiPromise, - dispatchError: DispatchError, - systemExtrinsicFailedEvent: EventRecord | undefined, - extrinsicCalled: string - ): Promise { - if (dispatchError?.isModule) { - const decoded = api.registry.findMetaError(dispatchError.asModule); - const { name, section, method } = decoded; - - return new Error(`Dispatch error: ${section}.${method}:: ${name}`); - } - - if (systemExtrinsicFailedEvent) { - const eventName = - systemExtrinsicFailedEvent?.event.data && systemExtrinsicFailedEvent?.event.data.length > 0 - ? systemExtrinsicFailedEvent?.event.data[0].toString() - : "Unknown"; - - const { - phase, - event: { method, section } - } = systemExtrinsicFailedEvent; - logger.error(`Extrinsic failed in phase ${phase.toString()} with ${section}.${method}:: ${eventName}`); - - return new Error(`Failed to dispatch ${extrinsicCalled}`); - } - - logger.error(`Encountered some other error: ${dispatchError?.toString()}, ${JSON.stringify(dispatchError)}`); - return new Error(`Unknown error during ${extrinsicCalled}`); - } - - /** - * Submit a transaction to the blockchain - * @param tx The transaction to submit - * @param api The API instance - * @returns The transaction hash when included in block - */ - private async submitTransaction(tx: SubmittableExtrinsic, api: ApiPromise): Promise { - logger.debug(`Submitting transaction to Pendulum for ${this.getPhaseName()} phase`); - - return await new Promise((resolve, reject) => - tx - .send((submissionResult: ISubmittableResult) => { - const { status, events, dispatchError, txHash } = submissionResult; - - // Try to find a 'system.ExtrinsicFailed' event - const systemExtrinsicFailedEvent = events.find( - record => record.event.section === "system" && record.event.method === "ExtrinsicFailed" - ); - - if (dispatchError) { - reject(this.handleDispatchError(api, dispatchError, systemExtrinsicFailedEvent, "distributeFees")); - } - - if (status.isBroadcast) { - logger.info(`Transaction broadcasted: ${status.asBroadcast.toString()}`); - resolve(txHash.toHex()); - } - if (status.isInBlock) { - logger.info(`Transaction in block: ${status.asInBlock.toString()}`); - resolve(txHash.toHex()); - } - }) - .catch((error: unknown) => { - logger.error("Error submitting transaction to distribute fees:", error); - // 1012 means that the extrinsic is temporarily banned and indicates that the extrinsic was already sent - if (error instanceof Error && error.message.includes("1012:")) { - return reject(new TransactionTemporarilyBannedError("Transaction for transfer is temporarily banned.")); - } - reject(new Error(`Failed to do transfer: ${error instanceof Error ? error.message : String(error)}`)); - }) - ); - } - - /** - * Check extrinsic status using Subscan API - * @param extrinsicHash The extrinsic hash to check - * @returns ExtrinsicStatus: Success, Fail, or Undefined - */ - private async checkExtrinsicStatus(extrinsicHash: string): Promise { - try { - const response = await fetchWithTimeout("https://pendulum.api.subscan.io/api/scan/extrinsic", { - body: JSON.stringify({ - events_limit: 10, - hash: extrinsicHash, - hide_events: false - }), - headers: { - "Content-Type": "application/json", - "x-api-key": config.subscanApiKey || "" - }, - method: "POST" - }); - - if (!response.ok) { - logger.error(`Subscan API error: ${response.status} ${response.statusText}`); - throw new Error(`API response error: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - logger.info("Subscan response data:", data); - - if (data.code !== 0) { - logger.error(`Subscan API returned error code: ${data.code}, message: ${data.message}`); - throw new Error(`Subscan API error code: ${data.code}, message: ${data.message}`); - } - - if (data.data?.success === true) { - return ExtrinsicStatus.Success; - } - - if (data.data?.success === false) { - return ExtrinsicStatus.Fail; - } - - return ExtrinsicStatus.Undefined; - } catch (error: unknown) { - logger.error(`Error checking extrinsic status with Subscan: ${error}`); - throw error; - } - } - - /** - * Submit a presigned EVM raw transaction - * @param serializedTransaction The signed serialized transaction - * @param network The EVM network - * @returns The transaction hash - */ - private async submitEvmRawTransaction(serializedTransaction: string, network: EvmNetworks): Promise { - logger.debug(`Broadcasting presigned EVM transaction to ${network} for ${this.getPhaseName()} phase`); - - if (typeof serializedTransaction !== "string" || !serializedTransaction.startsWith("0x")) { - throw new Error(`Invalid presigned EVM transaction data for ${this.getPhaseName()} phase`); - } - - const evmClientManager = EvmClientManager.getInstance(); - return await evmClientManager.sendRawTransactionWithRetry(network, serializedTransaction as `0x${string}`); - } - - /** - * Wait for EVM transaction success - * @param txHash The transaction hash - * @param network The EVM network - */ - private async waitForEvmTransactionSuccess(txHash: string, network: EvmNetworks): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const publicClient = evmClientManager.getClient(network); - - await waitUntilTrueWithTimeout( - async () => { - try { - const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); - return receipt?.status === "success"; - } catch (error) { - logger.debug(`Error checking EVM transaction receipt: ${error}`); - return false; - } - }, - 2000, // check every 2 seconds - 180000 // timeout after 3 minutes - ); - } - - /** - * Check EVM transaction status - * @param txHash The transaction hash - * @param network The EVM network where the transaction was submitted - * @returns ExtrinsicStatus: Success, Fail, or Undefined - */ - private async checkEvmTransactionStatus(txHash: string, network: EvmNetworks): Promise { - try { - const evmClientManager = EvmClientManager.getInstance(); - const publicClient = evmClientManager.getClient(network); - - const receipt = await publicClient.getTransactionReceipt({ hash: txHash as `0x${string}` }); - - if (receipt) { - if (receipt.status === "success") { - return ExtrinsicStatus.Success; - } else { - return ExtrinsicStatus.Fail; - } - } - - return ExtrinsicStatus.Undefined; - } catch (error: unknown) { - logger.error(`Error checking EVM transaction status: ${error}`); - return ExtrinsicStatus.Undefined; - } - } -} - -export default new DistributeFeesHandler(); 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 deleted file mode 100644 index ff88870de..000000000 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.helpers.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index f0279d871..000000000 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.helpers.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index a5f3572bc..000000000 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { - ALFREDPAY_EVM_TOKEN, - checkEvmBalanceForToken, - EvmClientManager, - EvmNetworks, - EvmToken, - EvmTokenDetails, - FiatToken, - getEvmBalance, - getNetworkId, - getOnChainTokenDetails, - getRoute, - isAlfredpayToken, - isNativeEvmToken, - multiplyByPowerOfTen, - NATIVE_TOKEN_ADDRESS, - Networks, - nativeToDecimal, - RampCurrency, - RampDirection, - RampPhase, - TokenType -} from "@vortexfi/shared"; -import Big from "big.js"; -import { encodeFunctionData, erc20Abi, TransactionReceipt } from "viem"; -import { generatePrivateKey, privateKeyToAddress } from "viem/accounts"; -import logger from "../../../../config/logger"; -import { MAX_FINAL_SETTLEMENT_SUBSIDY_USD } from "../../../../constants/constants"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -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; - -const NATIVE_TOKENS: Record = { - [Networks.Ethereum]: { decimals: 18, symbol: "ETH" }, - [Networks.Polygon]: { decimals: 18, symbol: "MATIC" }, - [Networks.PolygonAmoy]: { decimals: 18, symbol: "MATIC" }, - [Networks.BSC]: { decimals: 18, symbol: "BNB" }, - [Networks.Arbitrum]: { decimals: 18, symbol: "ETH" }, - [Networks.Base]: { decimals: 18, symbol: "ETH" }, - [Networks.Avalanche]: { decimals: 18, symbol: "AVAX" }, - [Networks.Moonbeam]: { decimals: 18, symbol: "GLMR" }, - [Networks.BaseSepolia]: { decimals: 18, symbol: "ETH" } -}; - -/** - * Handler for transferring funds to the destination address on EVM networks (onramp only) - */ -export class FinalSettlementSubsidyHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "finalSettlementSubsidy"; - } - - private getNextPhase(state: RampState, quote: QuoteTicket): RampPhase { - return state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken) - ? "alfredpayOfframpTransfer" - : "destinationTransfer"; - } - - protected async executePhase(state: RampState): Promise { - logger.debug(`FinalSettlementSubsidyHandler: Starting phase execution for ramp ${state.id}, type=${state.type}`); - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("FinalSettlementSubsidyHandler: Quote not found for the given state"); - } - - if ( - state.state.isDirectTransfer === true && - !(state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) - ) { - logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for direct-transfer ramp ${state.id}`); - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); - } - - const evmClientManager = EvmClientManager.getInstance(); - const fundingAccount = getEvmFundingAccount(Networks.Moonbeam); - - logger.debug( - `FinalSettlementSubsidyHandler: Quote found. inputCurrency=${quote.inputCurrency}, outputCurrency=${quote.outputCurrency}, network=${quote.network}` - ); - - if (isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) { - logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for Base direct-transfer route (ramp ${state.id})`); - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); - } - - const isAlfredpaySell = state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken); - - const outTokenDetails = - state.type === RampDirection.BUY - ? (getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails) - : isAlfredpaySell - ? getOnChainTokenDetails(Networks.Polygon, ALFREDPAY_EVM_TOKEN) - : getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC); - - if (!outTokenDetails || outTokenDetails.type === TokenType.AssetHub) { - // Should not happen. Destination onchain token or USDC must be defined. - throw new Error("FinalSettlementSubsidyHandler: Output currency is not an EVM token"); - } - - const isNative = isNativeEvmToken(outTokenDetails); - - let expectedAmountRaw: Big | undefined; - switch (state.type) { - case RampDirection.BUY: - expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); - break; - case RampDirection.SELL: - if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { - if (!quote.metadata.alfredpayOfframp) { - throw new Error("FinalSettlementSubsidyHandler: Missing Alfredpay offramp metadata"); - } - expectedAmountRaw = Big(quote.metadata.alfredpayOfframp.inputAmountRaw); - break; - } - break; - } - - if (!expectedAmountRaw) { - throw new Error("FinalSettlementSubsidyHandler: Unable to determine expected amount for subsidy"); - } - - const destinationNetwork = state.type === RampDirection.BUY ? (quote.network as EvmNetworks) : Networks.Polygon; - const publicClient = evmClientManager.getClient(destinationNetwork); - const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; - - logger.debug( - `FinalSettlementSubsidyHandler: expectedAmountRaw=${expectedAmountRaw.toString()}, destinationNetwork=${destinationNetwork}, ephemeralAddress=${ephemeralAddress}, isNative=${isNative}` - ); - - // 1. Idempotency Check - if (state.state.finalSettlementSubsidyTxHash) { - const receipt = await publicClient - .getTransactionReceipt({ - hash: state.state.finalSettlementSubsidyTxHash as `0x${string}` - }) - .catch(() => null); - - if (receipt && receipt.status === "success") { - logger.info( - `FinalSettlementSubsidyHandler: Transaction ${state.state.finalSettlementSubsidyTxHash} already successful. Skipping.` - ); - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); - } - } - - // 2. Check ephemeral address balance (handles both native and ERC-20 automatically) - logger.debug( - `FinalSettlementSubsidyHandler: Polling ephemeral balance for ${ephemeralAddress} on ${destinationNetwork} (timeout=${EVM_BALANCE_CHECK_TIMEOUT_MS}ms, interval=${BALANCE_POLLING_TIME_MS}ms)` - ); - const actualBalance = await checkEvmBalanceForToken({ - amountDesiredRaw: expectedAmountRaw.mul(MIN_BRIDGE_DELIVERY_RATIO).toFixed(0, 0), - chain: destinationNetwork, - intervalMs: BALANCE_POLLING_TIME_MS, - ownerAddress: ephemeralAddress, - timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, - tokenDetails: outTokenDetails - }); - logger.debug(`FinalSettlementSubsidyHandler: Ephemeral balance=${actualBalance.toString()}`); - - const preBalance = new Big(state.state.preSettlementBalance ?? "0"); - const deliveredRaw = actualBalance.minus(preBalance); - const delivered = deliveredRaw.gte(0) ? deliveredRaw : new Big(0); - - // 3. Check funding account balance (handles both native and ERC-20 automatically) - logger.debug(`FinalSettlementSubsidyHandler: Checking funding account balance at ${fundingAccount.address}`); - const actualBalanceFundingAccount = await getEvmBalance({ - chain: destinationNetwork, - ownerAddress: fundingAccount.address as `0x${string}`, - tokenDetails: outTokenDetails - }); - logger.debug(`FinalSettlementSubsidyHandler: Funding account balance=${actualBalanceFundingAccount.toString()}`); - - // 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.` - ); - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); - } - - logger.info( - `FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} raw units of ${isNative ? "native token" : outTokenDetails.assetSymbol} to ${ephemeralAddress}` - ); - - // 4. Top up funding account if insufficient balance (ERC-20 only; native tokens are transferred directly) - if (!isNative && actualBalanceFundingAccount.lt(subsidyAmountRaw)) { - logger.info( - `FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}` - ); - - const nativeToken = NATIVE_TOKENS[destinationNetwork]; - const oneUsdInNative = await priceFeedService.convertCurrency( - "1", - "USD" as RampCurrency, - nativeToken.symbol as RampCurrency - ); - const oneUsdInNativeRaw = multiplyByPowerOfTen(oneUsdInNative, nativeToken.decimals).toFixed(0); - - const chainId = getNetworkId(destinationNetwork).toString(); - - // Use a placeholder address for this query to prevent rate limiting issues - const placeholderAddress = privateKeyToAddress(generatePrivateKey()); - const testRouteResult = await getRoute( - { - bypassGuardrails: true, - enableExpress: true, - fromAddress: placeholderAddress, - fromAmount: oneUsdInNativeRaw, - fromChain: chainId, - fromToken: NATIVE_TOKEN_ADDRESS, - slippageConfig: { - autoMode: 1 - }, - toAddress: placeholderAddress, - toChain: chainId, - toToken: outTokenDetails.erc20AddressSourceChain - }, - { useCache: true } - ); - - const { route: testRoute } = testRouteResult.data; - const rate = new Big(testRoute.estimate.toAmount).div(new Big(oneUsdInNativeRaw)); - const requiredNativeRaw = subsidyAmountRaw.div(rate).mul(1.1).toFixed(0); - - logger.info( - `FinalSettlementSubsidyHandler: Swapping ${requiredNativeRaw} native units (approx. rate ${rate}) to get required subsidy.` - ); - - // Check the amount of native is not higher than cap, cap specified in units of usd. - const requiredNative = new Big(requiredNativeRaw).div(new Big(10).pow(nativeToken.decimals)); - const requiredNativeInUsd = await priceFeedService.convertCurrency( - requiredNative.toString(), - nativeToken.symbol as RampCurrency, - "USD" as RampCurrency - ); - - if (new Big(requiredNativeInUsd).gt(MAX_FINAL_SETTLEMENT_SUBSIDY_USD)) { - throw this.createUnrecoverableError( - `FinalSettlementSubsidyHandler: Required subsidy swap amount $${requiredNativeInUsd} exceeds maximum allowed $${MAX_FINAL_SETTLEMENT_SUBSIDY_USD}` - ); - } - - const swapRouteResult = await getRoute({ - bypassGuardrails: true, - enableExpress: true, - fromAddress: fundingAccount.address, - fromAmount: requiredNativeRaw, - fromChain: chainId, - fromToken: NATIVE_TOKEN_ADDRESS, - slippageConfig: { - autoMode: 1 - }, - toAddress: fundingAccount.address, - toChain: chainId, - toToken: outTokenDetails.erc20AddressSourceChain - }); - - const { route: swapRoute } = swapRouteResult.data; - - // F-030: Validate swap route output is within acceptable range (≥80% of required subsidy) - const estimatedOutput = new Big(swapRoute.estimate.toAmount); - const minimumAcceptableOutput = subsidyAmountRaw.mul(0.8); - if (estimatedOutput.lt(minimumAcceptableOutput)) { - throw this.createUnrecoverableError( - `FinalSettlementSubsidyHandler: SquidRouter swap output ${estimatedOutput.toString()} is below 80% of required subsidy ${subsidyAmountRaw.toString()}` - ); - } - - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - const txHashIdx = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data: swapRoute.transactionRequest.data as `0x${string}`, - gas: BigInt(swapRoute.transactionRequest.gasLimit), - maxFeePerGas, - maxPriorityFeePerGas, - to: swapRoute.transactionRequest.target as `0x${string}`, - value: BigInt(swapRoute.transactionRequest.value) - }); - - logger.info(`FinalSettlementSubsidyHandler: Swap transaction sent: ${txHashIdx}. Waiting for receipt...`); - const receipt = await publicClient.waitForTransactionReceipt({ hash: txHashIdx }); - - if (receipt.status !== "success") { - throw new Error(`Swap transaction ${txHashIdx} failed`); - } - - logger.info("FinalSettlementSubsidyHandler: Swap successful. Waiting for balance update..."); - - // Wait for balance checks to pass - await checkEvmBalanceForToken({ - amountDesiredRaw: subsidyAmountRaw.toString(), - chain: destinationNetwork, - intervalMs: BALANCE_POLLING_TIME_MS, - ownerAddress: fundingAccount.address, - timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, - tokenDetails: outTokenDetails - }); - } - - // 5. Execute the subsidy transfer (native value transfer vs ERC-20 transfer) - let txHash: `0x${string}` | undefined = state.state.finalSettlementSubsidyTxHash as `0x${string}` | undefined; - - try { - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - - let receipt: TransactionReceipt | undefined = undefined; - let attempt = 0; - - while (attempt < 5 && (!receipt || receipt.status !== "success")) { - logger.debug(`FinalSettlementSubsidyHandler: Subsidy transfer attempt ${attempt + 1}/5, isNative=${isNative}`); - if (isNative) { - // Native token: simple value transfer, no contract interaction - txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - maxFeePerGas, - maxPriorityFeePerGas, - to: ephemeralAddress, - value: BigInt(subsidyAmountRaw.toFixed(0)) - }); - } else { - // ERC-20: encode transfer call - const data = encodeFunctionData({ - abi: erc20Abi, - args: [ephemeralAddress, BigInt(subsidyAmountRaw.toFixed(0))], - functionName: "transfer" - }); - - txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: outTokenDetails.erc20AddressSourceChain as `0x${string}`, - value: 0n - }); - } - - receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); - - if (!receipt || receipt.status !== "success") { - logger.error(`FinalSettlementSubsidyHandler: Transaction ${txHash} failed or was not found. Retrying...`); - attempt++; - await new Promise(resolve => setTimeout(resolve, SETTLEMENT_RETRY_BACKOFF_MS)); - } - } - - if (!receipt || receipt.status !== "success") { - 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, - finalSettlementSubsidyTxHash: txHash - } - }); - - return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); - } catch (error) { - throw this.createRecoverableError( - `FinalSettlementSubsidyHandler: Error during phase execution - ${(error as Error).message}` - ); - } - } -} - -export default new FinalSettlementSubsidyHandler(); diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts deleted file mode 100644 index 280953291..000000000 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { - ApiManager, - EvmClientManager, - EvmNetworks, - FiatToken, - getNetworkFromDestination, - isAlfredpayToken, - isNetworkEVM, - Networks, - RampDirection, - RampPhase, - waitUntilTrueWithTimeout -} from "@vortexfi/shared"; -import { type Hex, parseTransaction } from "viem"; -import logger from "../../../../config/logger"; -import { - BASE_EPHEMERAL_STARTING_BALANCE_UNITS, - POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS -} from "../../../../constants/constants"; - -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { UnrecoverablePhaseError } from "../../../errors/phase-error"; -import { multiplyByPowerOfTen } from "../../pendulum/helpers"; -import { fundEphemeralAccount } from "../../pendulum/pendulum.service"; -import { isFiatToOwnStablecoinBaseDirect } from "../../quote/utils"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { getEvmFundingAccount } from "../evm-funding"; -import { verifyUserSubmittedTxByHash } from "../helpers/user-tx-verifier"; -import { StateMetadata } from "../meta-state-types"; -import { - DESTINATION_EVM_FUNDING_AMOUNTS, - isBaseEphemeralFunded, - isDestinationEvmEphemeralFunded, - isPendulumEphemeralFunded, - isPolygonEphemeralFunded -} from "./helpers"; - -function isOnramp(state: RampState): boolean { - return state.type === RampDirection.BUY; -} - -export class FundEphemeralPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "fundEphemeral"; - } - - protected getRequiresPendulumEphemeralAddress(state: RampState, inputCurrency?: string, outputCurrency?: string): boolean { - if (inputCurrency === FiatToken.EURC || outputCurrency === FiatToken.EURC) { - return false; - } - - if (isOnramp(state) && isAlfredpayToken(inputCurrency as FiatToken) && state.to !== Networks.AssetHub) { - return false; - } - - if (!isOnramp(state) && isAlfredpayToken(outputCurrency as FiatToken)) { - return false; - } - - if (inputCurrency === FiatToken.BRL || outputCurrency === FiatToken.BRL) { - return false; - } - return true; - } - - protected getRequiresPolygonEphemeralAddress(state: RampState, inputCurrency?: string, outputCurrency?: string): boolean { - // Only required for Alfredpay onramps and offramps. Mykobo (EUR) runs on Base, not Polygon. - if (isOnramp(state) && isAlfredpayToken(inputCurrency as FiatToken)) { - return true; - } - if (!isOnramp(state) && isAlfredpayToken(outputCurrency as FiatToken)) { - return true; - } - - return false; - } - - protected getRequiresBaseEphemeralAddress(inputCurrency?: string, outputCurrency?: string): boolean { - if (inputCurrency === FiatToken.BRL || outputCurrency === FiatToken.BRL) { - return true; - } - if (inputCurrency === FiatToken.EURC || outputCurrency === FiatToken.EURC) { - return true; - } - return false; - } - - protected getRequiresDestinationEvmFunding(state: RampState): boolean { - // Required for onramps where the destination is an EVM network (not AssetHub) - if (isOnramp(state) && state.to !== Networks.AssetHub) { - const destinationNetwork = getNetworkFromDestination(state.to); - if (destinationNetwork && isNetworkEVM(destinationNetwork)) { - return true; - } - } - return false; - } - - // SELL ramps where the user broadcasts squidRouterApprove + squidRouterSwap from their own - // wallet only report tx hashes back via /v1/ramp/update. Before we spend ephemeral gas funding - // the downstream phases, we must confirm on-chain that those hashes correspond to txs matching - // the blueprint we issued — otherwise an integrator could point us at any tx and have us fund - // ephemerals based on a tx that does not actually deliver tokens to our ephemeral. - private async verifyUserSubmittedSquidHashes(state: RampState, quote: QuoteTicket): Promise { - if (state.type !== RampDirection.SELL) return; - if (state.from === Networks.AssetHub) return; - if (isAlfredpayToken(quote.outputCurrency as FiatToken)) return; - - const fromNetwork = state.from as EvmNetworks; - if (!isNetworkEVM(fromNetwork)) return; - - // Base+USDC direct path: the user broadcasts a single ERC20 transfer instead of squid - // approve+swap. Verify that hash before we fund the ephemeral and spend gas on Nabla. - const hasNoPermitTransferBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterNoPermitTransfer"); - if (hasNoPermitTransferBlueprint) { - await verifyUserSubmittedTxByHash({ - fromNetwork, - hash: state.state.squidRouterNoPermitTransferHash as `0x${string}` | undefined, - label: "User direct USDC transfer to ephemeral", - presignedPhase: "squidRouterNoPermitTransfer", - state - }); - return; - } - - const hasSquidSwapBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterSwap"); - if (!hasSquidSwapBlueprint) return; - - // The approve hash is optional: users whose wallet already holds a sufficient allowance - // for the squid router skip the approve tx entirely and only broadcast the swap. When a - // hash IS reported we still verify it against the blueprint; the swap hash — the tx that - // actually delivers tokens to our ephemeral — is always required. - const approveHash = state.state.squidRouterApproveHash as `0x${string}` | undefined; - if (approveHash) { - await verifyUserSubmittedTxByHash({ - fromNetwork, - hash: approveHash, - label: "User squidRouter approve", - presignedPhase: "squidRouterApprove", - state - }); - } - await verifyUserSubmittedTxByHash({ - fromNetwork, - hash: state.state.squidRouterSwapHash as `0x${string}` | undefined, - label: "User squidRouter swap", - presignedPhase: "squidRouterSwap", - state - }); - } - - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - await this.verifyUserSubmittedSquidHashes(state, quote); - - const apiManager = ApiManager.getInstance(); - const pendulumNode = await apiManager.getApi("pendulum"); - - const { evmEphemeralAddress, substrateEphemeralAddress } = state.state as StateMetadata; - const requiresPendulumEphemeralAddress = this.getRequiresPendulumEphemeralAddress( - state, - quote.inputCurrency, - quote.outputCurrency - ); - const requiresPolygonEphemeralAddress = this.getRequiresPolygonEphemeralAddress( - state, - quote.inputCurrency, - quote.outputCurrency - ); - const requiresBaseEphemeralAddress = this.getRequiresBaseEphemeralAddress(quote.inputCurrency, quote.outputCurrency); - const requiresDestinationEvmFunding = this.getRequiresDestinationEvmFunding(state); - - // Ephemeral checks. - if (!substrateEphemeralAddress && requiresPendulumEphemeralAddress) { - throw new Error("FundEphemeralPhaseHandler: State metadata corrupted, missing substrateEphemeralAddress. This is a bug."); - } - if (isOnramp(state) && quote.inputCurrency === FiatToken.BRL && !evmEphemeralAddress) { - throw new Error("FundEphemeralPhaseHandler: State metadata corrupted, missing evmEphemeralAddress. This is a bug."); - } - if (isOnramp(state) && quote.inputCurrency === FiatToken.EURC && !evmEphemeralAddress) { - throw new Error("FundEphemeralPhaseHandler: State metadata corrupted, missing evmEphemeralAddress. This is a bug."); - } - - try { - const isPendulumFunded = requiresPendulumEphemeralAddress - ? await isPendulumEphemeralFunded(substrateEphemeralAddress, pendulumNode) - : true; - - const isBaseFunded = requiresBaseEphemeralAddress ? await isBaseEphemeralFunded(evmEphemeralAddress) : true; - - const isPolygonFunded = requiresPolygonEphemeralAddress ? await isPolygonEphemeralFunded(evmEphemeralAddress) : true; - - const destinationNetwork = getNetworkFromDestination(state.to); - const isDestinationEvmFunded = - requiresDestinationEvmFunding && destinationNetwork && isNetworkEVM(destinationNetwork) // for type safety - ? await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork) - : true; - - if (!isPendulumFunded) { - logger.info(`Funding PEN ephemeral account ${substrateEphemeralAddress}`); - if (isOnramp(state) && state.to !== Networks.AssetHub) { - await fundEphemeralAccount("pendulum", substrateEphemeralAddress, true); - } else if (quote.outputCurrency === FiatToken.BRL) { - await fundEphemeralAccount("pendulum", substrateEphemeralAddress, true); - } else { - await fundEphemeralAccount("pendulum", substrateEphemeralAddress, false); - } - } else if (requiresPendulumEphemeralAddress) { - logger.info("Pendulum ephemeral address already funded."); - } - - if (!isBaseFunded) { - logger.info(`Funding base ephemeral account ${evmEphemeralAddress}`); - await this.fundEvmEphemeralAccount(state, Networks.Base); - } - - if (!isPolygonFunded) { - logger.info(`Funding polygon ephemeral account ${evmEphemeralAddress}`); - await this.fundEvmEphemeralAccount(state, Networks.Polygon); - } else if (requiresPolygonEphemeralAddress) { - logger.info("Polygon ephemeral address already funded."); - } - - if (isOnramp(state) && !isDestinationEvmFunded && destinationNetwork && isNetworkEVM(destinationNetwork)) { - logger.info(`Funding destination EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); - await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork); - } else if (requiresDestinationEvmFunding) { - logger.info(`Destination EVM ephemeral address already funded on ${destinationNetwork}.`); - } - } catch (e) { - logger.error("Error in FundEphemeralPhaseHandler:", e); - - // Preserve UnrecoverablePhaseError - if (e instanceof UnrecoverablePhaseError) { - throw e; - } - - const recoverableError = this.createRecoverableError("Error funding ephemeral account"); - throw recoverableError; - } - - return this.transitionToNextPhase(state, this.nextPhaseSelector(state, quote)); - } - - protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - if ( - (state.state.isDirectTransfer === true && - !(state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken))) || - (isOnramp(state) && isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) - ) { - return "destinationTransfer"; - } - - // brla onramp case - if (isOnramp(state) && quote.inputCurrency === FiatToken.BRL) { - return "subsidizePreSwap"; - } - // mykobo (EURC) onramp case - if (isOnramp(state) && quote.inputCurrency === FiatToken.EURC) { - return "subsidizePreSwap"; - } - // alfredpay onramp case - if (isOnramp(state) && isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return "subsidizePreSwap"; - } - - // off ramp cases - if (state.type === RampDirection.SELL && state.from === Networks.AssetHub) { - return "distributeFees"; - } else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { - return "finalSettlementSubsidy"; - } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL) { - return "distributeFees"; - } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.EURC) { - return "distributeFees"; - } else { - return "moonbeamToPendulum"; // Via contract.subsidizePreSwap - } - } - - protected async fundEvmEphemeralAccount(state: RampState, network: EvmNetworks): Promise { - try { - const evmClientManager = EvmClientManager.getInstance(); - const networkClient = evmClientManager.getClient(network); - const chain = networkClient.chain; - - if (!chain) { - throw new Error(`FundEphemeralPhaseHandler: Could not get chain info for ${network}`); - } - - const amountToFundUnits = - network === Networks.Polygon ? POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS : BASE_EPHEMERAL_STARTING_BALANCE_UNITS; - - const ephmeralAddress = state.state.evmEphemeralAddress; - const baseFundingRaw = BigInt(multiplyByPowerOfTen(amountToFundUnits, chain.nativeCurrency.decimals).toFixed()); - - // Cover the exact native value the presigned squidRouter swap will send (bridge gas etc.). - // The value already includes a safety margin from computeSwapValueWithSafetyMargin. - // squidRouterPay remains as a top-up safety net if the route value still falls short. - const swapTx = this.getPresignedTransaction(state, "squidRouterSwap"); - let swapValueRaw = 0n; - if (swapTx?.txData && typeof swapTx.txData === "string") { - try { - swapValueRaw = parseTransaction(swapTx.txData as Hex).value ?? 0n; - } catch (decodeError) { - logger.warn( - `FundEphemeralPhaseHandler: Could not decode squidRouterSwap presigned tx for value extraction on ${network}: ${decodeError}` - ); - } - } - - const fundingAmountRaw = (baseFundingRaw + swapValueRaw).toString(); - - // We use Moonbeam's funding account to fund the ephemeral account on the network. - const fundingAccount = getEvmFundingAccount(network); - const walletClient = evmClientManager.getWalletClient(network, fundingAccount); - - const txHash = await walletClient.sendTransaction({ - to: ephmeralAddress as `0x${string}`, - value: BigInt(fundingAmountRaw) - }); - - const receipt = await networkClient.waitForTransactionReceipt({ - hash: txHash as `0x${string}` - }); - - if (!receipt || receipt.status !== "success") { - throw new Error(`FundEphemeralPhaseHandler: Transaction ${txHash} failed or was not found`); - } - - // The receipt confirms inclusion, but downstream phases use a different RPC client which - // may briefly lag behind. Poll the balance until it reflects the funded amount so that - // subsequent phases (nablaApprove etc.) don't read a stale balance. - const isFundedCheck = - network === Networks.Polygon - ? () => isPolygonEphemeralFunded(ephmeralAddress) - : () => isBaseEphemeralFunded(ephmeralAddress); - - try { - await waitUntilTrueWithTimeout(isFundedCheck, 1000, 30000); - } catch (pollError) { - throw new Error( - `FundEphemeralPhaseHandler: Funded ${ephmeralAddress} on ${network} but balance not reflected on RPC within timeout: ${pollError}` - ); - } - } catch (error) { - logger.error(`FundEphemeralPhaseHandler: Error during funding ${network} ephemeral:`, error); - throw new Error(`FundEphemeralPhaseHandler: Error during funding ${network} ephemeral: ` + error); - } - } - - protected async fundDestinationEvmEphemeralAccount(state: RampState, destinationNetwork: EvmNetworks): Promise { - try { - const evmClientManager = EvmClientManager.getInstance(); - const destinationClient = evmClientManager.getClient(destinationNetwork); - const chain = destinationClient.chain; - - if (!chain) { - throw new Error(`FundEphemeralPhaseHandler: Could not get chain info for ${destinationNetwork}`); - } - - const ephemeralAddress = state.state.evmEphemeralAddress; - const fundingAmountUnits = DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork]; - const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, chain.nativeCurrency.decimals).toFixed(); - - const fundingAccount = getEvmFundingAccount(destinationNetwork); - const walletClient = evmClientManager.getWalletClient(destinationNetwork, fundingAccount); - - const txHash = await walletClient.sendTransaction({ - to: ephemeralAddress as `0x${string}`, - value: BigInt(fundingAmountRaw) - }); - - const receipt = await destinationClient.waitForTransactionReceipt({ - hash: txHash as `0x${string}` - }); - - if (!receipt || receipt.status !== "success") { - throw new Error(`FundEphemeralPhaseHandler: Transaction ${txHash} failed or was not found on ${destinationNetwork}`); - } - - try { - await waitUntilTrueWithTimeout( - () => isDestinationEvmEphemeralFunded(ephemeralAddress, destinationNetwork), - 1000, - 30000 - ); - } catch (pollError) { - throw new Error( - `FundEphemeralPhaseHandler: Funded ${ephemeralAddress} on ${destinationNetwork} but balance not reflected on RPC within timeout: ${pollError}` - ); - } - } catch (error) { - logger.error(`FundEphemeralPhaseHandler: Error during funding ${destinationNetwork} ephemeral:`, error); - throw new Error(`FundEphemeralPhaseHandler: Error during funding ${destinationNetwork} ephemeral: ` + error); - } - } -} - -export default new FundEphemeralPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/hydration-swap-handler.ts b/apps/api/src/api/services/phases/handlers/hydration-swap-handler.ts deleted file mode 100644 index de9c574ee..000000000 --- a/apps/api/src/api/services/phases/handlers/hydration-swap-handler.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ApiManager, decodeSubmittableExtrinsic, RampPhase, submitExtrinsic } from "@vortexfi/shared"; -import logger from "../../../../config/logger"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -export class HydrationSwapPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "hydrationSwap"; - } - - protected async executePhase(state: RampState): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "hydration"; - const hydrationNode = await apiManager.getApi(networkName); - - const { substrateEphemeralAddress, hydrationSwapHash } = state.state as StateMetadata; - - if (!substrateEphemeralAddress) { - throw new Error("Pendulum ephemeral address is not defined in the state. This is a bug."); - } - - if (hydrationSwapHash) { - logger.info(`HydrationSwapPhaseHandler: Transaction already submitted (${hydrationSwapHash}), skipping to next phase`); - return this.transitionToNextPhase(state, "hydrationToAssethubXcm"); - } - - try { - const { txData: hydrationSwap } = this.getPresignedTransaction(state, "hydrationSwap"); - - const swapExtrinsic = decodeSubmittableExtrinsic(hydrationSwap as string, hydrationNode.api); - const { hash } = await submitExtrinsic(swapExtrinsic, hydrationNode.api); - - state.state = { - ...state.state, - hydrationSwapHash: hash - }; - await state.update({ state: state.state }); - - return this.transitionToNextPhase(state, "hydrationToAssethubXcm"); - } catch (e) { - logger.error("Error in hydrationSwap phase:", e); - throw e; - } - } -} - -export default new HydrationSwapPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/hydration-to-assethub-xcm-phase-handler.ts b/apps/api/src/api/services/phases/handlers/hydration-to-assethub-xcm-phase-handler.ts deleted file mode 100644 index 7093477a0..000000000 --- a/apps/api/src/api/services/phases/handlers/hydration-to-assethub-xcm-phase-handler.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ApiManager, decodeSubmittableExtrinsic, RampPhase, submitExtrinsic } from "@vortexfi/shared"; -import logger from "../../../../config/logger"; -import RampState from "../../../../models/rampState.model"; -import { RecoverablePhaseError } from "../../../errors/phase-error"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -export class HydrationToAssethubXCMPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "hydrationToAssethubXcm"; - } - - protected async executePhase(state: RampState): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "hydration"; - const hydrationNode = await apiManager.getApi(networkName); - - const { substrateEphemeralAddress } = state.state as StateMetadata; - - if (!substrateEphemeralAddress) { - throw new Error("Pendulum ephemeral address is not defined in the state. This is a bug."); - } - - try { - const { txData: hydrationToAssethub, nonce } = this.getPresignedTransaction(state, "hydrationToAssethubXcm"); - - const accountData = await hydrationNode.api.query.system.account(substrateEphemeralAddress); - const currentEphemeralAccountNonce = accountData.nonce.toNumber(); - if (currentEphemeralAccountNonce !== undefined && currentEphemeralAccountNonce > nonce) { - throw new RecoverablePhaseError( - `Nonce mismatch: Hydration Account ${substrateEphemeralAddress} has nonce ${currentEphemeralAccountNonce}, expected ${nonce}. Transaction may have already been submitted.`, - 10 - ); - } - - const xcmExtrinsic = decodeSubmittableExtrinsic(hydrationToAssethub as string, hydrationNode.api); - // Don't wait for finalization because it somehow doesn't work on Hydration - const { hash } = await submitExtrinsic(xcmExtrinsic, hydrationNode.api, false); - - state.state = { - ...state.state, - hydrationToAssethubXcmHash: hash - }; - await state.update({ state: state.state }); - - return this.transitionToNextPhase(state, "complete"); - } catch (e) { - logger.error("Error in hydrationToAssethubXcm phase:", e); - throw e; - } - } -} - -export default new HydrationToAssethubXCMPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts deleted file mode 100644 index d3ccd8d34..000000000 --- a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { FiatToken, isAlfredpayToken, RampDirection, RampPhase } from "@vortexfi/shared"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; - -/** - * Handler for the initial phase - */ -export class InitialPhaseHandler extends BasePhaseHandler { - /** - * Get the phase name - */ - public getPhaseName(): RampPhase { - return "initial"; - } - - /** - * Execute the phase - * @param state The current ramp state - * @returns The updated ramp state - */ - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - logger.info(`Executing initial phase for ramp ${state.id}`); - - if (config.sandboxEnabled) { - await new Promise(resolve => setTimeout(resolve, 10000)); - return this.transitionToNextPhase(state, "complete"); - } - - if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.BRL) { - return this.transitionToNextPhase(state, "brlaOnrampMint"); - } else if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.EURC) { - return this.transitionToNextPhase(state, "mykoboOnrampDeposit"); - } else if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return this.transitionToNextPhase(state, "alfredpayOnrampMint"); - } else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { - return this.transitionToNextPhase(state, "squidRouterPermitExecute"); - } - - return this.transitionToNextPhase(state, "fundEphemeral"); - } -} - -export default new InitialPhaseHandler(); 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 deleted file mode 100644 index c752f76dd..000000000 --- a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { u8aToHex } from "@polkadot/util"; -import { decodeAddress } from "@polkadot/util-crypto"; -import { - ApiManager, - EvmClientManager, - encodePayload, - Networks, - RampPhase, - splitReceiverABI, - waitUntilTrue -} from "@vortexfi/shared"; -import Big from "big.js"; -import { encodeFunctionData, TransactionReceipt } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import { MOONBEAM_RECEIVER_CONTRACT_ADDRESS } from "../../../../constants/constants"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -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"; - } - - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - const evmClientManager = EvmClientManager.getInstance(); - - const apiManager = ApiManager.getInstance(); - const pendulumNode = await apiManager.getApi("pendulum"); - - const { substrateEphemeralAddress, moonbeamXcmTransactionHash, squidRouterReceiverId, squidRouterReceiverHash } = - state.state as StateMetadata; - - if (!substrateEphemeralAddress || !squidRouterReceiverId || !squidRouterReceiverHash) { - throw new Error("MoonbeamToPendulumPhaseHandler: State metadata corrupted. This is a bug."); - } - - const pendulumEphemeralAccountHex = u8aToHex(decodeAddress(substrateEphemeralAddress)); - const squidRouterPayload = encodePayload(pendulumEphemeralAccountHex); - - const didInputTokenArriveOnPendulum = async () => { - if (!quote.metadata.nablaSwap) { - throw new Error("MoonbeamToPendulumXcmPhaseHandler: Missing nablaSwap info in quote metadata"); - } - - const balanceResponse = await pendulumNode.api.query.tokens.accounts( - substrateEphemeralAddress, - quote.metadata.nablaSwap.inputCurrencyId - ); - - // @ts-ignore - const currentBalance = Big(balanceResponse?.free?.toString() ?? "0"); - return currentBalance.gt(Big(0)); - }; - - const moonbeamExecutorAccount = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); - const publicClient = evmClientManager.getClient(Networks.Moonbeam); - - const isHashRegisteredInSplitReceiver = async () => { - const result = await publicClient.readContract({ - abi: splitReceiverABI, - address: MOONBEAM_RECEIVER_CONTRACT_ADDRESS, - args: [squidRouterReceiverHash], - functionName: "xcmDataMapping" - }); - - return result > 0n; - }; - - try { - if (!(await didInputTokenArriveOnPendulum())) { - await waitUntilTrue(isHashRegisteredInSplitReceiver); - logger.info(`Hash ${squidRouterReceiverHash} is registered in receiver contract`); - } - } catch (e) { - logger.error(e); - throw new RecoverablePhaseError( - "MoonbeamToPendulumPhaseHandler: Failed to wait for hash registration in split receiver.", - 30 - ); - } - - let obtainedHash: `0x${string}` | undefined = moonbeamXcmTransactionHash; - try { - if (!(await didInputTokenArriveOnPendulum())) { - if (moonbeamXcmTransactionHash === undefined) { - const data = encodeFunctionData({ - abi: splitReceiverABI, - args: [squidRouterReceiverId, squidRouterPayload], - functionName: "executeXCM" - }); - - logger.info( - `Sending transaction to Moonbeam split receiver contract at address ${MOONBEAM_RECEIVER_CONTRACT_ADDRESS} with data ${data}. Args: [${squidRouterReceiverId}, ${squidRouterPayload}]` - ); - - let receipt: TransactionReceipt | undefined = undefined; - let attempt = 0; - while (attempt < 5 && (!receipt || receipt.status !== "success")) { - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - - // blind retry for transaction submission - obtainedHash = await evmClientManager.sendTransactionWithBlindRetry(Networks.Moonbeam, moonbeamExecutorAccount, { - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: MOONBEAM_RECEIVER_CONTRACT_ADDRESS, - value: 0n - }); - - receipt = await publicClient.waitForTransactionReceipt({ hash: obtainedHash }); - if (!receipt || receipt.status !== "success") { - logger.error(`MoonbeamToPendulumPhaseHandler: Transaction ${obtainedHash} failed or was not found`); - attempt++; - // Allow the network to settle the squidRouter transaction - await new Promise(resolve => setTimeout(resolve, SETTLEMENT_RETRY_BACKOFF_MS)); - } - } - - // We want to store the `moonbeamXcmTransactionHash` immediately in the local storage - // and not just after this function call here would usually end (i.e. after the - // tokens arrived on Pendulum). - // For recovery purposes. - state.state = { - ...state.state, - moonbeamXcmTransactionHash: obtainedHash - }; - await state.update({ state: state.state }); - } - } - } catch (e) { - logger.error("Error while executing moonbeam split contract transaction:", e); - throw new RecoverablePhaseError("MoonbeamToPendulumPhaseHandler: Failed to send XCM transaction", 30); - } - - try { - await waitUntilTrue(didInputTokenArriveOnPendulum, 5000); - } catch (e) { - logger.error("Error while waiting for transaction receipt:", e); - throw new RecoverablePhaseError("MoonbeamToPendulumPhaseHandler: Failed to wait for tokens to arrive on Pendulum.", 30); - } - - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); - } - - private nextPhaseSelector(state: RampState): RampPhase { - if (state.type === "BUY") { - return "subsidizePreSwap"; - } else { - return "distributeFees"; - } - } -} - -export default new MoonbeamToPendulumPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts deleted file mode 100644 index 1d044401f..000000000 --- a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { ApiManager, decodeSubmittableExtrinsic, RampPhase, submitMoonbeamXcm, waitUntilTrue } from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { RecoverablePhaseError } from "../../../errors/phase-error"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -const MINIMUM_WAIT_SECONDS_FOR_EXHAUSTION = 1800; // 30 minutes -const MINIMUM_WAIT_SECONDS_FOR_BANNED_OR_INVALID = 60; // 1 minute -export class MoonbeamToPendulumXcmPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "moonbeamToPendulumXcm"; - } - - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - const apiManager = ApiManager.getInstance(); - - // Check if there's a previous error for this phase to determine if we should use RPC shuffling - const hasPreviousError = state.errorLogs.some(log => log.phase === "moonbeamToPendulumXcm"); - - // Use shuffling on (a potential) retry when there's a previous error, otherwise use the default RPC - // Failure to obtain an RPC handle means we have exhausted all options, we should fail recoverably with larger waits. - let moonbeamNode; - try { - moonbeamNode = hasPreviousError - ? await apiManager.getApiWithShuffling("moonbeam", state.id) - : await apiManager.getApi("moonbeam"); - } catch { - throw new RecoverablePhaseError( - "MoonbeamToPendulumXcmPhaseHandler: All RPC options exhausted.", - MINIMUM_WAIT_SECONDS_FOR_EXHAUSTION - ); - } - - // TODO if no node is returned, we fail recoverably but wait a longer amount here. For this phase, and current failure mode - // it is known to be at least 30 minues. - - const pendulumNode = await apiManager.getApi("pendulum"); - - const { substrateEphemeralAddress, evmEphemeralAddress } = state.state as StateMetadata; - - if (!substrateEphemeralAddress || !evmEphemeralAddress) { - throw new Error("MoonbeamToPendulumXcmPhaseHandler: State metadata corrupted. This is a bug."); - } - - const didInputTokenArriveOnPendulum = async () => { - if (!quote.metadata.nablaSwap) { - throw new Error("MoonbeamToPendulumXcmPhaseHandler: Missing nablaSwap info in quote metadata"); - } - - const balanceResponse = await pendulumNode.api.query.tokens.accounts( - substrateEphemeralAddress, - quote.metadata.nablaSwap.inputCurrencyId - ); - - // @ts-ignore - const currentBalance = Big(balanceResponse?.free?.toString() ?? "0"); - return currentBalance.gt(Big(0)); - }; - - try { - if (!(await didInputTokenArriveOnPendulum())) { - const { txData: moonbeamToPendulumXcmTransaction } = this.getPresignedTransaction(state, "moonbeamToPendulumXcm"); - - const xcmTransaction = decodeSubmittableExtrinsic(moonbeamToPendulumXcmTransaction as string, moonbeamNode.api); - - // Check nonce of account - const txNonce = xcmTransaction.nonce.toNumber(); - const accountNonce = await moonbeamNode.api.rpc.system.accountNextIndex(evmEphemeralAddress); - if (txNonce !== accountNonce.toNumber()) { - logger.warn( - `Nonce mismatch for XCM transaction of account ${evmEphemeralAddress}: expected ${accountNonce.toNumber()}, got ${txNonce}` - ); - } - - await submitMoonbeamXcm(evmEphemeralAddress, xcmTransaction); - } - } catch (error) { - if (error && error instanceof Error) { - if (error.message.includes("IsInvalid") || error.message.includes("banned")) { - throw new RecoverablePhaseError( - "MoonbeamToPendulumXcmPhaseHandler: XCM transaction is invalid or banned, but we assume it can be fixed with resubmission.", - MINIMUM_WAIT_SECONDS_FOR_BANNED_OR_INVALID - ); - } - } - logger.error("Error while executing moonbeam-to-pendulum xcm:", error); - throw new RecoverablePhaseError("MoonbeamToPendulumXcmPhaseHandler: Failed to send XCM transaction", 120); - } - - try { - logger.info("waiting for token to arrive on pendulum..."); - await waitUntilTrue(didInputTokenArriveOnPendulum, 5000); - } catch (e) { - logger.error("Error while waiting for transaction receipt:", e); - throw new Error("MoonbeamToPendulumXcmPhaseHandler: Failed to wait for tokens to arrive on Pendulum."); - } - - return this.transitionToNextPhase(state, "subsidizePreSwap"); - } -} - -export default new MoonbeamToPendulumXcmPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts deleted file mode 100644 index ade234875..000000000 --- a/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - BalanceCheckError, - BalanceCheckErrorType, - checkEvmBalancePeriodically, - EvmAddress, - EvmToken, - evmTokenConfig, - getEvmTokenBalance, - Networks, - RampPhase -} from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -// Mykobo SEPA settlement can take significantly longer than card-based onramps. -// 24h is a generous upper bound matching SEPA business-day cutoffs. -const PAYMENT_TIMEOUT_MS = 24 * 60 * 60 * 1000; -const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; -const POLL_INTERVAL_MS = 5000; - -// The pre-computed deliveredEurc value stored at quote-creation time can be slightly -// higher than the amount actually transferred due to fee differences at execution time. -// Allow 5% tolerance in the recovery shortcut so an already-funded ephemeral is not missed. -const EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95; - -// Phase description: wait for the EURC to arrive at the Base ephemeral address from Mykobo's -// SEPA→on-chain settlement. If the timeout is reached, we assume the user has NOT made the -// SEPA transfer and we cancel the ramp. -export class MykoboOnrampDepositHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "mykoboOnrampDeposit"; - } - - protected async executePhase(state: RampState): Promise { - const { evmEphemeralAddress } = state.state as StateMetadata; - - if (!evmEphemeralAddress) { - throw new Error("MykoboOnrampDepositHandler: Missing evmEphemeralAddress in state. This is a bug."); - } - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("MykoboOnrampDepositHandler: Quote not found for the given state."); - } - - if (!quote.metadata.mykoboMint?.outputAmountRaw) { - throw new Error("MykoboOnrampDepositHandler: Missing 'mykoboMint.outputAmountRaw' in quote metadata."); - } - - const tokenDetails = evmTokenConfig[Networks.Base][EvmToken.EURC]; - if (!tokenDetails) { - throw new Error("MykoboOnrampDepositHandler: EURC token details not found for Base network."); - } - - const expectedAmountRaw = quote.metadata.mykoboMint.outputAmountRaw; - - // Recovery shortcut: a previous run may have already received Mykobo's settlement on the - // ephemeral. Accept a balance of at least 95% of the pre-computed expected amount to account - // for any fee variance between quote-creation time and settlement. - const recoveryThresholdRaw = new Big(expectedAmountRaw).times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0); - - if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw)) { - logger.info( - `MykoboOnrampDepositHandler: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${expectedAmountRaw} EURC (threshold: ${recoveryThresholdRaw}). Skipping deposit wait.` - ); - return this.transitionToNextPhase(state, "fundEphemeral"); - } - - logger.info( - `MykoboOnrampDepositHandler: Waiting for ${expectedAmountRaw} (raw, ${tokenDetails.decimals} decimals) EURC ` + - `on Base at ephemeral address ${evmEphemeralAddress}.` - ); - - try { - await checkEvmBalancePeriodically( - tokenDetails.erc20AddressSourceChain, - evmEphemeralAddress, - expectedAmountRaw, - POLL_INTERVAL_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - Networks.Base - ); - } catch (error) { - if (!(error instanceof BalanceCheckError)) { - throw new Error(`MykoboOnrampDepositHandler: Error checking Base EURC balance: ${error}`); - } - - const isCheckTimeout = error.type === BalanceCheckErrorType.Timeout; - if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { - logger.error("MykoboOnrampDepositHandler: Payment timeout reached. Cancelling ramp."); - return this.transitionToNextPhase(state, "failed"); - } - - throw isCheckTimeout - ? this.createRecoverableError( - `MykoboOnrampDepositHandler: balance-check timeout reached waiting for Mykobo settlement: ${error}` - ) - : new Error(`MykoboOnrampDepositHandler: Error checking Base EURC balance: ${error}`); - } - - logger.info( - `MykoboOnrampDepositHandler: EURC deposit received on Base ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.` - ); - - return this.transitionToNextPhase(state, "fundEphemeral"); - } - - private async ephemeralAlreadyFunded( - tokenAddress: string, - ownerAddress: string, - expectedAmountRaw: string - ): Promise { - try { - const balance = await getEvmTokenBalance({ - chain: Networks.Base, - ownerAddress: ownerAddress as EvmAddress, - tokenAddress: tokenAddress as EvmAddress - }); - return balance.gte(new Big(expectedAmountRaw)); - } catch (error) { - // Treat read failures as "not funded" so we fall through to the regular flow - // rather than aborting the phase on a transient RPC error. - logger.warn( - `MykoboOnrampDepositHandler: ephemeral balance pre-check failed for ${ownerAddress}, falling back to wait loop: ${error}` - ); - return false; - } - } - - protected isPaymentTimeoutReached(state: RampState): boolean { - const thisPhaseEntry = state.phaseHistory.find(phaseHistoryEntry => phaseHistoryEntry.phase === this.getPhaseName()); - if (!thisPhaseEntry) { - throw new Error("MykoboOnrampDepositHandler: Phase not found in history. This is a bug."); - } - - const initialTimestamp = new Date(thisPhaseEntry.timestamp); - return initialTimestamp.getTime() + PAYMENT_TIMEOUT_MS < Date.now(); - } -} - -export default new MykoboOnrampDepositHandler(); 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 deleted file mode 100644 index a37710ec6..000000000 --- a/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { EvmClientManager, MykoboApiService, MykoboTransactionStatus, Networks, RampPhase } from "@vortexfi/shared"; -import logger from "../../../../config/logger"; -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; - -export class MykoboPayoutOnBasePhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "mykoboPayoutOnBase"; - } - - protected async executePhase(state: RampState): Promise { - const { mykoboTransactionId, mykoboPayoutTxHash } = state.state as StateMetadata; - - if (!mykoboTransactionId) { - throw new Error("MykoboPayoutOnBasePhaseHandler: mykoboTransactionId missing in state. This is a bug."); - } - - await this.sendMykoboPayoutTransaction(state, mykoboPayoutTxHash); - await this.pollMykoboUntilCompleted(mykoboTransactionId); - - return this.transitionToNextPhase(state, "complete"); - } - - private async sendMykoboPayoutTransaction(state: RampState, mykoboPayoutTxHash?: `0x${string}`): Promise { - try { - const evmClientManager = EvmClientManager.getInstance(); - const baseClient = evmClientManager.getClient(Networks.Base); - const { txData: payoutTx } = this.getPresignedTransaction(state, "mykoboPayoutOnBase"); - - if (!payoutTx) { - throw new Error("Missing presigned transaction for mykoboPayoutOnBase"); - } - - if (mykoboPayoutTxHash) { - logger.info(`MykoboPayoutOnBasePhaseHandler: Found existing tx ${mykoboPayoutTxHash}. Waiting for receipt...`); - const receipt = await baseClient.waitForTransactionReceipt({ hash: mykoboPayoutTxHash }); - if (receipt.status === "success") { - logger.info(`MykoboPayoutOnBasePhaseHandler: Existing tx ${mykoboPayoutTxHash} succeeded.`); - return; - } - 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}` - )) as `0x${string}`; - logger.info(`MykoboPayoutOnBasePhaseHandler: Sent EURC transfer tx ${txHash}. Waiting for receipt...`); - - const receipt = await baseClient.waitForTransactionReceipt({ hash: txHash }); - if (receipt.status !== "success") { - throw new Error(`Transaction ${txHash} failed on chain`); - } - - await state.update({ - state: { - ...state.state, - mykoboPayoutTxHash: txHash - } - }); - 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"); - } - } - - private async pollMykoboUntilCompleted(transactionId: string): Promise { - const mykobo = MykoboApiService.getInstance(); - const startTime = Date.now(); - let lastError: unknown; - - while (Date.now() - startTime < POLL_TIMEOUT_MS) { - try { - const { transaction } = await mykobo.getTransaction(transactionId); - logger.debug(`MykoboPayoutOnBasePhaseHandler: tx ${transactionId} status=${transaction.status}`); - - if (transaction.status === MykoboTransactionStatus.COMPLETED) { - return; - } - if ( - transaction.status === MykoboTransactionStatus.FAILED || - transaction.status === MykoboTransactionStatus.CANCELLED || - transaction.status === MykoboTransactionStatus.EXPIRED - ) { - throw this.createUnrecoverableError( - `MykoboPayoutOnBasePhaseHandler: Mykobo transaction ${transactionId} ended with status ${transaction.status}` - ); - } - } catch (error) { - if (error instanceof PhaseError) throw error; - lastError = error; - logger.warn("MykoboPayoutOnBasePhaseHandler: Polling Mykobo transaction failed. Retrying...", error); - } - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); - } - - if (lastError) { - throw this.createRecoverableError( - `MykoboPayoutOnBasePhaseHandler: Polling timed out with transient error: ${(lastError as Error).message}` - ); - } - throw this.createRecoverableError("MykoboPayoutOnBasePhaseHandler: Polling for Mykobo transaction status timed out."); - } -} - -export default new MykoboPayoutOnBasePhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts deleted file mode 100644 index 052dcb785..000000000 --- a/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { createExecuteMessageExtrinsic, ExecuteMessageResult, submitExtrinsic } from "@pendulum-chain/api-solang"; -import { Abi } from "@polkadot/api-contract"; -import { ApiManager, decodeSubmittableExtrinsic, EvmClientManager, NABLA_ROUTER, Networks, RampPhase } from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../config/logger"; -import { erc20WrapperAbi } from "../../../../contracts/ERC20Wrapper"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -export class NablaApprovePhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "nablaApprove"; - } - - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (!quote.metadata.nablaSwap && !quote.metadata.nablaSwapEvm) { - throw new Error("Missing nablaSwap info in quote metadata"); - } - - const { substrateEphemeralAddress } = state.state as StateMetadata; - - // EVM-ephemeral flows (BRL, Mykobo EUR, ...) use the EVM Nabla instance. - if (quote.metadata.nablaSwapEvm) { - return this.executeEvmApprove(state); - } else if (substrateEphemeralAddress) { - return this.executeSubstrateApprove(state, quote); - } else { - throw new Error( - "NablaApprovePhaseHandler: Invalid state. Missing substrate ephemeral address for a non-EVM-ephemeral quote." - ); - } - } - - private async executeSubstrateApprove(state: RampState, quote: QuoteTicket): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - - if (!quote.metadata.nablaSwap) { - throw new Error("Missing nablaSwap info in quote metadata"); - } - - try { - const approval = await pendulumNode.api.query.tokenAllowance.approvals( - quote.metadata.nablaSwap.inputCurrencyId, - state.state.substrateEphemeralAddress, - NABLA_ROUTER - ); - const requiredAmount = new Big(quote.metadata.nablaSwap.inputAmountForSwapRaw); - const approvedAmount = approval.toString() !== "" ? Big(approval.toString()) : Big(0); - if (approvedAmount.gte(requiredAmount)) { - logger.info("NablaApprovePhaseHandler: Amount already approved. Skipping approval."); - return this.transitionToNextPhase(state, "nablaSwap"); - } - } catch (e) { - throw this.createRecoverableError( - `NablaApprovePhaseHandler: Could not check if the approve has already been performed. ${(e as Error).message}` - ); - } - - try { - const { txData: nablaApproveTransaction } = this.getPresignedTransaction(state, "nablaApprove"); - // This is a new item that might not be available on old states. - const approveExtrinsicOptions = state.state.nabla?.approveExtrinsicOptions; - - if (approveExtrinsicOptions) { - const { api } = pendulumNode; - const erc20ContractAbi = new Abi(erc20WrapperAbi, api.registry.getChainProperties()); - - // Do a dry-run with the extrinsic options we used to create the presigned extrinsic. - const { result: readMessageResult } = await createExecuteMessageExtrinsic({ - ...approveExtrinsicOptions, - abi: erc20ContractAbi, - api: pendulumNode.api, - skipDryRunning: false - }); - - if (!readMessageResult) { - throw new Error("Could not dry-run nabla swap transaction. Missing result."); - } - if (readMessageResult.type !== "success") { - const errorMessage = this.parseContractMessageResultError(readMessageResult); - throw new Error("Could not dry-run nabla swap transaction: " + errorMessage); - } - } - - if (typeof nablaApproveTransaction !== "string") { - throw new Error("NablaApprovePhaseHandler: Invalid transaction data. This is a bug."); - } - const approvalExtrinsic = decodeSubmittableExtrinsic(nablaApproveTransaction, pendulumNode.api); - const result = await submitExtrinsic(approvalExtrinsic); - - if (result.status.type === "error") { - logger.error(`Could not approve token: ${result.status.error.toString()}`); - throw new Error("Could not approve token"); - } - - return this.transitionToNextPhase(state, "nablaSwap"); - } catch (e) { - let errorMessage = ""; - const { result } = e as ExecuteMessageResult; - if (result?.type === "reverted") { - errorMessage = result.description; - } else if (result?.type === "error") { - errorMessage = result.error; - } else { - errorMessage = "Something went wrong"; - } - logger.error(`Could not approve the required amount of token: ${errorMessage}`); - - throw e; - } - } - - private async executeEvmApprove(state: RampState): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const baseClient = evmClientManager.getClient(Networks.Base); - - try { - const { txData: nablaApproveTransaction } = this.getPresignedTransaction(state, "nablaApprove"); - - if (typeof nablaApproveTransaction !== "string") { - throw new Error("NablaApprovePhaseHandler: Invalid EVM transaction data. This is a bug."); - } - - const txHash = await baseClient.sendRawTransaction({ - serializedTransaction: nablaApproveTransaction as `0x${string}` - }); - - const receipt = await baseClient.waitForTransactionReceipt({ - hash: txHash - }); - - if (!receipt || receipt.status !== "success") { - throw new Error(`NablaApprovePhaseHandler: EVM approve transaction ${txHash} failed`); - } - - logger.info(`NablaApprovePhaseHandler: EVM approve transaction successful: ${txHash}`); - - return this.transitionToNextPhase(state, "nablaSwap"); - } catch (e) { - logger.error(`Could not approve token on EVM: ${(e as Error).message}`); - throw e; - } - } -} - -export default new NablaApprovePhaseHandler(); 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 deleted file mode 100644 index 5bfad2852..000000000 --- a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -// eslint-disable-next-line import/no-unresolved -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" -} as const; - -const RampDirection = { - SELL: "SELL" -} as const; - -const EvmToken = { - USDC: "USDC" -} as const; - -const EVM_EPHEMERAL_ACCOUNT = privateKeyToAccount( - "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" -); -const UNEXPECTED_EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; -const NABLA_ROUTER_ADDRESS = "0x2222222222222222222222222222222222222222"; -const SWAP_TX_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const SWAP_TX = await EVM_EPHEMERAL_ACCOUNT.signTransaction({ - chainId: 8453, - data: "0x12345678", - gas: 500000n, - maxFeePerGas: 2000000000n, - maxPriorityFeePerGas: 1000000n, - nonce: 0, - to: NABLA_ROUTER_ADDRESS, - type: "eip1559", - value: 0n -}); - -const call = mock(async () => ({ data: "0x" })); -const sendRawTransaction = mock(async () => SWAP_TX_HASH); -const waitForTransactionReceipt = mock(async () => ({ status: "success" })); -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, - EvmClientManager: { - getInstance: () => ({ - getClient: () => ({ - call, - sendRawTransaction, - waitForTransactionReceipt - }) - }) - }, - evmTokenConfig: { - [Networks.Base]: { - [EvmToken.USDC]: { - assetSymbol: EvmToken.USDC, - decimals: 6, - erc20AddressSourceChain: "0x3333333333333333333333333333333333333333", - isNative: false, - network: Networks.Base - } - } - }, - NABLA_ROUTER: "0x4444444444444444444444444444444444444444" -})); - -mock.module("../../ramp/ramp.service", () => ({ - default: { - appendErrorLog - } -})); - -const { default: QuoteTicket } = await import("../../../../models/quoteTicket.model"); -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: { - inputAmountForSwapRaw: "1000000", - inputCurrency: EvmToken.USDC - } - } -})) as typeof QuoteTicket.findByPk; - -function makeState(overrides: Record = {}) { - const state = { - currentPhase: "nablaSwap", - errorLogs: [], - get() { - const { get: _get, update: _update, ...data } = this; - return data; - }, - id: "ramp-1", - phaseHistory: [], - presignedTxs: [ - { - meta: {}, - network: Networks.Base, - nonce: 0, - phase: "nablaSwap", - signer: EVM_EPHEMERAL_ACCOUNT.address, - txData: SWAP_TX - } - ], - quoteId: "quote-1", - state: { - evmEphemeralAddress: EVM_EPHEMERAL_ACCOUNT.address - }, - type: RampDirection.SELL, - async update(updateData: Record) { - Object.assign(this, updateData); - return this; - }, - ...overrides - }; - - return state as unknown as NablaSwapState; -} - -describe("NablaSwapPhaseHandler", () => { - beforeEach(() => { - call.mockClear(); - sendRawTransaction.mockClear(); - waitForTransactionReceipt.mockClear(); - checkEvmBalanceForToken.mockClear(); - appendErrorLog.mockClear(); - }); - - it("dry-runs the decoded EVM swap transaction before broadcasting", async () => { - const decodedSwapTx = parseTransaction(SWAP_TX); - const handler = new NablaSwapPhaseHandler(); - const updatedState = await handler.execute(makeState()); - - expect(call).toHaveBeenCalledTimes(1); - expect(call).toHaveBeenCalledWith({ - accessList: decodedSwapTx.accessList, - account: EVM_EPHEMERAL_ACCOUNT.address, - blockTag: "pending", - data: decodedSwapTx.data, - gas: decodedSwapTx.gas, - maxFeePerGas: decodedSwapTx.maxFeePerGas, - maxPriorityFeePerGas: decodedSwapTx.maxPriorityFeePerGas, - to: decodedSwapTx.to, - type: "eip1559", - value: decodedSwapTx.value - }); - expect(sendRawTransaction).toHaveBeenCalledTimes(1); - expect(sendRawTransaction).toHaveBeenCalledWith({ serializedTransaction: SWAP_TX }); - expect(updatedState.currentPhase).toBe("subsidizePostSwap"); - }); - - it("does not broadcast when the EVM swap dry-run reverts", async () => { - call.mockRejectedValueOnce(new Error("SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO")); - - const handler = new NablaSwapPhaseHandler(); - - await expect(handler.execute(makeState())).rejects.toThrow("SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO"); - - expect(call).toHaveBeenCalledTimes(1); - expect(sendRawTransaction).not.toHaveBeenCalled(); - expect(appendErrorLog).toHaveBeenCalledTimes(1); - expect(appendErrorLog.mock.calls[0][1].error).toContain("SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO"); - expect(appendErrorLog.mock.calls[0][1].recoverable).toBe(true); - }); - - it("rejects EVM swap transactions signed by an unexpected sender", async () => { - const handler = new NablaSwapPhaseHandler(); - - await expect( - handler.execute( - makeState({ - state: { - evmEphemeralAddress: UNEXPECTED_EVM_EPHEMERAL_ADDRESS - } - }) - ) - ).rejects.toThrow("EVM swap transaction sender mismatch"); - - expect(call).not.toHaveBeenCalled(); - expect(sendRawTransaction).not.toHaveBeenCalled(); - expect(appendErrorLog).toHaveBeenCalledTimes(1); - expect(appendErrorLog.mock.calls[0][1].recoverable).toBe(false); - }); -}); diff --git a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts deleted file mode 100644 index 62ac1c1f6..000000000 --- a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { createExecuteMessageExtrinsic, ExecuteMessageResult, readMessage, submitExtrinsic } from "@pendulum-chain/api-solang"; -import { Abi } from "@polkadot/api-contract"; -import { - ApiManager, - checkEvmBalanceForToken, - decodeSubmittableExtrinsic, - defaultReadLimits, - EvmClientManager, - EvmTokenDetails, - evmTokenConfig, - NABLA_ROUTER, - Networks, - RampDirection, - RampPhase -} from "@vortexfi/shared"; -import Big from "big.js"; -import { parseTransaction, recoverTransactionAddress } from "viem"; -import logger from "../../../../config/logger"; -import { routerAbi } from "../../../../contracts/Router"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { PhaseError } from "../../../errors/phase-error"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -export class NablaSwapPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "nablaSwap"; - } - - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - const { substrateEphemeralAddress } = state.state as StateMetadata; - - if (quote.metadata.nablaSwapEvm) { - return this.executeEvmSwap(state, quote); - } else if (substrateEphemeralAddress) { - return this.executeSubstrateSwap(state, quote); - } else { - throw new Error( - "NablaSwapPhaseHandler: Invalid state. Missing substrate ephemeral address for a non-EVM-ephemeral quote." - ); - } - } - - private async executeSubstrateSwap(state: RampState, quote: QuoteTicket): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - - const { nablaSoftMinimumOutputRaw, substrateEphemeralAddress, nablaSwapTxHash } = state.state as StateMetadata; - - if (!nablaSoftMinimumOutputRaw || !substrateEphemeralAddress) { - throw new Error("State metadata is corrupt, missing values. This is a bug."); - } - - if (nablaSwapTxHash) { - logger.info(`NablaSwapPhaseHandler: Transaction already submitted (${nablaSwapTxHash}), skipping to next phase`); - const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; - return this.transitionToNextPhase(state, nextPhase); - } - - if (!quote.metadata.nablaSwap?.inputAmountForSwapRaw) { - throw new Error("Missing input amount for swap in quote metadata"); - } - - try { - const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, "nablaSwap"); - // This is a new item that might not be available on old states. - const swapExtrinsicOptions = state.state.nabla?.swapExtrinsicOptions; - - if (swapExtrinsicOptions) { - // Do a dry-run with the extrinsic options we used to create the presigned extrinsic. - const { result: readMessageResult } = await createExecuteMessageExtrinsic({ - ...swapExtrinsicOptions, - abi: new Abi(routerAbi), - api: pendulumNode.api, - skipDryRunning: false - }); - - if (!readMessageResult) { - throw new Error("Could not dry-run nabla swap transaction. Missing result."); - } - if (readMessageResult.type !== "success") { - const errorMessage = this.parseContractMessageResultError(readMessageResult); - throw new Error("Could not dry-run nabla swap transaction: " + errorMessage); - } - } - - // Get up-to-date quote and compare it to the soft minimum output. - const response = await readMessage({ - abi: new Abi(routerAbi), - api: pendulumNode.api, - callerAddress: substrateEphemeralAddress, - contractDeploymentAddress: NABLA_ROUTER, - limits: defaultReadLimits, - messageArguments: [ - quote.metadata.nablaSwap.inputAmountForSwapRaw, - [quote.metadata.nablaSwap.inputToken, quote.metadata.nablaSwap.outputToken] - ], - messageName: "getAmountOut" - }); - if (response.type !== "success") { - throw new Error("Couldn't get a quote from the AMM"); - } - - const ouputAmountQuoteRaw = Big(response.value[0].toString()); - if (ouputAmountQuoteRaw.lt(Big(nablaSoftMinimumOutputRaw))) { - logger.info( - `The estimated output amount is too low to swap. Expected: ${nablaSoftMinimumOutputRaw}, got: ${ouputAmountQuoteRaw}` - ); - throw new Error("Won't execute the swap now. The estimated output amount is too low."); - } - - if (typeof nablaSwapTransaction !== "string") { - throw new Error("NablaSwapPhaseHandler: Presigned transaction is not a string -> not an encoded Nabla transaction."); - } - - const swapExtrinsic = decodeSubmittableExtrinsic(nablaSwapTransaction, pendulumNode.api); - const result = await submitExtrinsic(swapExtrinsic); - - if (result.status.type === "error") { - logger.error(`Could not swap token: ${result.status.error.toString()}`); - throw new Error("Could not swap token"); - } - - state.state = { - ...state.state, - nablaSwapTxHash: result.txHash.toString() - }; - await state.update({ state: state.state }); - } catch (e) { - let errorMessage = ""; - const { result } = e as ExecuteMessageResult; - if (result?.type === "reverted") { - errorMessage = result.description; - } else if (result?.type === "error") { - errorMessage = result.error; - } else { - errorMessage = (e as string).toString(); - } - - throw new Error(`Could not swap the required amount of token: ${errorMessage}`); - } - - const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; - return this.transitionToNextPhase(state, nextPhase); - } - - private async executeEvmSwap(state: RampState, quote: QuoteTicket): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const baseClient = evmClientManager.getClient(Networks.Base); - - if (!quote.metadata.nablaSwapEvm?.inputAmountForSwapRaw || !quote.metadata.nablaSwapEvm.inputCurrency) { - throw new Error("Missing nablaSwapEvm input metadata required to validate pre-swap balance"); - } - - const evmEphemeralAddress = state.state.evmEphemeralAddress; - if (!evmEphemeralAddress) { - throw new Error("Missing EVM ephemeral address to validate nabla swap input balance"); - } - - const inputTokenDetails = evmTokenConfig[Networks.Base]?.[quote.metadata.nablaSwapEvm.inputCurrency] as - | EvmTokenDetails - | undefined; - if (!inputTokenDetails) { - throw new Error(`Invalid input token ${quote.metadata.nablaSwapEvm.inputCurrency} for Base nabla swap`); - } - - try { - await checkEvmBalanceForToken({ - amountDesiredRaw: quote.metadata.nablaSwapEvm.inputAmountForSwapRaw, - chain: Networks.Base, - intervalMs: 1000, - ownerAddress: evmEphemeralAddress, - timeoutMs: 5000, - tokenDetails: inputTokenDetails - }); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - logger.error(`Could not validate EVM input balance before swap: ${errorMessage}`); - - throw this.createUnrecoverableError(`Could not validate EVM input balance before swap: ${errorMessage}`); - } - - try { - const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, "nablaSwap"); - - if (typeof nablaSwapTransaction !== "string") { - throw new Error("NablaSwapPhaseHandler: Invalid EVM transaction data. This is a bug."); - } - - await this.dryRunEvmSwap(nablaSwapTransaction as `0x${string}`, evmEphemeralAddress as `0x${string}`); - - const txHash = await baseClient.sendRawTransaction({ - serializedTransaction: nablaSwapTransaction as `0x${string}` - }); - - const receipt = await baseClient.waitForTransactionReceipt({ - hash: txHash - }); - - if (!receipt || receipt.status !== "success") { - throw new Error(`NablaSwapPhaseHandler: EVM swap transaction ${txHash} failed`); - } - - logger.info(`NablaSwapPhaseHandler: EVM swap transaction successful: ${txHash}`); - } catch (e) { - logger.error(`Could not swap token on EVM: ${(e as Error).message}`); - if (e instanceof PhaseError) { - throw e; - } - - // unrecoverable by default. - // TODO do we want to add automatic recovery? Issue is, invalid swaps now revert. - // We can add a retry with up to 1 or 2 backups. Or try to differentiate based on the revert message. - // Although, this operation should never fail with the right amount of tokens, assuming the minium can be met. - // we could call the quoter to be sure right before, a sort of dry-run. - throw this.createUnrecoverableError(`Could not swap token on EVM: ${(e as Error).message}`); - } - - const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; - return this.transitionToNextPhase(state, nextPhase); - } - - private async dryRunEvmSwap(serializedTransaction: `0x${string}`, expectedSenderAddress: `0x${string}`): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const baseClient = evmClientManager.getClient(Networks.Base); - const transaction = parseTransaction(serializedTransaction); - type RecoverTransactionAddressParams = Parameters[0]; - const transactionSender = await recoverTransactionAddress({ - serializedTransaction: serializedTransaction as RecoverTransactionAddressParams["serializedTransaction"] - }); - - if (transactionSender.toLowerCase() !== expectedSenderAddress.toLowerCase()) { - throw new Error( - `NablaSwapPhaseHandler: EVM swap transaction sender mismatch. Expected ${expectedSenderAddress}, got ${transactionSender}` - ); - } - - if (!transaction.to) { - throw new Error("NablaSwapPhaseHandler: Cannot dry-run EVM swap transaction without a recipient address."); - } - - try { - const callParameters = { - account: transactionSender, - blockTag: "pending" as const, - data: transaction.data, - gas: transaction.gas, - to: transaction.to, - value: transaction.value - }; - - if (transaction.type === "legacy" || transaction.type === undefined) { - await baseClient.call({ - ...callParameters, - gasPrice: transaction.gasPrice, - type: "legacy" - }); - } else if (transaction.type === "eip2930") { - await baseClient.call({ - ...callParameters, - accessList: transaction.accessList, - gasPrice: transaction.gasPrice, - type: "eip2930" - }); - } else if (transaction.type === "eip1559") { - await baseClient.call({ - ...callParameters, - accessList: transaction.accessList, - maxFeePerGas: transaction.maxFeePerGas, - maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, - type: "eip1559" - }); - } else { - throw new Error(`Unsupported EVM swap transaction type for dry-run: ${transaction.type}`); - } - } catch (error) { - if (error instanceof Error) { - const recoverableError = this.createRecoverableError( - `NablaSwapPhaseHandler: EVM swap dry-run failed: ${error.message}` - ); - recoverableError.stack = error.stack; - throw recoverableError; - } - - throw this.createRecoverableError(`NablaSwapPhaseHandler: EVM swap dry-run failed: ${String(error)}`); - } - } -} - -export default new NablaSwapPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/pendulum-to-assethub-phase-handler.ts b/apps/api/src/api/services/phases/handlers/pendulum-to-assethub-phase-handler.ts deleted file mode 100644 index d4e6d2a8e..000000000 --- a/apps/api/src/api/services/phases/handlers/pendulum-to-assethub-phase-handler.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ApiManager, decodeSubmittableExtrinsic, getAddressForFormat, RampPhase, submitXTokens } from "@vortexfi/shared"; -import logger from "../../../../config/logger"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -export class PendulumToAssethubXCMPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "pendulumToAssethubXcm"; - } - - protected async executePhase(state: RampState): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - - const { substrateEphemeralAddress, pendulumToAssethubXcmHash } = state.state as StateMetadata; - - if (!substrateEphemeralAddress) { - throw new Error("Pendulum ephemeral address is not defined in the state. This is a bug."); - } - - if (pendulumToAssethubXcmHash) { - logger.info( - `PendulumToAssethubXCMPhaseHandler: Transaction already submitted (${pendulumToAssethubXcmHash}), skipping to complete` - ); - return this.transitionToNextPhase(state, "complete"); - } - - try { - const { txData: pendulumToAssethubTransaction } = this.getPresignedTransaction(state, "pendulumToAssethubXcm"); - - const xcmExtrinsic = decodeSubmittableExtrinsic(pendulumToAssethubTransaction as string, pendulumNode.api); - const { hash } = await submitXTokens( - getAddressForFormat(substrateEphemeralAddress, pendulumNode.ss58Format), - xcmExtrinsic - ); - - state.state = { - ...state.state, - pendulumToAssethubXcmHash: hash - }; - await state.update({ state: state.state }); - - return this.transitionToNextPhase(state, "complete"); - } catch (e) { - logger.error("Error in PendulumToAssethubPhase:", e); - throw e; - } - } -} - -export default new PendulumToAssethubXCMPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/pendulum-to-hydration-xcm-phase-handler.ts b/apps/api/src/api/services/phases/handlers/pendulum-to-hydration-xcm-phase-handler.ts deleted file mode 100644 index b603450d9..000000000 --- a/apps/api/src/api/services/phases/handlers/pendulum-to-hydration-xcm-phase-handler.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - ApiManager, - decodeSubmittableExtrinsic, - getAddressForFormat, - RampPhase, - submitXTokens, - waitUntilTrueWithTimeout -} from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -export class PendulumToHydrationXCMPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "pendulumToHydrationXcm"; - } - - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - const apiManager = ApiManager.getInstance(); - const pendulumNode = await apiManager.getApi("pendulum"); - const hydrationNode = await apiManager.getApi("hydration"); - - const { substrateEphemeralAddress, pendulumToHydrationXcmHash } = state.state as StateMetadata; - - if (!substrateEphemeralAddress) { - throw new Error("Pendulum ephemeral address is not defined in the state. This is a bug."); - } - - const didInputTokenArriveOnHydration = async () => { - if (!quote.metadata.hydrationSwap) { - throw new Error("MoonbeamToPendulumXcmPhaseHandler: Missing hydrationSwap info in quote metadata"); - } - - const balanceResponse = await hydrationNode.api.query.tokens.accounts( - substrateEphemeralAddress, - quote.metadata.hydrationSwap.inputAsset - ); - - // @ts-ignore - const currentBalance = Big(balanceResponse?.free?.toString() ?? "0"); - return currentBalance.gt(Big(0)); - }; - - if (pendulumToHydrationXcmHash) { - logger.info( - `PendulumToHydrationXCMPhaseHandler: Transaction already submitted (${pendulumToHydrationXcmHash}), waiting for arrival` - ); - logger.info("Waiting for assets to arrive on Hydration"); - await waitUntilTrueWithTimeout(didInputTokenArriveOnHydration, 5000, 120000); - return this.transitionToNextPhase(state, "hydrationSwap"); - } - - try { - const { txData: pendulumToHydrationTransaction } = this.getPresignedTransaction(state, "pendulumToHydrationXcm"); - - const xcmExtrinsic = decodeSubmittableExtrinsic(pendulumToHydrationTransaction as string, pendulumNode.api); - const { hash } = await submitXTokens( - getAddressForFormat(substrateEphemeralAddress, pendulumNode.ss58Format), - xcmExtrinsic - ); - - state.state = { - ...state.state, - pendulumToHydrationXcmHash: hash - }; - await state.update({ state: state.state }); - - logger.info("Waiting for assets to arrive on Hydration"); - await waitUntilTrueWithTimeout(didInputTokenArriveOnHydration, 5000, 120000); - - return this.transitionToNextPhase(state, "hydrationSwap"); - } catch (e) { - logger.error("Error in pendulumToHydrationXcm phase:", e); - throw e; - } - } -} - -export default new PendulumToHydrationXCMPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts b/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts deleted file mode 100644 index 2121d8e62..000000000 --- a/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { - ApiManager, - AXL_USDC_MOONBEAM, - decodeSubmittableExtrinsic, - FiatToken, - getAddressForFormat, - getAnyFiatTokenDetailsMoonbeam, - getEvmTokenBalance, - MOONBEAM_XCM_FEE_GLMR, - Networks, - nativeToDecimal, - PENDULUM_USDC_AXL, - RampDirection, - RampPhase, - submitXTokens -} from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { SubsidyToken } from "../../../../models/subsidy.model"; -import { BasePhaseHandler } from "../base-phase-handler"; - -export class PendulumToMoonbeamXCMPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "pendulumToMoonbeamXcm"; - } - - protected async executePhase(state: RampState): Promise { - const apiManager = ApiManager.getInstance(); - const pendulumNode = await apiManager.getApi("pendulum"); - - const quote = await QuoteTicket.findByPk(state.quoteId); - - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - const { substrateEphemeralAddress, evmEphemeralAddress, brlaEvmAddress } = state.state; - - if (!substrateEphemeralAddress) { - throw new Error("Ephemeral address not defined in the state. This is a bug."); - } - - if (!evmEphemeralAddress && !brlaEvmAddress) { - throw new Error( - "Moonbeam ephemeral address and BRL EVM address not defined in the state. One of them should be defined. This is a bug." - ); - } - - if (!quote.metadata.pendulumToMoonbeamXcm?.outputAmountRaw) { - throw new Error("Missing output amount for Pendulum to Moonbeam XCM in quote metadata"); - } - - const expectedOutputAmountRaw = quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw; - - const didTokensLeavePendulum = async () => { - // Token is always either axlUSDC or BRL. - const currencyId = - state.type === RampDirection.SELL - ? getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL).pendulumRepresentative.currencyId - : PENDULUM_USDC_AXL.currencyId; - const balanceResponse = await pendulumNode.api.query.tokens.accounts(substrateEphemeralAddress, currencyId); - - // @ts-ignore - const currentBalance = Big(balanceResponse?.free?.toString() ?? "0"); - return currentBalance.lt(expectedOutputAmountRaw); - }; - - const didTokensArriveOnMoonbeam = async () => { - // Token is always either axlUSDC or BRL. - const tokenAddress = - state.type === RampDirection.SELL - ? getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL).moonbeamErc20Address - : AXL_USDC_MOONBEAM; - const ownerAddress = - state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL ? brlaEvmAddress : evmEphemeralAddress; - - const balance = await getEvmTokenBalance({ - chain: Networks.Moonbeam, - ownerAddress: ownerAddress as `0x${string}`, - tokenAddress: tokenAddress as `0x${string}` - }); - - return balance.gte(expectedOutputAmountRaw); - }; - - const waitForMoonbeamArrival = async (timeoutMs = 120000): Promise => { - const startTime = Date.now(); - const pollIntervalMs = 5000; - - while (Date.now() - startTime < timeoutMs) { - if (await didTokensArriveOnMoonbeam()) { - return true; - } - await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); - } - return false; - }; - - try { - // Check if we already have a stored XCM hash (XCM was submitted in a previous attempt) - if (state.state.pendulumToMoonbeamXcmHash) { - logger.info( - `PendulumToMoonbeamPhaseHandler: XCM already submitted (hash: ${state.state.pendulumToMoonbeamXcmHash}) for ramp ${state.id}. Waiting for arrival on Moonbeam...` - ); - - if (await didTokensArriveOnMoonbeam()) { - logger.info(`PendulumToMoonbeamPhaseHandler: Tokens already arrived on Moonbeam for ramp ${state.id}.`); - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); - } - - const arrived = await waitForMoonbeamArrival(); - if (!arrived) { - throw this.createRecoverableError("Timeout waiting for tokens to arrive on Moonbeam after XCM was already submitted"); - } - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); - } - - // Check if tokens already left Pendulum (XCM was submitted but hash wasn't stored due to crash) - if (await didTokensLeavePendulum()) { - logger.info( - `PendulumToMoonbeamPhaseHandler: Tokens already left Pendulum for ramp ${state.id}. XCM likely submitted but hash not stored. Waiting for arrival on Moonbeam...` - ); - - if (await didTokensArriveOnMoonbeam()) { - logger.info(`PendulumToMoonbeamPhaseHandler: Tokens already arrived on Moonbeam for ramp ${state.id}.`); - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); - } - - const arrived = await waitForMoonbeamArrival(); - if (!arrived) { - throw this.createRecoverableError("Timeout waiting for tokens to arrive on Moonbeam after tokens left Pendulum"); - } - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); - } - - // No previous XCM submission detected, proceed with transfer - const { txData: pendulumToMoonbeamTransaction } = this.getPresignedTransaction(state, "pendulumToMoonbeamXcm"); - - if (typeof pendulumToMoonbeamTransaction !== "string") { - throw new Error("PendulumToMoonbeamPhaseHandler: Invalid transaction data. This is a bug."); - } - - const xcmExtrinsic = decodeSubmittableExtrinsic(pendulumToMoonbeamTransaction, pendulumNode.api); - logger.info(`PendulumToMoonbeamPhaseHandler: Submitting XCM transfer to Moonbeam for ramp ${state.id}`); - const { hash } = await submitXTokens( - getAddressForFormat(substrateEphemeralAddress, pendulumNode.ss58Format), - xcmExtrinsic - ); - - logger.info( - `PendulumToMoonbeamPhaseHandler: XCM transfer submitted with hash ${hash} for ramp ${state.id}. Waiting for the token to arrive on Moonbeam...` - ); - - // Store the hash immediately after submission to minimize crash window - state.state = { - ...state.state, - pendulumToMoonbeamXcmHash: hash - }; - await state.update({ state: state.state }); - - const arrived = await waitForMoonbeamArrival(); - if (!arrived) { - throw this.createRecoverableError("Timeout waiting for tokens to arrive on Moonbeam after XCM submission"); - } - - // XCM is payed by the ephemeral, in GLMR, with a fixed value of MOONBEAM_XCM_FEE_GLMR - const subsidyAmount = nativeToDecimal(MOONBEAM_XCM_FEE_GLMR, 18).toNumber(); - const hashToStore = hash ?? "0x"; - await this.createSubsidy(state, subsidyAmount, SubsidyToken.GLMR, substrateEphemeralAddress, hashToStore); - - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); - } catch (e) { - logger.error("Error in PendulumToMoonbeamPhase:", e); - throw this.createRecoverableError("Error in PendulumToMoonbeamPhase"); - } - } - - protected nextPhaseSelector(state: RampState): RampPhase { - if (state.type === RampDirection.SELL) { - return "brlaPayoutOnBase"; - } else { - return "squidRouterSwap"; - } - } -} - -export default new PendulumToMoonbeamXCMPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.test.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.test.ts deleted file mode 100644 index b0277f8b6..000000000 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.test.ts +++ /dev/null @@ -1,492 +0,0 @@ -// eslint-disable-next-line import/no-unresolved -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; -// 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"; -import * as evmFundingNamespace from "../evm-funding"; - -// 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 evmFundingReal = { ...evmFundingNamespace }; - -const Networks = { - AssetHub: "assethub", - Base: "base", - Moonbeam: "moonbeam", - Polygon: "polygon" -} as const; - -const FiatToken = { - BRL: "BRL", - EURC: "EUR" -} as const; - -const RampDirection = { - BUY: "BUY", - SELL: "SELL" -} as const; - -const SWAP_HASH = "0x31365ff4337000801303097a0494fd97ecc1661ea84fedee801f01825b236f49"; -const EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; -const FUNDER_ADDRESS = "0x2222222222222222222222222222222222222222"; - -// Queue of axelarscan statuses returned per polling iteration; refilled per test. -let axelarStatusQueue: unknown[] = []; -const getStatusAxelarScan = mock(async () => { - if (axelarStatusQueue.length > 1) { - return axelarStatusQueue.shift(); - } - return axelarStatusQueue[0]; -}); -const getStatus = mock(async () => ({ - id: "", - isGMPTransaction: true, - routeStatus: [], - squidTransactionStatus: "", - status: "ongoing" -})); -const recoverAxelarStuckConfirm = mock(async () => "AXELAR_TX_HASH"); -// Never settles on its own; the real implementation rejects on abort, but these -// tests always resolve via the bridge path of Promise.any. -const checkEvmBalanceForToken = mock(() => new Promise(() => undefined)); - -mock.module("@vortexfi/shared", () => ({ - ...sharedReal, - checkEvmBalanceForToken, - EvmClientManager: { - getInstance: () => ({ - getClient: () => ({}), - getWalletClient: () => ({ account: { address: FUNDER_ADDRESS } }) - }) - }, - FiatToken, - getNetworkId: (network: string) => { - if (network === Networks.Base) return 8453; - if (network === Networks.Polygon) return 137; - if (network === Networks.Moonbeam) return 1284; - return undefined; - }, - getOnChainTokenDetails: () => ({ - decimals: 6, - erc20AddressSourceChain: "0x3333333333333333333333333333333333333333", - isNative: false - }), - getStatus, - getStatusAxelarScan, - isAlfredpayToken: () => false, - Networks, - RampDirection, - recoverAxelarStuckConfirm -})); - -mock.module("../evm-funding", () => ({ - getEvmFundingAccount: () => ({ address: FUNDER_ADDRESS }) -})); - -mock.module("../../ramp/ramp.service", () => ({ - default: { - appendErrorLog: mock(async () => undefined) - } -})); - -const { default: QuoteTicket } = await import("../../../../models/quoteTicket.model"); -const { default: RampState } = await import("../../../../models/rampState.model"); -const { SquidRouterPayPhaseHandler } = await import("./squid-router-pay-phase-handler"); - -const realQuoteTicketFindByPk = QuoteTicket.findByPk; -const sequelizeInstance = RampState.sequelize; -if (!sequelizeInstance) throw new Error("RampState has no sequelize instance"); -const realSequelizeQuery = sequelizeInstance.query; - -// Raw conditional jsonb_set patch used by patchStateKey; [rows, rowCount] with -// rowCount 1 = claim won. -const statePatchQuery = mock(async () => [[], 1]); -sequelizeInstance.query = statePatchQuery as unknown as typeof sequelizeInstance.query; - -afterAll(() => { - mock.module("@vortexfi/shared", () => ({ ...sharedReal })); - mock.module("../evm-funding", () => ({ ...evmFundingReal })); - mock.module("../../ramp/ramp.service", () => ({ ...rampServiceReal })); - QuoteTicket.findByPk = realQuoteTicketFindByPk; - sequelizeInstance.query = realSequelizeQuery; -}); - -let quote: { - inputCurrency: string; - outputCurrency: string; - to: string; -}; - -QuoteTicket.findByPk = mock(async () => quote as any) as typeof QuoteTicket.findByPk; - -function makeState(stateOverrides: Record = {}) { - const state = { - currentPhase: "squidRouterPay", - errorLogs: [], - get() { - const { get: _get, update: _update, ...data } = this; - return data; - }, - id: "ramp-1", - phaseHistory: [], - quoteId: "quote-1", - state: { - evmEphemeralAddress: EVM_EPHEMERAL_ADDRESS, - squidRouterPayTxHash: "0xpay", - squidRouterSwapHash: SWAP_HASH, - ...stateOverrides - }, - to: Networks.Base, - type: RampDirection.BUY, - async update(updateData: Record) { - Object.assign(this, updateData); - return this; - } - }; - return state as any; -} - -function makeHandler() { - const handler = new SquidRouterPayPhaseHandler(); - // Shrink the real 60s/10s waits so the polling loop runs in test time. - (handler as any).initialDelayMs = 10; - (handler as any).pollIntervalMs = 10; - return handler; -} - -const STUCK_CONFIRM_STATUS = { - call: { chain: "base" }, - confirm_failed: true, - id: `${SWAP_HASH}_55_172`, - is_insufficient_fee: false, - status: "called" -}; - -const EXECUTED_STATUS = { - id: `${SWAP_HASH}_55_172`, - is_insufficient_fee: false, - status: "executed" -}; - -describe("SquidRouterPayPhaseHandler", () => { - beforeEach(() => { - axelarStatusQueue = []; - getStatus.mockClear(); - getStatusAxelarScan.mockClear(); - recoverAxelarStuckConfirm.mockClear(); - checkEvmBalanceForToken.mockClear(); - statePatchQuery.mockClear(); - statePatchQuery.mockImplementation(async () => [[], 1]); - quote = { - inputCurrency: FiatToken.BRL, - outputCurrency: "USDC", - to: Networks.Base - }; - }); - - it("recovers a stuck confirm and records the attempt timestamp", async () => { - axelarStatusQueue = [STUCK_CONFIRM_STATUS, EXECUTED_STATUS]; - - const state = makeState(); - const updatedState = await makeHandler().execute(state); - - expect(recoverAxelarStuckConfirm).toHaveBeenCalledTimes(1); - expect(recoverAxelarStuckConfirm).toHaveBeenCalledWith(SWAP_HASH, "base", undefined); - expect(state.state.axelarConfirmRecoveryAt).toBeString(); - expect(updatedState.currentPhase).toBe("finalSettlementSubsidy"); - }); - - it("respects the cooldown and does not re-broadcast a recent recovery attempt", async () => { - axelarStatusQueue = [STUCK_CONFIRM_STATUS, STUCK_CONFIRM_STATUS, EXECUTED_STATUS]; - - const state = makeState({ axelarConfirmRecoveryAt: new Date().toISOString() }); - await makeHandler().execute(state); - - expect(recoverAxelarStuckConfirm).not.toHaveBeenCalled(); - }); - - it("does not attempt recovery while the confirm poll has not failed", async () => { - axelarStatusQueue = [ - { ...STUCK_CONFIRM_STATUS, confirm_failed: false }, - EXECUTED_STATUS - ]; - - const state = makeState(); - await makeHandler().execute(state); - - expect(recoverAxelarStuckConfirm).not.toHaveBeenCalled(); - }); - - describe("stuck-GMP monitoring", () => { - const CALLED_STATUS = { - call: { chain: "base" }, - id: `${SWAP_HASH}_55_172`, - is_insufficient_fee: false, - status: "called" - }; - - const INSUFFICIENT_GAS_STATUS = { - ...CALLED_STATUS, - fees: { - execute_gas_multiplier: 1.1, - source_base_fee: 0.01, - source_token: { gas_price: "0.00000002", gas_price_in_units: { decimals: 18, value: "20000000000" } } - }, - is_insufficient_fee: true - }; - - function makeStuckHandler() { - const handler = makeHandler(); - (handler as any).stuckAlertThresholdMs = 0; - const sendMessage = mock(async () => undefined); - (handler as any).slackNotifier = { sendMessage }; - return { handler, sendMessage }; - } - - it("alerts once with classification and context when stuck past the threshold", async () => { - axelarStatusQueue = [CALLED_STATUS, EXECUTED_STATUS]; - - // Uses the real 20-minute default threshold; elapsed time comes from phaseHistory. - const handler = makeHandler(); - const sendMessage = mock(async () => undefined); - (handler as any).slackNotifier = { sendMessage }; - - const state = makeState({ squidRouterQuoteId: "squid-quote-1" }); - state.phaseHistory = [{ phase: "squidRouterPay", timestamp: new Date(Date.now() - 30 * 60 * 1000) }]; - state.errorLogs = [ - { error: "Bridge status check timed out after 480000ms", phase: "squidRouterPay", timestamp: new Date().toISOString() } - ]; - - await handler.execute(state); - - expect(sendMessage).toHaveBeenCalledTimes(1); - const text = (sendMessage.mock.calls[0] as any)[0].text as string; - expect(text).toContain("stuck for 30 minutes"); - expect(text).toContain("ramp-1"); - expect(text).toContain("classification: waiting_source_confirmation"); - expect(text).toContain(SWAP_HASH); - expect(text).toContain("squid-quote-1"); - expect(text).toContain(`https://axelarscan.io/gmp/${SWAP_HASH}`); - expect(text).toContain("Bridge status check timed out after 480000ms"); - expect(state.state.squidRouterStuckAlertedAt).toBeString(); - }); - - it("attempts confirm recovery for a transfer stuck at called even without confirm_failed", async () => { - axelarStatusQueue = [CALLED_STATUS, EXECUTED_STATUS]; - - const { handler } = makeStuckHandler(); - await handler.execute(makeState()); - - expect(recoverAxelarStuckConfirm).toHaveBeenCalledTimes(1); - expect(recoverAxelarStuckConfirm).toHaveBeenCalledWith(SWAP_HASH, "base", undefined); - }); - - it("does not re-alert within the repeat window", async () => { - axelarStatusQueue = [CALLED_STATUS, EXECUTED_STATUS]; - - const { handler, sendMessage } = makeStuckHandler(); - await handler.execute(makeState({ squidRouterStuckAlertedAt: new Date().toISOString() })); - - expect(sendMessage).not.toHaveBeenCalled(); - }); - - it("does not alert or recover before the threshold", async () => { - axelarStatusQueue = [CALLED_STATUS, EXECUTED_STATUS]; - - const handler = makeHandler(); // default 20-minute threshold, phase just started - const sendMessage = mock(async () => undefined); - (handler as any).slackNotifier = { sendMessage }; - await handler.execute(makeState()); - - expect(sendMessage).not.toHaveBeenCalled(); - expect(recoverAxelarStuckConfirm).not.toHaveBeenCalled(); - }); - - it("sends exactly one supplemental gas top-up when Axelar reports insufficient gas after payment", async () => { - axelarStatusQueue = [INSUFFICIENT_GAS_STATUS, INSUFFICIENT_GAS_STATUS, EXECUTED_STATUS]; - - const { handler, sendMessage } = makeStuckHandler(); - const executeFundTransaction = mock(async () => "0xtopup"); - (handler as any).executeFundTransaction = executeFundTransaction; - - const state = makeState(); // squidRouterPayTxHash "0xpay" already set - await handler.execute(state); - - expect(executeFundTransaction).toHaveBeenCalledTimes(1); - expect(state.state.squidRouterExtraGasTxHash).toBe("0xtopup"); - const text = (sendMessage.mock.calls[0] as any)[0].text as string; - expect(text).toContain("classification: insufficient_gas"); - expect(text).toContain("0xtopup"); - }); - - it("does not send when a concurrent execution already claimed the top-up", async () => { - axelarStatusQueue = [INSUFFICIENT_GAS_STATUS, EXECUTED_STATUS]; - // Conditional claim loses: another execution flipped the marker first. - statePatchQuery.mockImplementationOnce(async () => [[], 0]); - - const { handler, sendMessage } = makeStuckHandler(); - const executeFundTransaction = mock(async () => "0xtopup"); - (handler as any).executeFundTransaction = executeFundTransaction; - - const state = makeState(); - await handler.execute(state); - - expect(executeFundTransaction).not.toHaveBeenCalled(); - expect(state.state.squidRouterExtraGasTxHash).toBeUndefined(); - const text = (sendMessage.mock.calls[0] as any)[0].text as string; - expect(text).toContain("already claimed by a concurrent execution"); - }); - - it("reports the real recovery outcome instead of a generic attempted message", async () => { - axelarStatusQueue = [CALLED_STATUS, EXECUTED_STATUS]; - - const { handler, sendMessage } = makeStuckHandler(); - // Cooldown active: the alert must say so rather than claim an attempt was made. - await handler.execute(makeState({ axelarConfirmRecoveryAt: new Date().toISOString(), squidRouterStuckAlertedAt: undefined })); - - expect(recoverAxelarStuckConfirm).not.toHaveBeenCalled(); - const text = (sendMessage.mock.calls[0] as any)[0].text as string; - expect(text).toContain("confirm recovery on cooldown"); - }); - - it("reports the just-made recovery broadcast instead of a cooldown for confirm_failed transfers", async () => { - axelarStatusQueue = [STUCK_CONFIRM_STATUS, EXECUTED_STATUS]; - - const { handler, sendMessage } = makeStuckHandler(); - await handler.execute(makeState()); - - // The confirm_failed branch broadcasts once; the monitor must report that - // outcome, not re-invoke the helper into the cooldown it just started. - expect(recoverAxelarStuckConfirm).toHaveBeenCalledTimes(1); - const text = (sendMessage.mock.calls[0] as any)[0].text as string; - expect(text).toContain("broadcast recovery ConfirmGatewayTx AXELAR_TX_HASH"); - }); - - it("suppresses the alert when a concurrent execution claims the alert slot", async () => { - axelarStatusQueue = [CALLED_STATUS, EXECUTED_STATUS]; - // Call 1 = recovery-timestamp patch succeeds; call 2 = alert CAS loses. - statePatchQuery.mockImplementationOnce(async () => [[], 1]); - statePatchQuery.mockImplementationOnce(async () => [[], 0]); - - const { handler, sendMessage } = makeStuckHandler(); - await handler.execute(makeState()); - - expect(sendMessage).not.toHaveBeenCalled(); - }); - - it("classifies the real GMP state when the failure happens after the status fetch", async () => { - axelarStatusQueue = [INSUFFICIENT_GAS_STATUS]; - quote = { inputCurrency: FiatToken.EURC, outputCurrency: "USDC", to: Networks.AssetHub }; - - const { handler, sendMessage } = makeStuckHandler(); - // Initial gas funding fails after a successful status fetch. - (handler as any).executeFundTransaction = mock(async () => { - throw new Error("rpc rejected"); - }); - - const state = makeState({ squidRouterPayTxHash: undefined }); - await expect(handler.execute(state)).rejects.toThrow("Failed to check bridge status"); - - const text = (sendMessage.mock.calls[0] as any)[0].text as string; - expect(text).toContain("classification: insufficient_gas"); - expect(text).not.toContain("classification: unknown"); - expect(text).toContain("rpc rejected"); - }); - - it("never retries a top-up whose outcome is unknown (pending marker)", async () => { - axelarStatusQueue = [INSUFFICIENT_GAS_STATUS, EXECUTED_STATUS]; - - const { handler } = makeStuckHandler(); - const executeFundTransaction = mock(async () => "0xtopup"); - (handler as any).executeFundTransaction = executeFundTransaction; - - const state = makeState({ squidRouterExtraGasTxHash: "pending" }); - await handler.execute(state); - - expect(executeFundTransaction).not.toHaveBeenCalled(); - expect(state.state.squidRouterExtraGasTxHash).toBe("pending"); - }); - - it("leaves the pending marker in place when the top-up broadcast fails", async () => { - axelarStatusQueue = [INSUFFICIENT_GAS_STATUS, INSUFFICIENT_GAS_STATUS, EXECUTED_STATUS]; - - const { handler } = makeStuckHandler(); - const executeFundTransaction = mock(async () => { - throw new Error("rpc rejected"); - }); - (handler as any).executeFundTransaction = executeFundTransaction; - - const state = makeState(); - await handler.execute(state); - - // The failed attempt persists "pending" first; the second insufficient-gas - // iteration must not send again. - expect(executeFundTransaction).toHaveBeenCalledTimes(1); - expect(state.state.squidRouterExtraGasTxHash).toBe("pending"); - }); - - it("does not top up in the same iteration as the initial gas funding", async () => { - axelarStatusQueue = [INSUFFICIENT_GAS_STATUS, EXECUTED_STATUS]; - - const { handler } = makeStuckHandler(); - const executeFundTransaction = mock(async () => "0xinitialpay"); - (handler as any).executeFundTransaction = executeFundTransaction; - - const state = makeState({ squidRouterPayTxHash: undefined }); - await handler.execute(state); - - // Only the regular initial funding ran; the stale pre-payment status must not - // additionally trigger a top-up. - expect(executeFundTransaction).toHaveBeenCalledTimes(1); - expect(state.state.squidRouterPayTxHash).toBe("0xinitialpay"); - expect(state.state.squidRouterExtraGasTxHash).toBeUndefined(); - }); - - it("alerts with unknown classification when the status APIs are down", async () => { - getStatus.mockImplementationOnce(() => Promise.reject(new Error("squid down"))); - getStatusAxelarScan.mockImplementationOnce(() => Promise.reject(new Error("axelarscan down"))); - // Non-EVM destination: bridge-status-only path, so the status failure rejects the - // execution instead of losing the Promise.any race to a pending balance check. - quote = { inputCurrency: FiatToken.EURC, outputCurrency: "USDC", to: Networks.AssetHub }; - - const { handler, sendMessage } = makeStuckHandler(); - const state = makeState(); - - await expect(handler.execute(state)).rejects.toThrow("Failed to check bridge status"); - - expect(sendMessage).toHaveBeenCalledTimes(1); - const text = (sendMessage.mock.calls[0] as any)[0].text as string; - expect(text).toContain("classification: unknown"); - expect(text).toContain("axelar status: unavailable"); - }); - }); - - it("stops polling when the processor aborts the execution", async () => { - // Regression test for the retry storm: abandoned executions must unwind on abort - // instead of polling the status APIs forever. - axelarStatusQueue = [STUCK_CONFIRM_STATUS]; - quote = { - inputCurrency: FiatToken.EURC, - outputCurrency: "USDC", - to: Networks.AssetHub - }; - - const abortController = new AbortController(); - const state = makeState({ axelarConfirmRecoveryAt: new Date().toISOString() }); - - const execution = makeHandler().execute(state, abortController.signal); - // Let the loop run a few iterations before aborting. - await new Promise(resolve => setTimeout(resolve, 100)); - abortController.abort(new Error("Phase execution timed out")); - - await expect(execution).rejects.toThrow(); - expect(getStatus.mock.calls.length).toBeGreaterThan(0); - - const callsAtAbort = getStatus.mock.calls.length; - await new Promise(resolve => setTimeout(resolve, 150)); - expect(getStatus.mock.calls.length).toBe(callsAtAbort); - }); -}); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.timeout.test.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.timeout.test.ts deleted file mode 100644 index cef1033d6..000000000 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.timeout.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { RecoverablePhaseError } from "../../../errors/phase-error"; -import { SquidRouterPayPhaseHandler } from "./squid-router-pay-phase-handler"; - -type BridgeStatusChecker = { - checkBridgeStatus(state: RampState, swapHash: string, quote: QuoteTicket, timeoutMs?: number): Promise; -}; - -describe("SquidRouterPayPhaseHandler bridge polling timeout", () => { - it("rejects with a recoverable error when its deadline expires", async () => { - const handler = Object.create(SquidRouterPayPhaseHandler.prototype) as BridgeStatusChecker; - const state = { state: {} } as RampState; - const quote = {} as QuoteTicket; - - const result = handler.checkBridgeStatus(state, "0xswap", quote, 0); - - await expect(result).rejects.toBeInstanceOf(RecoverablePhaseError); - await expect(result).rejects.toThrow("Bridge status check timed out after 0ms"); - }); -}); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts deleted file mode 100644 index 039202085..000000000 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ /dev/null @@ -1,861 +0,0 @@ -import { - AxelarScanStatusFees, - AxelarScanStatusResponse, - BalanceCheckError, - BalanceCheckErrorType, - checkEvmBalanceForToken, - classifyGmpStatus, - EvmClientManager, - EvmNetworks, - EvmTokenDetails, - FiatToken, - GmpClassification, - getNetworkId, - getOnChainTokenDetails, - getStatus, - getStatusAxelarScan, - isAlfredpayToken, - Networks, - nativeToDecimal, - OnChainToken, - RampDirection, - RampPhase, - recoverAxelarStuckConfirm, - SquidRouterPayResponse, - sleep -} from "@vortexfi/shared"; -import Big from "big.js"; -import { QueryTypes } from "sequelize"; -import { createWalletClient, encodeFunctionData, Hash, PublicClient } from "viem"; -import { base, polygon } from "viem/chains"; -import logger from "../../../../config/logger"; -import { axelarGasServiceAbi } from "../../../../contracts/AxelarGasService"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { SubsidyToken } from "../../../../models/subsidy.model"; -import { SlackNotifier } from "../../slack.service"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { getEvmFundingAccount } from "../evm-funding"; -import { StateMetadata } from "../meta-state-types"; -import { getSquidRouterPayStuckAlertMs, getSquidRouterPayTimeoutMs } from "../phase-processor-config"; - -const AXELAR_POLLING_INTERVAL_MS = 10000; // 10 seconds -const SQUIDROUTER_INITIAL_DELAY_MS = 60000; // 60 seconds -const AXL_GAS_SERVICE_EVM = "0x2d5d7d31F671F86C782533cc367F14109a082712"; -const BALANCE_POLLING_TIME_MS = 10000; -const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; // Estimate used to calculate part of the gas fee for SquidRouter transactions. -// Minimum time between Axelar stuck-confirm recovery broadcasts for the same ramp. A new -// validator poll needs a few minutes to complete, so re-broadcasting sooner is pure noise. -const AXELAR_CONFIRM_RECOVERY_COOLDOWN_MS = 10 * 60 * 1000; -// Minimum time between stuck-GMP alerts for the same ramp, so a multi-hour outage -// produces periodic reminders instead of one alert per 10s poll iteration. -const STUCK_ALERT_REPEAT_MS = 6 * 60 * 60 * 1000; -// Sentinel persisted to squidRouterExtraGasTxHash before broadcasting the top-up; -// its presence (never cleared on failure) guarantees at most one top-up ever. -const EXTRA_GAS_PENDING_MARKER = "pending"; -// Upper bound on a single Squid/axelarscan status request. Without it a hung request -// outlives the phase-processor timeout and the stuck monitor never sees the outage. -const STATUS_REQUEST_TIMEOUT_MS = 30000; -/** - * Handler for the squidRouter pay phase. Checks the status of the Axelar bridge and pays on native GLMR fee. - */ -export class SquidRouterPayPhaseHandler extends BasePhaseHandler { - private moonbeamPublicClient: PublicClient; - private polygonPublicClient: PublicClient; - private basePublicClient: PublicClient; - private moonbeamWalletClient: ReturnType; - private polygonWalletClient: ReturnType; - private baseWalletClient: ReturnType; - // Instance fields (not module constants) so tests can shrink the waits. - private initialDelayMs = SQUIDROUTER_INITIAL_DELAY_MS; - private pollIntervalMs = AXELAR_POLLING_INTERVAL_MS; - // Test override; when unset the env-backed default applies per call. - private stuckAlertThresholdMs?: number; - // Lazily created so environments without SLACK_WEB_HOOK_TOKEN (dev, tests) still - // load the handler; stuck alerts then only go to the logs. - private slackNotifier?: SlackNotifier | null; - - constructor() { - super(); - const evmClientManager = EvmClientManager.getInstance(); - this.moonbeamPublicClient = evmClientManager.getClient(Networks.Moonbeam); - this.polygonPublicClient = evmClientManager.getClient(Networks.Polygon); - this.basePublicClient = evmClientManager.getClient(Networks.Base); - - const moonbeamExecutorAccount = getEvmFundingAccount(Networks.Moonbeam); - this.moonbeamWalletClient = evmClientManager.getWalletClient(Networks.Moonbeam, moonbeamExecutorAccount); - this.polygonWalletClient = evmClientManager.getWalletClient(Networks.Polygon, moonbeamExecutorAccount); - this.baseWalletClient = evmClientManager.getWalletClient(Networks.Base, moonbeamExecutorAccount); - } - - /** - * Get the phase name - */ - public getPhaseName(): RampPhase { - return "squidRouterPay"; - } - - /** - * Execute the phase - * @param state The current ramp state - * @returns The updated ramp state - */ - protected async executePhase(state: RampState, signal?: AbortSignal): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - logger.info(`Executing squidRouterPay phase for ramp ${state.id}`); - - if (state.type === RampDirection.SELL) { - logger.info("squidRouterPay phase is not supported for off-ramp"); - return state; - } - - try { - // Get the bridge hash - const bridgeCallHash = state.state.squidRouterSwapHash; - if (!bridgeCallHash) { - throw new Error("SquidRouterPayPhaseHandler: Missing bridge hash in state for squidRouterPay phase. State corrupted."); - } - - // Enter check status loop - await this.checkStatus(state, bridgeCallHash, quote, signal); - - if (state.to === Networks.AssetHub) { - return this.transitionToNextPhase(state, "moonbeamToPendulum"); - } else { - return this.transitionToNextPhase(state, "finalSettlementSubsidy"); - } - } catch (error: unknown) { - logger.error(`SquidRouterPayPhaseHandler: Error in squidRouterPay phase for ramp ${state.id}:`, error); - throw error; - } - } - - /** - * Checks the status of the Axelar bridge and balances in parallel. - * If a balance arrived, we consider it a success. - * If the bridge reports success, we consider it a success. - * Only if both fail (timeout) we throw. - */ - private async checkStatus(state: RampState, swapHash: string, quote: QuoteTicket, signal?: AbortSignal): Promise { - const pollingTimeoutMs = getSquidRouterPayTimeoutMs(); - - // If the destination is not an EVM network, skip the EVM balance optimization and rely on bridge status only. - if (quote.to === Networks.AssetHub) { - logger.info("SquidRouterPayPhaseHandler: Destination network is non-EVM; skipping EVM balance check optimization.", { - toNetwork: quote.to - }); - await this.checkBridgeStatus(state, swapHash, quote, pollingTimeoutMs, signal); - return; - } - - const toChain = quote.to as EvmNetworks; - - let balanceCheckPromise: Promise; - - try { - const outTokenDetails = getOnChainTokenDetails(toChain, quote.outputCurrency as OnChainToken) as EvmTokenDetails; - const ephemeralAddress = state.state.evmEphemeralAddress; - - if (outTokenDetails && ephemeralAddress) { - balanceCheckPromise = checkEvmBalanceForToken({ - amountDesiredRaw: "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. - chain: toChain, - intervalMs: BALANCE_POLLING_TIME_MS, - ownerAddress: ephemeralAddress, - signal, - timeoutMs: pollingTimeoutMs, - tokenDetails: outTokenDetails - }); - } else { - logger.warn( - "SquidRouterPayPhaseHandler: Cannot perform balance check optimization (missing expected token details or address)." - ); - balanceCheckPromise = Promise.reject(new Error("Skipped balance check")); - } - } catch (err) { - logger.warn(`SquidRouterPayPhaseHandler: Error preparing balance check: ${err}`); - balanceCheckPromise = Promise.reject(err); - } - - // Wrap both promises to prevent unhandled rejections after one succeeds - const bridgeCheckPromise = this.checkBridgeStatus(state, swapHash, quote, pollingTimeoutMs, signal).catch(err => { - // Re-throw to preserve the error for Promise.any - throw err; - }); - - const balanceCheckWithErrorHandling = balanceCheckPromise.catch(err => { - // Re-throw to preserve the error for Promise.any - throw err; - }); - - try { - await Promise.any([bridgeCheckPromise, balanceCheckWithErrorHandling]); - } catch (error) { - // Both failed. - if (error instanceof AggregateError) { - // Distinguish between balance check timeout and read failure - const balanceError = error.errors.find(e => e instanceof BalanceCheckError); - const bridgeError = error.errors.find(e => !(e instanceof BalanceCheckError)); - - let errorMessage = "SquidRouterPayPhaseHandler: Both bridge status check and balance check failed."; - - if (balanceError instanceof BalanceCheckError) { - if (balanceError.type === BalanceCheckErrorType.Timeout) { - errorMessage += ` Balance check timed out after ${pollingTimeoutMs}ms.`; - } else if (balanceError.type === BalanceCheckErrorType.ReadFailure) { - errorMessage += ` Balance check read failure (unexpected infrastructure issue): ${balanceError.message}.`; - } - } - - if (bridgeError) { - errorMessage += ` Bridge check error: ${bridgeError instanceof Error ? bridgeError.message : String(bridgeError)}.`; - } - - throw this.createRecoverableError(errorMessage); - } - throw error; - } - } - - /** - * Gets the status of the Axelar bridge - * @param txHash The swap (bridgeCall) transaction hash - */ - private async checkBridgeStatus( - state: RampState, - swapHash: string, - quote: QuoteTicket, - timeoutMs = getSquidRouterPayTimeoutMs(), - signal?: AbortSignal - ): Promise { - let isExecuted = false; - let payTxHash: string | undefined = state.state.squidRouterPayTxHash; - const timeoutAt = Date.now() + timeoutMs; - - // The signal-aware sleeps make abandoned executions unwind when the processor - // times out this phase; without them every timed-out execution left an immortal - // polling loop behind, and they piled up against the SquidRouter rate limit. - await sleep(Math.min(this.initialDelayMs ?? SQUIDROUTER_INITIAL_DELAY_MS, timeoutMs), signal); - - while (!isExecuted) { - if (Date.now() >= timeoutAt) { - throw this.createRecoverableError(`SquidRouterPayPhaseHandler: Bridge status check timed out after ${timeoutMs}ms`); - } - - // Set when the initial gas funding ran this iteration: the fetched status - // predates that payment, so acting on it (e.g. topping up "insufficient" gas) - // would double-pay. The next iteration sees a fresh status. - let fundedThisIteration = false; - // Kept for the failure path: an error after a successful status fetch (e.g. in - // gas funding) must not masquerade as an "unknown/API outage" classification. - let lastAxelarScanStatus: AxelarScanStatusResponse | undefined; - // Outcome of a confirm recovery already attempted this iteration, so the stuck - // monitor reports it instead of re-invoking the helper into its own cooldown. - let recoveryOutcome: string | undefined; - - try { - const squidRouterStatus = await this.getSquidrouterStatus(swapHash, state, quote, signal); - - if (!squidRouterStatus) { - logger.warn(`SquidRouterPayPhaseHandler: No squidRouter status found for swap hash ${swapHash}.`); - } else if (squidRouterStatus.status === "success") { - logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Squidrouter.`); - isExecuted = true; - break; - } - - const isGmp = squidRouterStatus ? squidRouterStatus.isGMPTransaction : true; - - if (isGmp) { - const axelarScanStatus = await getStatusAxelarScan(swapHash, this.statusRequestSignal(signal)); - lastAxelarScanStatus = axelarScanStatus ?? undefined; - - if (!axelarScanStatus) { - logger.info(`SquidRouterPayPhaseHandler: Axelar status not found yet for hash ${swapHash}.`); - } else if (axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed") { - logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Axelar.`); - isExecuted = true; - break; - } else if (!payTxHash) { - logger.info("SquidRouterPayPhaseHandler: Bridge transaction detected on Axelar. Proceeding to fund gas."); - fundedThisIteration = true; - - const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); - const logIndex = Number(axelarScanStatus.id.split("_")[2]); - - payTxHash = await this.executeFundTransaction(nativeToFundRaw, swapHash as `0x${string}`, logIndex, state, quote); - - let subsidyToken: SubsidyToken; - let payerAccount: `0x${string}` | undefined; - - if (quote.inputCurrency === FiatToken.BRL) { - subsidyToken = SubsidyToken.ETH; - payerAccount = this.baseWalletClient.account?.address as `0x${string}` | undefined; - } else { - subsidyToken = SubsidyToken.MATIC; - payerAccount = this.polygonWalletClient.account?.address as `0x${string}` | undefined; - } - - const subsidyAmount = nativeToDecimal(nativeToFundRaw, 18).toNumber(); - - if (payerAccount) { - await this.createSubsidy(state, subsidyAmount, subsidyToken, payerAccount, payTxHash); - } - - // Single-key patch: a full-blob write from this execution's snapshot - // could erase the top-up marker a concurrent execution claimed while - // the (abort-unaware) funding transaction was in flight. - await this.patchStateKey(state, "squidRouterPayTxHash", payTxHash); - } else if (axelarScanStatus.status === "called" && axelarScanStatus.confirm_failed) { - recoveryOutcome = await this.maybeRecoverStuckConfirm(state, swapHash, axelarScanStatus.call?.chain, signal); - } - - if (!fundedThisIteration) { - await this.monitorStuckGmp(state, swapHash, quote, axelarScanStatus ?? undefined, signal, { recoveryOutcome }); - } - } else { - logger.info("SquidRouterPayPhaseHandler: Same-chain transaction detected. Skipping Axelar check."); - } - } catch (error) { - // Status APIs down is exactly how a stuck transfer looked in production, so - // the stuck check must also run when no status could be fetched at all. When - // the failure happened after a successful fetch (e.g. gas funding), the - // fetched status is passed along so the alert classifies the real GMP state. - await this.monitorStuckGmp(state, swapHash, quote, lastAxelarScanStatus, signal, { lastError: error, recoveryOutcome }); - throw this.createRecoverableError( - `SquidRouterPayPhaseHandler: Failed to check bridge status for ${swapHash}, error: ${error instanceof Error ? error.message : String(error)}` - ); - } - - await sleep(this.pollIntervalMs, signal); - } - } - - /** - * Per-request bound for status API calls: a hung request aborts after - * STATUS_REQUEST_TIMEOUT_MS (or when the phase processor gives up), so an outage - * surfaces as a classifiable failure instead of stalling the loop indefinitely. - */ - private statusRequestSignal(signal?: AbortSignal): AbortSignal { - const timeoutSignal = AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS); - return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; - } - - /** - * Atomically patch a single key of the JSONB state column via jsonb_set instead - * of writing the whole blob: a full-blob write from a stale in-memory snapshot - * could erase keys a concurrent execution persisted in the meantime (e.g. wipe - * the top-up marker and re-open a payment claim). `guardSql` turns the patch - * into a conditional claim; the return value is the number of rows updated. - * Also mirrors a successful patch into the in-memory state. Raw SQL because - * Model.update() JSON-stringifies fn/where expression objects on JSONB columns - * instead of rendering them; `key` and `guardSql` are compile-time literals, - * the value and guard parameters are bound replacements. - */ - private async patchStateKey( - state: RampState, - key: keyof StateMetadata & string, - value: string, - guardSql = "TRUE", - guardReplacements: Record = {} - ): Promise { - const sequelizeInstance = RampState.sequelize; - if (!sequelizeInstance) { - throw new Error("SquidRouterPayPhaseHandler: RampState model is not attached to a sequelize instance"); - } - const [, affectedRows] = await sequelizeInstance.query( - `UPDATE ramp_states SET state = jsonb_set(state, '{${key}}', :patchValue::jsonb), updated_at = NOW() WHERE id = :rampId AND (${guardSql})`, - { - replacements: { patchValue: JSON.stringify(value), rampId: state.id, ...guardReplacements }, - type: QueryTypes.UPDATE - } - ); - const updatedRows = typeof affectedRows === "number" ? affectedRows : 0; - if (updatedRows > 0) { - state.state = { ...state.state, [key]: value }; - } - return updatedRows; - } - - /** - * Axelar's relayer does not retry a failed validator confirmation poll, so a transfer - * whose poll failed stays in status "called" forever. Ask Axelar's recovery signing - * service for a new ConfirmGatewayTx and broadcast it, which restarts the poll. - * Attempts are rate-limited via a timestamp persisted in the ramp state, and failures - * are swallowed so the status loop keeps polling and retries after the cooldown. - * Returns the actual outcome for the ops alert's "action taken" field. - */ - private async maybeRecoverStuckConfirm( - state: RampState, - swapHash: string, - sourceChain: string | undefined, - signal?: AbortSignal - ): Promise { - // An unparseable persisted timestamp yields NaN; treat it as "never attempted" so - // the comparison below stays well-defined (NaN comparisons are always false). - const parsedLastAttempt = state.state.axelarConfirmRecoveryAt ? new Date(state.state.axelarConfirmRecoveryAt).getTime() : 0; - const lastAttempt = Number.isFinite(parsedLastAttempt) ? parsedLastAttempt : 0; - if (Date.now() - lastAttempt < AXELAR_CONFIRM_RECOVERY_COOLDOWN_MS) { - return `confirm recovery on cooldown (last attempt ${new Date(lastAttempt).toISOString()})`; - } - - if (!sourceChain) { - logger.warn( - `SquidRouterPayPhaseHandler: Confirm poll failed for ${swapHash} but Axelar status has no source chain; cannot attempt recovery.` - ); - return "confirm recovery unavailable: Axelar status has no source chain"; - } - - // Persist the attempt timestamp before broadcasting so a failing relayer is not - // hammered on every 10s poll iteration. Single-key patch: a full-blob write from - // this execution's snapshot could erase the top-up marker a concurrent - // execution just claimed. - await this.patchStateKey(state, "axelarConfirmRecoveryAt", new Date().toISOString()); - - try { - const axelarTxHash = await recoverAxelarStuckConfirm(swapHash, sourceChain, signal); - logger.info( - `SquidRouterPayPhaseHandler: Confirm poll failed for ${swapHash}; broadcast recovery ConfirmGatewayTx ${axelarTxHash} on Axelar.` - ); - return `broadcast recovery ConfirmGatewayTx ${axelarTxHash} on Axelar`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`SquidRouterPayPhaseHandler: Axelar stuck-confirm recovery attempt failed for ${swapHash}: ${message}`); - return `confirm recovery attempt failed: ${message}`; - } - } - - /** Time since the ramp entered squidRouterPay, spanning retried executions. */ - private getElapsedInPhaseMs(state: RampState): number { - const entry = [...(state.phaseHistory ?? [])].reverse().find(e => e.phase === "squidRouterPay"); - const startIso = entry?.timestamp ?? state.createdAt; - const start = startIso ? new Date(startIso).getTime() : Number.NaN; - return Number.isFinite(start) ? Date.now() - start : 0; - } - - /** - * Active monitoring for a GMP that has been in squidRouterPay past the stuck - * threshold: classify the Axelar state, take the safe recovery action for that - * state, and alert ops. Never throws — the surrounding status loop (or its error - * path) must proceed unchanged. Completion still requires an executed status or - * arrived destination balance; nothing here marks the phase successful. - */ - private async monitorStuckGmp( - state: RampState, - swapHash: string, - quote: QuoteTicket, - axelarScanStatus: AxelarScanStatusResponse | undefined, - signal?: AbortSignal, - context: { lastError?: unknown; recoveryOutcome?: string } = {} - ): Promise { - try { - // An aborted execution has been abandoned by the processor (a retry may already - // be running); it must not take recovery actions or send payments. - if (signal?.aborted) { - return; - } - - const elapsedMs = this.getElapsedInPhaseMs(state); - if (elapsedMs < (this.stuckAlertThresholdMs ?? getSquidRouterPayStuckAlertMs())) { - return; - } - - const classification = classifyGmpStatus(axelarScanStatus); - if (classification === "executed") { - return; - } - - let actionTaken = "none"; - if (classification === "insufficient_gas") { - actionTaken = await this.maybeTopUpGas(state, swapHash, quote, axelarScanStatus, signal); - } else if (classification === "waiting_source_confirmation" || classification === "source_confirmation_stuck") { - // A transfer sitting in "called" this long has a stalled validator poll even - // when axelarscan has not flagged confirm_failed; a fresh ConfirmGatewayTx is - // safe (public tx hash only) and restarts the poll. Cooldown-gated. When the - // confirm_failed branch already recovered this iteration, report that real - // outcome instead of re-invoking the helper into its own fresh cooldown. - actionTaken = - context.recoveryOutcome ?? - (await this.maybeRecoverStuckConfirm(state, swapHash, axelarScanStatus?.call?.chain, signal)); - } - - await this.alertStuckGmp(state, swapHash, classification, axelarScanStatus, elapsedMs, actionTaken, context.lastError); - } catch (error) { - logger.warn( - `SquidRouterPayPhaseHandler: Stuck-GMP monitor failed for ramp ${state.id}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - /** - * One-time supplemental addNativeGas top-up for a transfer whose paid gas Axelar - * reports as insufficient. The "pending" sentinel is claimed via a conditional - * UPDATE (marker must still be absent in the database) BEFORE broadcasting and is - * reconciled to the tx hash after, so neither a crash in between nor a concurrent - * execution can cause a second payment — a top-up with unknown outcome is left - * for manual handling via the ops alert. Overpayment is refunded by the gas - * service to the funding wallet. Returns a human-readable summary for the alert. - */ - private async maybeTopUpGas( - state: RampState, - swapHash: string, - quote: QuoteTicket, - axelarScanStatus: AxelarScanStatusResponse | undefined, - signal?: AbortSignal - ): Promise { - if (!state.state.squidRouterPayTxHash) { - return "initial gas payment still pending; regular funding flow will pay"; - } - if (state.state.squidRouterExtraGasTxHash === EXTRA_GAS_PENDING_MARKER) { - return "gas top-up previously attempted with unknown outcome; not retrying — check the funding wallet's transactions manually"; - } - if (state.state.squidRouterExtraGasTxHash) { - return `gas top-up already sent (${state.state.squidRouterExtraGasTxHash}); not topping up again`; - } - if (!axelarScanStatus?.fees) { - return "cannot top up gas: Axelar status has no fee data"; - } - const logIndex = Number(axelarScanStatus.id?.split("_")[2]); - if (!Number.isFinite(logIndex)) { - return `cannot top up gas: malformed Axelar status id "${axelarScanStatus.id}"`; - } - if (signal?.aborted) { - return "execution aborted before gas top-up; not sending"; - } - - const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); - // Atomic claim: only the execution that flips the still-absent marker to - // "pending" may broadcast. A concurrent execution (e.g. a timed-out handler - // racing its retry) loses the conditional update and takes no action. - const claimedRows = await this.patchStateKey( - state, - "squidRouterExtraGasTxHash", - EXTRA_GAS_PENDING_MARKER, - `state->>'squidRouterExtraGasTxHash' IS NULL` - ); - if (claimedRows === 0) { - return "gas top-up already claimed by a concurrent execution; not sending"; - } - const extraGasTxHash = await this.executeFundTransaction( - nativeToFundRaw, - swapHash as `0x${string}`, - logIndex, - state, - quote - ); - await this.patchStateKey(state, "squidRouterExtraGasTxHash", extraGasTxHash); - - // The Subsidy dedup guard (one row per ramp+phase) already holds the initial gas - // payment, so this top-up is not recorded there. Keep this line alertable for - // accounting. - logger.warn( - `SQUIDROUTER_EXTRA_GAS_PAID: supplemental Axelar gas top-up sent. ramp=${state.id} amountRaw=${nativeToFundRaw} tx=${extraGasTxHash}` - ); - return `sent one-time gas top-up ${extraGasTxHash} (${nativeToDecimal(nativeToFundRaw, 18).toNumber()} native units)`; - } - - private async alertStuckGmp( - state: RampState, - swapHash: string, - classification: GmpClassification, - axelarScanStatus: AxelarScanStatusResponse | undefined, - elapsedMs: number, - actionTaken: string, - lastError?: unknown - ): Promise { - // NaN-safe like axelarConfirmRecoveryAt: an unparseable timestamp means "never". - const previousAlertAt = state.state.squidRouterStuckAlertedAt; - const parsedLastAlert = previousAlertAt ? new Date(previousAlertAt).getTime() : 0; - const lastAlert = Number.isFinite(parsedLastAlert) ? parsedLastAlert : 0; - if (Date.now() - lastAlert < STUCK_ALERT_REPEAT_MS) { - return; - } - - // Claim the alert slot with a compare-and-set on the persisted timestamp — - // before sending, so a failing webhook is not hammered every poll iteration, - // and conditionally, so concurrent executions cannot double-alert. - const claimedRows = previousAlertAt - ? await this.patchStateKey( - state, - "squidRouterStuckAlertedAt", - new Date().toISOString(), - `state->>'squidRouterStuckAlertedAt' = :previousAlertAt`, - { previousAlertAt } - ) - : await this.patchStateKey( - state, - "squidRouterStuckAlertedAt", - new Date().toISOString(), - `state->>'squidRouterStuckAlertedAt' IS NULL` - ); - if (claimedRows === 0) { - return; - } - - const guidanceByClassification: Record = { - executed: "", - execution_failed: "destination execution failed — external; retry the execution manually from the Axelarscan page", - insufficient_gas: "Vortex-actionable: Axelar reports the paid gas as insufficient", - relayer_pending: - "gas paid and call approved — likely external Axelar/Squid relayer latency; manual execute possible on Axelarscan", - source_confirmation_stuck: "validator confirm poll failed — auto-recovery attempted; external if it persists", - unknown: "status unavailable or not indexed — possible Squid/Axelarscan API outage; check the Axelarscan link manually", - waiting_source_confirmation: "waiting for Axelar source confirmation — auto-recovery attempted; external if it persists" - }; - - const lastErrorLog = state.errorLogs?.[state.errorLogs.length - 1]; - const lastErrorText = - lastError instanceof Error ? lastError.message : lastError ? String(lastError) : (lastErrorLog?.error ?? "none"); - - const text = [ - `squidRouterPay stuck for ${Math.round(elapsedMs / 60000)} minutes`, - `- ramp: ${state.id}`, - `- classification: ${classification} (${guidanceByClassification[classification]})`, - `- axelar status: ${axelarScanStatus?.status ?? "unavailable"} (confirm_failed=${axelarScanStatus?.confirm_failed ?? "n/a"}, is_insufficient_fee=${axelarScanStatus?.is_insufficient_fee ?? "n/a"}, gas_status=${axelarScanStatus?.gas_status ?? "n/a"})`, - `- source tx: ${swapHash}`, - `- squid quote id: ${state.state.squidRouterQuoteId ?? "unknown"}`, - `- axelarscan: https://axelarscan.io/gmp/${swapHash}`, - `- gas payment tx: ${state.state.squidRouterPayTxHash ?? "none"}`, - `- action taken: ${actionTaken}`, - `- last error: ${lastErrorText}` - ].join("\n"); - - logger.warn(`SQUIDROUTER_PAY_STUCK: ${text}`); - - const notifier = this.getSlackNotifier(); - if (notifier) { - try { - await notifier.sendMessage({ text }); - } catch (error) { - logger.warn( - `SquidRouterPayPhaseHandler: Failed to send stuck-GMP Slack alert for ramp ${state.id}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - } - - private getSlackNotifier(): SlackNotifier | null { - if (this.slackNotifier === undefined) { - try { - this.slackNotifier = new SlackNotifier(); - } catch { - logger.warn( - "SquidRouterPayPhaseHandler: Slack notifier unavailable (SLACK_WEB_HOOK_TOKEN not set); stuck-GMP alerts will only be logged." - ); - this.slackNotifier = null; - } - } - return this.slackNotifier; - } - - /** - * Execute a call to the Axelar gas service and fund the bridge process. - * Routes to the appropriate network-specific method based on input currency. - * @param tokenValueRaw The amount of native token to fund the transaction with. - * @param swapHash The swap transaction hash. - * @param logIndex The log index from Axelar scan. - * @param state The current ramp state. - * @returns Hash of the transaction that funds the Axelar gas service. - */ - private async executeFundTransaction( - tokenValueRaw: string, - swapHash: `0x${string}`, - logIndex: number, - state: RampState, - quote: QuoteTicket - ): Promise { - if (quote.inputCurrency === FiatToken.BRL) { - return this.executeFundTransactionOnBase(tokenValueRaw, swapHash, logIndex); - } else { - return this.executeFundTransactionOnPolygon(tokenValueRaw, swapHash, logIndex); - } - } - - /** - * Execute a call to the Axelar gas service on Polygon network. - * @param tokenValueRaw The amount of MATIC to fund the transaction with. - * @param swapHash The swap transaction hash. - * @param logIndex The log index from Axelar scan. - * @returns Hash of the transaction that funds the Axelar gas service. - */ - private async executeFundTransactionOnPolygon( - tokenValueRaw: string, - swapHash: `0x${string}`, - logIndex: number - ): Promise { - try { - const walletClientAccount = this.polygonWalletClient.account; - - if (!walletClientAccount) { - throw new Error("SquidRouterPayPhaseHandler: Polygon wallet client account not found."); - } - - // Create addNativeGas transaction data - const transactionData = encodeFunctionData({ - abi: axelarGasServiceAbi, - args: [swapHash, logIndex, walletClientAccount.address], - functionName: "addNativeGas" - }); - - const { maxFeePerGas, maxPriorityFeePerGas } = await this.polygonPublicClient.estimateFeesPerGas(); - - const gasPaymentHash = await this.polygonWalletClient.sendTransaction({ - account: walletClientAccount, - chain: polygon, - data: transactionData, - maxFeePerGas, - maxPriorityFeePerGas, - to: AXL_GAS_SERVICE_EVM as `0x${string}`, - value: BigInt(tokenValueRaw) - }); - - logger.info(`SquidRouterPayPhaseHandler: Polygon fund transaction sent with hash: ${gasPaymentHash}`); - return gasPaymentHash; - } catch (error) { - logger.error("SquidRouterPayPhaseHandler: Error funding gas to Axelar gas service on Polygon: ", error); - throw new Error("SquidRouterPayPhaseHandler: Failed to send Polygon transaction"); - } - } - - /** - * Execute a call to the Axelar gas service on Base network. - * @param tokenValueRaw The amount of ETH to fund the transaction with. - * @param swapHash The swap transaction hash. - * @param logIndex The log index from Axelar scan. - * @returns Hash of the transaction that funds the Axelar gas service. - */ - private async executeFundTransactionOnBase(tokenValueRaw: string, swapHash: `0x${string}`, logIndex: number): Promise { - try { - const walletClientAccount = this.baseWalletClient.account; - - if (!walletClientAccount) { - throw new Error("SquidRouterPayPhaseHandler: Base wallet client account not found."); - } - - // Create addNativeGas transaction data - const transactionData = encodeFunctionData({ - abi: axelarGasServiceAbi, - args: [swapHash, logIndex, walletClientAccount.address], - functionName: "addNativeGas" - }); - - const { maxFeePerGas, maxPriorityFeePerGas } = await this.basePublicClient.estimateFeesPerGas(); - - const gasPaymentHash = await this.baseWalletClient.sendTransaction({ - account: walletClientAccount, - chain: base, - data: transactionData, - maxFeePerGas: maxFeePerGas * 2n, - maxPriorityFeePerGas: maxPriorityFeePerGas * 2n, - to: AXL_GAS_SERVICE_EVM as `0x${string}`, - value: BigInt(tokenValueRaw) - }); - - logger.info(`SquidRouterPayPhaseHandler: Base fund transaction sent with hash: ${gasPaymentHash}`); - return gasPaymentHash; - } catch (error) { - logger.error("SquidRouterPayPhaseHandler: Error funding gas to Axelar gas service on Base: ", error); - throw new Error("SquidRouterPayPhaseHandler: Failed to send Base transaction"); - } - } - - // Takes the processor signal (not a pre-bounded request signal): each request gets - // its own fresh 30s child bound, so a Squid request that hangs into its timeout - // does not leave an already-aborted signal for the Axelar fallback. - private async getSquidrouterStatus( - swapHash: string, - state: RampState, - quote: QuoteTicket, - signal?: AbortSignal - ): Promise { - try { - // Always Polygon for Monerium/Alfredpay onramp, Base for BRL - const fromChain = - quote.inputCurrency === FiatToken.EURC || isAlfredpayToken(quote.inputCurrency as FiatToken) - ? Networks.Polygon - : quote.inputCurrency === FiatToken.BRL - ? Networks.Base - : Networks.Moonbeam; - const fromChainId = getNetworkId(fromChain)?.toString(); - const toChain = quote.to === Networks.AssetHub ? Networks.Moonbeam : quote.to; - const toChainId = getNetworkId(toChain)?.toString(); - - if (!fromChainId || !toChainId) { - throw new Error("SquidRouterPayPhaseHandler: Invalid from or to network for Squidrouter status check"); - } - - const squidRouterStatus = await getStatus( - swapHash, - fromChainId, - toChainId, - state.state.squidRouterQuoteId, - this.statusRequestSignal(signal) - ); - return squidRouterStatus; - } catch (squidRouterError) { - logger.warn( - `SquidRouterPayPhaseHandler: SquidRouter status check failed for swap hash ${swapHash}, attempting Axelar fallback: ${squidRouterError instanceof Error ? squidRouterError.message : String(squidRouterError)}` - ); - - try { - const axelarScanStatus = await getStatusAxelarScan(swapHash, this.statusRequestSignal(signal)); - - if (!axelarScanStatus) { - throw new Error( - `SquidRouterPayPhaseHandler: Axelar scan status not found for swap hash ${swapHash} during fallback attempt.` - ); - } - - // Map Axelar status to SquidRouter format, assuming GMP transaction. - const mappedStatus = - axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed" - ? "success" - : axelarScanStatus.status; - - return { - id: "", - isGMPTransaction: true, - routeStatus: [], - squidTransactionStatus: "", - status: mappedStatus - } as SquidRouterPayResponse; - } catch (axelarError) { - logger.error( - `SquidRouterPayPhaseHandler: Both SquidRouter and Axelar fallback failed for swap hash ${swapHash}. Axelar fallback error: ${axelarError instanceof Error ? axelarError.message : String(axelarError)}` - ); - throw new Error(`SquidRouterPayPhaseHandler: Failed to fetch Squidrouter status for swap hash ${swapHash}`); - } - } - } - - private calculateGasFeeInUnits(feeResponse: AxelarScanStatusFees, estimatedGas: string | number): string { - const baseFeeInUnitsBig = Big(feeResponse.source_base_fee); - - // Calculate the Execution Fee (with multiplier) in native units - // This is the cost to execute the transaction on the destination chain. - const estimatedGasBig = Big(estimatedGas); - const sourceGasPriceBig = Big(feeResponse.source_token.gas_price); - - // Calculate base execution fee: gasLimit * gasPrice - const executionFeeUnits = estimatedGasBig.mul(sourceGasPriceBig); - - // Apply the gas multiplier. - const multiplier = feeResponse.execute_gas_multiplier; - const executionFeeWithMultiplier = executionFeeUnits.mul(multiplier); - - const totalGasFee = baseFeeInUnitsBig.add(executionFeeWithMultiplier); - // .add(l1ExecutionFeeWithMultiplier); - - // Convert to raw, using source decimals - const sourceDecimals = feeResponse.source_token.gas_price_in_units.decimals; - const totalGasFeeRaw = totalGasFee.mul(Big(10).pow(sourceDecimals)); - - return totalGasFeeRaw.lt(0) ? "0" : totalGasFeeRaw.toFixed(0, 0); - } -} - -export default new SquidRouterPayPhaseHandler(); 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 deleted file mode 100644 index b38408e0a..000000000 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -// eslint-disable-next-line import/no-unresolved -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", - Moonbeam: "moonbeam", - Polygon: "polygon" -} as const; - -const EvmNetworks = Networks; - -const EvmToken = { - USDC: "USDC" -} as const; - -const FiatToken = { - BRL: "BRL", - EURC: "EUR", - USD: "USD" -} as const; - -const RampDirection = { - BUY: "BUY", - 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"; -const APPROVE_TX = "0xapprove"; -const SWAP_TX = "0xswap"; -const APPROVE_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const SWAP_HASH = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - -const sendRawTransaction = mock(async ({ serializedTransaction }: { serializedTransaction: string }) => { - if (serializedTransaction === APPROVE_TX) { - return APPROVE_HASH; - } - if (serializedTransaction === SWAP_TX) { - return SWAP_HASH; - } - throw new Error(`Unexpected transaction ${serializedTransaction}`); -}); -const waitForTransactionReceipt = mock(async () => ({ status: "success" })); -const getTransactionCount = mock(async () => 0); -const checkEvmBalanceForToken = mock(async () => Big(1000)); -const getEvmBalance = mock(async () => Big(0)); -const getOnChainTokenDetails = mock((network: string, token: string) => ({ - assetSymbol: "Monerium EURe", - decimals: 18, - erc20AddressSourceChain: token, - isNative: false, - network -})); -const isEvmTokenDetails = mock(() => true); - -mock.module("@vortexfi/shared", () => ({ - ...sharedReal, - checkEvmBalanceForToken, - EvmClientManager: { - getInstance: () => ({ - getClient: () => ({ - getTransactionCount, - sendRawTransaction, - waitForTransactionReceipt - }) - }) - }, - ALFREDPAY_EVM_TOKEN: "USDT", - EvmNetworks, - EvmToken, - EvmTokenDetails: {}, - evmTokenConfig: { - [Networks.Polygon]: { - EURC: { - erc20AddressSourceChain: EURE_POLYGON_ADDRESS - } - } - }, - FiatToken, - getEvmBalance, - getOnChainTokenDetails, - getNetworkFromDestination: (destination: string) => - Object.values(Networks).includes(destination as (typeof Networks)[keyof typeof Networks]) ? destination : undefined, - getNetworkId: (network: string) => { - if (network === Networks.Base) return 8453; - if (network === Networks.Polygon) return 137; - if (network === Networks.Moonbeam) return 1284; - return undefined; - }, - isAlfredpayToken: () => false, - isEvmTokenDetails, - Networks, - RampDirection, - RampPhase -})); - -mock.module("../../ramp/ramp.service", () => ({ - default: { - appendErrorLog: mock(async () => undefined) - } -})); - -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; -}; - -QuoteTicket.findByPk = mock(async () => quote as any) as typeof QuoteTicket.findByPk; - -function makeState(overrides: Record = {}) { - const state = { - currentPhase: "squidRouterSwap", - errorLogs: [], - from: "sepa", - get() { - const { get: _get, update: _update, ...data } = this; - return data; - }, - id: "ramp-1", - phaseHistory: [], - presignedTxs: [ - { - meta: {}, - network: Networks.Polygon, - nonce: 0, - phase: "squidRouterApprove", - signer: EVM_EPHEMERAL_ADDRESS, - txData: APPROVE_TX - }, - { - meta: {}, - network: Networks.Polygon, - nonce: 1, - phase: "squidRouterSwap", - signer: EVM_EPHEMERAL_ADDRESS, - txData: SWAP_TX - } - ], - quoteId: "quote-1", - state: { - evmEphemeralAddress: EVM_EPHEMERAL_ADDRESS - }, - to: Networks.Base, - type: RampDirection.BUY, - async update(updateData: Record) { - Object.assign(this, updateData); - return this; - }, - ...overrides - }; - return state as any; -} - -describe("SquidRouterPhaseHandler", () => { - beforeEach(() => { - sendRawTransaction.mockClear(); - waitForTransactionReceipt.mockClear(); - getTransactionCount.mockClear(); - checkEvmBalanceForToken.mockClear(); - getEvmBalance.mockClear(); - getOnChainTokenDetails.mockClear(); - isEvmTokenDetails.mockClear(); - }); - - it("submits Squid approve and swap for Monerium EUR onramp to Base USDC", async () => { - quote = { - inputCurrency: FiatToken.EURC, - metadata: { - evmToEvm: { - fromNetwork: Networks.Polygon, - fromToken: EURE_POLYGON_ADDRESS, - inputAmountRaw: "1000", - toNetwork: Networks.Base, - toToken: USDC_BASE_ADDRESS - }, - moneriumMint: { - 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 - }; - - const handler = new SquidRouterPhaseHandler(); - const updatedState = await handler.execute(makeState()); - - expect(sendRawTransaction).toHaveBeenCalledTimes(2); - 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"); - }); - - it("skips Squid for same-chain Base USDC passthrough quotes", async () => { - quote = { - inputCurrency: FiatToken.BRL, - metadata: { - aveniaTransfer: { - outputAmountRaw: "1000" - }, - evmToEvm: { - fromNetwork: Networks.Base, - fromToken: USDC_BASE_ADDRESS, - inputAmountRaw: "1000", - toNetwork: Networks.Base, - toToken: USDC_BASE_ADDRESS - } - }, - network: Networks.Base, - outputCurrency: EvmToken.USDC, - to: Networks.Base - }; - - const handler = new SquidRouterPhaseHandler(); - const updatedState = await handler.execute( - makeState({ - presignedTxs: [] - }) - ); - - expect(sendRawTransaction).not.toHaveBeenCalled(); - 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 deleted file mode 100644 index 39c2635b5..000000000 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { - ALFREDPAY_EVM_TOKEN, - checkEvmBalanceForToken, - EvmClientManager, - EvmNetworks, - EvmTokenDetails, - evmTokenConfig, - FiatToken, - getEvmBalance, - getOnChainTokenDetails, - isAlfredpayToken, - isEvmTokenDetails, - Networks, - RampDirection, - RampPhase -} from "@vortexfi/shared"; -import { PublicClient } from "viem"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { isFiatToOwnStablecoinBaseDirect } from "../../quote/utils"; -import { BasePhaseHandler } from "../base-phase-handler"; - -/** - * Handler for the squidRouter phase - */ -export class SquidRouterPhaseHandler extends BasePhaseHandler { - private getClient(network: EvmNetworks): PublicClient { - 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 - */ - public getPhaseName(): RampPhase { - return "squidRouterSwap"; - } - - /** - * Execute the phase - * @param state The current ramp state - * @returns The updated ramp state - */ - protected async executePhase(state: RampState): Promise { - logger.info(`Executing squidRouter phase for ramp ${state.id}`); - - if (state.state.isDirectTransfer === true) { - logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for direct-transfer ramp ${state.id}`); - return this.transitionToNextPhase(state, "destinationTransfer"); - } - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) { - logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Base direct-transfer route (ramp ${state.id})`); - return this.transitionToNextPhase(state, "destinationTransfer"); - } - - if (state.type === RampDirection.SELL) { - logger.info("SquidRouter phase is not supported for off-ramp"); - return state; - } - - // Alfredpay mints USDT directly on Polygon. Skip the swap ONLY when the requested - // output is that direct token; metadata.to is the destination network, not the output - // token, so other Polygon outputs (e.g. USDC) still need a real USDT→output swap. - const isAlfredpayOnramp = - state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken) && !!quote.metadata.alfredpayMint; - - if (isAlfredpayOnramp && quote.metadata.request.to === Networks.Polygon && quote.outputCurrency === ALFREDPAY_EVM_TOKEN) { - logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Alfredpay direct-token onramp (ramp ${state.id})`); - return this.transitionToNextPhase(state, "finalSettlementSubsidy"); - } - - const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; - if ( - !bridgeMeta?.inputAmountRaw || - !bridgeMeta.fromNetwork || - !bridgeMeta.fromToken || - !bridgeMeta.toNetwork || - !bridgeMeta.toToken - ) { - throw new Error("Missing bridge metadata required to validate squidRouter input balance"); - } - - const isSameChainSameTokenPassthrough = - bridgeMeta.fromNetwork === bridgeMeta.toNetwork && - bridgeMeta.fromToken.toLowerCase() === bridgeMeta.toToken.toLowerCase(); - if (isSameChainSameTokenPassthrough) { - logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for same-chain same-token passthrough (ramp ${state.id})`); - return this.transitionToNextPhase(state, "finalSettlementSubsidy"); - } - - const evmEphemeralAddress = state.state.evmEphemeralAddress; - if (!evmEphemeralAddress) { - throw new Error("Missing EVM ephemeral address to validate squidRouter input balance"); - } - - const sourceNetwork = bridgeMeta.fromNetwork as EvmNetworks; - const sourceTokenDetails = Object.values(evmTokenConfig[sourceNetwork] || {}).find( - token => token.erc20AddressSourceChain.toLowerCase() === bridgeMeta.fromToken.toLowerCase() - ) as EvmTokenDetails | undefined; - - if (!sourceTokenDetails) { - throw new Error( - `Could not resolve source token details on ${bridgeMeta.fromNetwork} for token ${bridgeMeta.fromToken} in squidRouter phase` - ); - } - - try { - try { - await checkEvmBalanceForToken({ - amountDesiredRaw: bridgeMeta.inputAmountRaw, - chain: sourceNetwork, - intervalMs: 1000, - ownerAddress: evmEphemeralAddress, - timeoutMs: 15000, - tokenDetails: sourceTokenDetails - }); - } catch (_error) { - throw this.createRecoverableError( - `Unable to verify squidRouter input balance for ${evmEphemeralAddress} on ${sourceNetwork}; balance may not be settled yet` - ); - } - - // 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"); - - if (!approveTransaction || !swapTransaction) { - throw new Error("Missing presigned transactions for squidRouter phase"); - } - - let approveHash = state.state.squidRouterApproveHash; - // Check if the approve transaction has already been sent - if (!approveHash) { - const accountNonce = await this.getNonce(sourceNetwork, approveTransaction.signer as `0x${string}`); - if (approveTransaction.nonce && approveTransaction.nonce !== accountNonce) { - logger.warn( - `Nonce mismatch for approve transaction of account ${approveTransaction.signer}: expected ${accountNonce}, got ${approveTransaction.nonce}` - ); - } - - // Execute the approve transaction - approveHash = await this.executeTransaction(sourceNetwork, approveTransaction.txData as string); - logger.info(`Approve transaction executed with hash: ${approveHash}`); - - // Update the state with the approve hash immediately after sending the transaction - state.state = { ...state.state, squidRouterApproveHash: approveHash }; - await state.update({ state: state.state }); - } - - // Wait for the approve transaction to be confirmed - await this.waitForTransactionConfirmation(sourceNetwork, approveHash); - logger.info(`Approve transaction confirmed: ${approveHash}`); - - // Execute the swap transaction - const swapHash = await this.executeTransaction(sourceNetwork, swapTransaction.txData as string); - logger.info(`Swap transaction executed with hash: ${swapHash}`); - - // Update the state with the transaction hashes - 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}`); - - // preSettlementBalance was captured before the swap (see above); do not re-snapshot here. - // Transition to the next phase - return this.transitionToNextPhase(state, "squidRouterPay"); - } catch (error) { - logger.error(`Error in squidRouter phase for ramp ${state.id}:`, error); - throw error; - } - } - - private async executeTransaction(network: EvmNetworks, txData: string): Promise { - try { - const publicClient = this.getClient(network); - const txHash = await publicClient.sendRawTransaction({ - serializedTransaction: txData as `0x${string}` - }); - return txHash; - } catch (error) { - logger.error("Error sending raw transaction", error); - throw new Error("Failed to send transaction"); - } - } - - private async waitForTransactionConfirmation(network: EvmNetworks, txHash: string): Promise { - const maxRetries = 3; - const baseDelay = 5000; // 5 seconds - const maxDelay = 30000; // 30 seconds - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - const publicClient = this.getClient(network); - const receipt = await publicClient.waitForTransactionReceipt({ - hash: txHash as `0x${string}` - }); - - if (!receipt || receipt.status !== "success") { - throw new Error(`SquidRouterPhaseHandler: Transaction ${txHash} failed or was not found`); - } - - return; - } catch (error) { - const isLastAttempt = attempt === maxRetries; - // Based on error message returned by the client. - const isTransactionNotFoundError = - error instanceof Error && - (error.message.includes("TransactionReceiptNotFoundError") || - error.message.includes("could not be found") || - error.message.includes("Transaction may not be processed")); - - if (isLastAttempt) { - throw new Error( - `SquidRouterPhaseHandler: Error waiting for transaction confirmation after ${maxRetries + 1} attempts: ${error}` - ); - } - - if (isTransactionNotFoundError) { - const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); - - logger.info( - `SquidRouterPhaseHandler: Transaction ${txHash} not found on attempt ${attempt + 1}/${maxRetries + 1}. Retrying in ${delay}ms...` - ); - - await new Promise(resolve => setTimeout(resolve, delay)); - } else { - throw this.createRecoverableError(`SquidRouterPhaseHandler: Error waiting for transaction confirmation: ${error}`); - } - } - } - } - - private async getNonce(network: EvmNetworks, address: `0x${string}`): Promise { - try { - const publicClient = this.getClient(network); - return await publicClient.getTransactionCount({ address }); - } catch (error) { - logger.error("Error getting nonce", error); - throw this.createRecoverableError("Failed to get transaction nonce"); - } - } -} - -export default new SquidRouterPhaseHandler(); 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 deleted file mode 100644 index 0cdf5e479..000000000 --- a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { - EvmClientManager, - EvmNetworks, - getNetworkFromDestination, - isNetworkEVM, - isSignedTypedDataArray, - RampPhase, - SignedTypedData -} from "@vortexfi/shared"; -import { erc20Abi } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import { tokenRelayerAbi } from "../../../../contracts/TokenRelayer"; -import RampState from "../../../../models/rampState.model"; -import { PhaseError } from "../../../errors/phase-error"; -import { getRelayerAddress } from "../../transactions/offramp/routes/evm-to-alfredpay"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { verifyUserSubmittedTxByHash } from "../helpers/user-tx-verifier"; - -type VrsSignature = { v: number; r: `0x${string}`; s: `0x${string}` }; - -const permitAbi = [ - { - inputs: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "deadline", type: "uint256" }, - { name: "v", type: "uint8" }, - { name: "r", type: "bytes32" }, - { name: "s", type: "bytes32" } - ], - name: "permit", - outputs: [], - stateMutability: "nonpayable", - type: "function" - } -] as const; - -const transferFromAbi = [ - { - inputs: [ - { name: "from", type: "address" }, - { name: "to", type: "address" }, - { name: "value", type: "uint256" } - ], - name: "transferFrom", - outputs: [{ name: "", type: "bool" }], - stateMutability: "nonpayable", - type: "function" - } -] as const; - -function extractPermitFields(permitTypedData: SignedTypedData) { - const permitMessage = permitTypedData.message; - return { - deadline: BigInt(permitMessage.deadline as string), - owner: permitMessage.owner as `0x${string}`, - spender: permitMessage.spender as `0x${string}`, - token: permitTypedData.domain.verifyingContract as `0x${string}`, - value: BigInt(permitMessage.value as string) - }; -} - -// Phase description: call the relayer contract's `execute` function with both the token permit and -// the signed squidrouter call. -export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { - private evmClientManager: EvmClientManager; - - constructor() { - super(); - this.evmClientManager = EvmClientManager.getInstance(); - } - - public getPhaseName(): RampPhase { - 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 { - publicClient: this.evmClientManager.getClient(fromNetwork), - walletClient: this.evmClientManager.getWalletClient(fromNetwork, executorAccount) - }; - } - - private extractSignature(typedData: SignedTypedData, label: string): VrsSignature { - const sig = typedData.signature as VrsSignature | undefined; - if (!sig) { - throw this.createUnrecoverableError(`${label} signature not found`); - } - return sig; - } - - private async saveHashAndAwaitReceipt( - state: RampState, - hash: `0x${string}`, - fromNetwork: EvmNetworks, - label: string - ): Promise { - logger.info(`${label} tx sent: ${hash}`); - - const updatedState = await state.update({ - state: { ...state.state, squidRouterPermitExecutionHash: hash } - }); - - const { publicClient } = this.getExecutorClients(fromNetwork); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); - - if (!receipt || receipt.status !== "success") { - throw this.createRecoverableError(`${label} tx failed: ${hash}`); - } - - logger.info(`${label} tx confirmed: ${hash}`); - return this.transitionToNextPhase(updatedState, "fundEphemeral"); - } - - private async waitForUserHash( - state: RampState, - hash: `0x${string}` | undefined, - fromNetwork: EvmNetworks, - label: string, - presignedPhase: RampPhase - ): Promise { - await verifyUserSubmittedTxByHash({ fromNetwork, hash, label, presignedPhase, state }); - logger.info(`${label} tx confirmed: ${hash}`); - } - - private async executeNoPermitFallback(state: RampState, fromNetwork: EvmNetworks): Promise { - if (state.state.isDirectTransfer) { - await this.waitForUserHash( - state, - state.state.squidRouterNoPermitTransferHash as `0x${string}` | undefined, - fromNetwork, - "No-permit direct transfer", - "squidRouterNoPermitTransfer" - ); - } else { - const hasApproveBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterNoPermitApprove"); - if (hasApproveBlueprint) { - await this.waitForUserHash( - state, - state.state.squidRouterNoPermitApproveHash as `0x${string}` | undefined, - fromNetwork, - "No-permit approve", - "squidRouterNoPermitApprove" - ); - } - await this.waitForUserHash( - state, - state.state.squidRouterNoPermitSwapHash as `0x${string}` | undefined, - fromNetwork, - "No-permit swap", - "squidRouterNoPermitSwap" - ); - } - - return this.transitionToNextPhase(state, "fundEphemeral"); - } - - private async executeDirectTransfer( - state: RampState, - signedTypedDataArray: SignedTypedData[], - fromNetwork: EvmNetworks - ): Promise { - if (!isSignedTypedDataArray(signedTypedDataArray) || signedTypedDataArray.length !== 1) { - throw this.createUnrecoverableError("Invalid txData format for direct transfer: expected array of 1 SignedTypedData"); - } - - const [permitTypedData] = signedTypedDataArray; - const permitSig = this.extractSignature(permitTypedData, "Permit"); - const { token, owner, spender, value, deadline } = extractPermitFields(permitTypedData); - const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; - - const { walletClient, publicClient } = 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); - - // 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], - functionName: "allowance" - }); - - 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({ - abi: transferFromAbi, - address: token, - args: [owner, ephemeralAddress, value], - functionName: "transferFrom" - }); - - return this.saveHashAndAwaitReceipt(state, transferHash, fromNetwork, "Direct transfer"); - } - - private async executeRelayerTransfer( - state: RampState, - signedTypedDataArray: SignedTypedData[], - fromNetwork: EvmNetworks - ): Promise { - if (!isSignedTypedDataArray(signedTypedDataArray) || signedTypedDataArray.length !== 2) { - throw this.createUnrecoverableError("Invalid txData format: expected array of 2 SignedTypedData objects"); - } - - const [permitTypedData, payloadTypedData] = signedTypedDataArray; - const permitSig = this.extractSignature(permitTypedData, "Permit"); - const payloadSig = this.extractSignature(payloadTypedData, "Payload"); - const { token, owner, value, deadline } = extractPermitFields(permitTypedData); - - const payloadMessage = payloadTypedData.message; - const payloadData = payloadMessage.data as `0x${string}`; - const payloadNonce = BigInt(payloadMessage.nonce as string); - const payloadDeadline = BigInt(payloadMessage.deadline as string); - const executionValue = state.state.squidRouterPermitExecutionValue; - if (executionValue === undefined || executionValue === null) { - throw this.createUnrecoverableError("Missing squidRouterPermitExecutionValue in ramp state"); - } - - 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), - args: [ - { - deadline, - owner, - payloadData, - payloadDeadline, - payloadNonce, - payloadR: payloadSig.r, - payloadS: payloadSig.s, - payloadV: payloadSig.v, - payloadValue: executionValue, - permitR: permitSig.r, - permitS: permitSig.s, - permitV: permitSig.v, - token, - value - } - ], - functionName: "execute", - value: BigInt(executionValue) - }); - - return this.saveHashAndAwaitReceipt(state, hash, fromNetwork, "Relayer execute"); - } - - protected async executePhase(state: RampState): Promise { - logger.info(`Executing squidRouterPermitExecute phase for ramp ${state.id}`); - - const fromNetwork = getNetworkFromDestination(state.from); - - if (!fromNetwork || !isNetworkEVM(fromNetwork)) { - throw this.createUnrecoverableError(`Unsupported network for squidRouterPermitExecute phase: ${state.from}`); - } - - try { - // No-permit fallback: the user submitted the substitute transaction(s) from their own - // wallet during the signing step. We just verify their on-chain success and proceed. - if (state.state.isNoPermitFallback) { - return await this.executeNoPermitFallback(state, fromNetwork); - } - - const existingHash = state.state.squidRouterPermitExecutionHash || null; - - if (existingHash) { - logger.info(`Found existing squidRouter permit execution hash for ramp ${state.id}: ${existingHash}`); - - try { - const publicClient = this.evmClientManager.getClient(fromNetwork); - const receipt = await publicClient.waitForTransactionReceipt({ - hash: existingHash as `0x${string}` - }); - - if (receipt && receipt.status === "success") { - logger.info(`Existing squidRouter permit execution transaction was successful for ramp ${state.id}`); - return this.transitionToNextPhase(state, "fundEphemeral"); - } else { - logger.info( - `Existing squidRouter permit execution transaction was not successful (status: ${receipt?.status}), will retry` - ); - } - } catch (error) { - logger.info(`Could not verify existing transaction status: ${error}, will retry`); - } - } - - const permitExecuteTransaction = this.getPresignedTransaction(state, "squidRouterPermitExecute"); - if (!permitExecuteTransaction) { - throw this.createUnrecoverableError("Missing presigned transaction for squidRouterPermitExecute phase"); - } - - const signedTypedDataArray = permitExecuteTransaction.txData as SignedTypedData[]; - if (state.state.isDirectTransfer) { - return await this.executeDirectTransfer(state, signedTypedDataArray, fromNetwork); - } - - const executionValue = state.state.squidRouterPermitExecutionValue; - if (executionValue === undefined || executionValue === null) { - throw this.createUnrecoverableError("Missing squidRouterPermitExecutionValue in ramp state"); - } - - const executionValueBigInt = BigInt(executionValue); - const maxAllowedValue = BigInt("1000000000000000000"); // 1 ETH in wei - if (executionValueBigInt > maxAllowedValue) { - throw this.createUnrecoverableError( - `squidRouterPermitExecutionValue ${executionValueBigInt} exceeds maximum allowed ${maxAllowedValue}` - ); - } - - return await this.executeRelayerTransfer(state, signedTypedDataArray, fromNetwork); - } catch (error) { - logger.error(`Error in squidRouterPermitExecute phase for ramp ${state.id}:`, error); - - if (error instanceof PhaseError) { - throw error; - } - - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - throw this.createRecoverableError(`SquidrouterPermitExecuteHandler: ${errorMessage}`); - } - } -} - -export default new SquidrouterPermitExecuteHandler(); 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 deleted file mode 100644 index 12dbc538a..000000000 --- a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { - ApiManager, - AssetHubToken, - checkEvmBalanceForToken, - EvmClientManager, - EvmNetworks, - EvmToken, - EvmTokenDetails, - FiatToken, - getOnChainTokenDetails, - Networks, - nativeToDecimal, - RampCurrency, - RampDirection, - RampPhase, - waitUntilTrueWithTimeout -} from "@vortexfi/shared"; -import Big from "big.js"; -import { encodeFunctionData, erc20Abi } from "viem"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { SubsidyToken } from "../../../../models/subsidy.model"; -import { getFundingAccount } from "../../../controllers/subsidize.controller"; -import { PhaseError } from "../../../errors/phase-error"; -import { priceFeedService } from "../../priceFeed.service"; -import { BasePhaseHandler } from "../base-phase-handler"; -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"; - } - - public getMaxRetries(): number { - return 200; - } - - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (quote.metadata.nablaSwapEvm) { - return this.executeEvmSubsidize(state, quote); - } - - return this.executeSubstrateSubsidize(state, quote); - } - - private async executeSubstrateSubsidize(state: RampState, quote: QuoteTicket): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - - const { substrateEphemeralAddress } = state.state as StateMetadata; - - if (!substrateEphemeralAddress) { - throw new Error("SubsidizePostSwapPhaseHandler: State metadata corrupted. This is a bug."); - } - - if (!quote.metadata.nablaSwap) { - throw new Error("Missing nablaSwap in quote metadata"); - } - - if (!quote.metadata.subsidy) { - throw new Error("Missing subsidy information in quote metadata"); - } - - try { - const balanceResponse = await pendulumNode.api.query.tokens.accounts( - substrateEphemeralAddress, - quote.metadata.nablaSwap.outputCurrencyId - ); - - const balanceJson = balanceResponse.toJSON() as { free?: string | number } | null; - const currentBalance = Big(String(balanceJson?.free ?? "0")); - if (currentBalance.eq(Big(0))) { - throw new Error("Invalid phase: input token did not arrive yet on pendulum"); - } - - // Add a default/base expected output amount from the swap - let expectedSwapOutputAmountRaw = Big(quote.metadata.nablaSwap.outputAmountRaw).plus( - quote.metadata.subsidy.subsidyAmountInOutputTokenRaw - ); - - // Try to find the required amount to subsidize on the quote metadata - if (state.type === RampDirection.BUY) { - if (quote.metadata.pendulumToHydrationXcm) { - expectedSwapOutputAmountRaw = Big(quote.metadata.pendulumToHydrationXcm.inputAmountRaw); - } else if (quote.metadata.pendulumToAssethubXcm) { - expectedSwapOutputAmountRaw = Big(quote.metadata.pendulumToAssethubXcm.inputAmountRaw); - } else if (quote.metadata.pendulumToMoonbeamXcm) { - expectedSwapOutputAmountRaw = Big(quote.metadata.pendulumToMoonbeamXcm.inputAmountRaw); - } - } else { - if (quote.metadata.pendulumToMoonbeamXcm) { - expectedSwapOutputAmountRaw = Big(quote.metadata.pendulumToMoonbeamXcm.inputAmountRaw); - } - } - - const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); - - const didBalanceReachExpected = async () => { - const balanceResponse = await pendulumNode.api.query.tokens.accounts( - substrateEphemeralAddress, - quote.metadata.nablaSwap?.outputCurrencyId - ); - - const innerJson = balanceResponse.toJSON() as { free?: string | number } | null; - const currentBalance = Big(String(innerJson?.free ?? "0")); - const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); - return requiredAmount.lte(Big(0)); - }; - - if (requiredAmount.gt(Big(0))) { - const fundingAccountKeypair = getFundingAccount(); - - const fundingBalanceResponse = await pendulumNode.api.query.tokens.accounts( - fundingAccountKeypair.address, - quote.metadata.nablaSwap?.outputCurrencyId - ); - const fundingBalanceJson = fundingBalanceResponse.toJSON() as { free?: string | number } | null; - const fundingBalance = Big(String(fundingBalanceJson?.free ?? "0")); - if (fundingBalance.lt(requiredAmount)) { - throw this.createUnrecoverableError( - `SubsidizePostSwapPhaseHandler: Funding account balance too low for subsidy: has ${fundingBalance.toFixed(0)}, needs ${requiredAmount.toFixed(0)}` - ); - } - - logger.info( - `Subsidizing post-swap with ${requiredAmount.toFixed()} to reach target value of ${expectedSwapOutputAmountRaw.toFixed(0, 0)}` - ); - const result = await apiManager.executeApiCall( - api => - api.tx.tokens.transfer( - substrateEphemeralAddress, - quote.metadata.nablaSwap?.outputCurrencyId, - requiredAmount.toFixed(0, 0) - ), - fundingAccountKeypair, - networkName - ); - - const subsidyAmount = nativeToDecimal(requiredAmount, quote.metadata.nablaSwap.outputDecimals).toNumber(); - const subsidyToken = quote.metadata.nablaSwap.outputCurrency as unknown as SubsidyToken; - - await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccountKeypair.address, result.hash); - - // Wait for the balance to update - await waitUntilTrueWithTimeout(didBalanceReachExpected, 2000); - } - - return this.transitionToNextPhase(state, this.substrateNextPhaseSelector(state, quote)); - } catch (e) { - logger.error("Error in subsidizePostSwap (substrate):", e); - throw this.createRecoverableError("SubsidizePostSwapPhaseHandler: Failed to subsidize post swap."); - } - } - - private async executeEvmSubsidize(state: RampState, quote: QuoteTicket): Promise { - const { evmEphemeralAddress } = state.state as StateMetadata; - - if (!evmEphemeralAddress) { - throw new Error("SubsidizePostSwapPhaseHandler: State metadata corrupted. This is a bug."); - } - - if (!quote.metadata.evmToEvm) { - throw new Error("Missing evmToEvm information in quote metadata"); - } - - if (!quote.metadata.nablaSwapEvm) { - throw new Error("Missing nablaSwapEvm information in quote metadata"); - } - - if (!quote.metadata.subsidy) { - throw new Error("Missing subsidy information in quote metadata"); - } - - try { - // Get token details for the output token - const outputToken = quote.metadata.nablaSwapEvm.outputCurrency as EvmToken; - - const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outputToken) as EvmTokenDetails; - if (!outputTokenDetails) { - throw new Error( - `Could not find token details for output token ${outputToken} on network ${Networks.Base}. Invalid quote metadata.` - ); - } - - // Wait for token settlement before checking balance - await new Promise(resolve => setTimeout(resolve, EVM_SETTLEMENT_DELAY_MS)); - - // Check current balance on EVM - const currentBalance = await checkEvmBalanceForToken({ - amountDesiredRaw: "1", - chain: outputTokenDetails.network as EvmNetworks, - intervalMs: 1000, // Just check if there's any balance - ownerAddress: evmEphemeralAddress, - timeoutMs: 5000, - tokenDetails: outputTokenDetails - }); - - if (currentBalance.eq(Big(0))) { - throw new Error("Invalid phase: input token did not arrive yet on EVM"); - } - - // Add a default/base expected output amount from the swap - let expectedSwapOutputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw).plus( - quote.metadata.subsidy.subsidyAmountInOutputTokenRaw - ); - - logger.debug(`SubsidizePostSwapHandler (EVM): expectedSwapOutputAmountRaw ${expectedSwapOutputAmountRaw.toFixed(0, 0)}`); - - // Try to find the required amount to subsidize on the quote metadata - if (state.type === RampDirection.BUY) { - // For BUY operations, use the evmToEvm inputAmountRaw as the expected amount - expectedSwapOutputAmountRaw = Big(quote.metadata.evmToEvm?.inputAmountRaw); - } else { - expectedSwapOutputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); - } - - const subsidyComponents = calculatePostSwapSubsidyComponents({ - currentBalanceRaw: currentBalance, - discountSubsidyAmountRaw: quote.metadata.subsidy.subsidyAmountInOutputTokenRaw, - expectedOutputAmountRaw: expectedSwapOutputAmountRaw, - quotedActualOutputAmountRaw: quote.metadata.subsidy.actualOutputAmountRaw - }); - const requiredAmount = subsidyComponents.requiredAmountRaw; - logger.debug( - `SubsidizePostSwapHandler (EVM): requiredAmount ${requiredAmount.toFixed(0, 0)}, ` + - `discrepancyAmount ${subsidyComponents.discrepancyAmountRaw.toFixed(0, 0)}, ` + - `discountAmount ${subsidyComponents.discountAmountRaw.toFixed(0, 0)}` - ); - - if (requiredAmount.gt(Big(0))) { - const discrepancySubsidyDecimal = nativeToDecimal( - subsidyComponents.discrepancyAmountRaw, - quote.metadata.nablaSwapEvm.outputDecimals - ).toFixed(); - const discountSubsidyDecimal = nativeToDecimal( - subsidyComponents.discountAmountRaw, - quote.metadata.nablaSwapEvm.outputDecimals - ).toFixed(); - const discrepancySubsidyUsd = subsidyComponents.discrepancyAmountRaw.gt(0) - ? await priceFeedService.convertCurrency( - discrepancySubsidyDecimal, - outputToken as RampCurrency, - EvmToken.USDC as RampCurrency - ) - : "0"; - const discountSubsidyUsd = subsidyComponents.discountAmountRaw.gt(0) - ? await priceFeedService.convertCurrency( - discountSubsidyDecimal, - outputToken as RampCurrency, - EvmToken.USDC as RampCurrency - ) - : "0"; - const quoteOutputUsd = await priceFeedService.convertCurrency( - quote.outputAmount, - quote.outputCurrency as RampCurrency, - EvmToken.USDC as RampCurrency - ); - const discrepancySubsidyCapFraction = config.subsidy.evmSwapSubsidyQuoteFraction; - const discrepancySubsidyCapUsd = Big(quoteOutputUsd).mul(discrepancySubsidyCapFraction); - if (Big(discrepancySubsidyUsd).gt(discrepancySubsidyCapUsd)) { - // Pause for operator intervention without moving the ramp to failed. - throw this.createRecoverableError( - `SubsidizePostSwapPhaseHandler: Required swap discrepancy subsidy $${discrepancySubsidyUsd} exceeds cap $${discrepancySubsidyCapUsd.toFixed(2)} (${discrepancySubsidyCapFraction} of quote output $${quoteOutputUsd}).` - ); - } - - const discountSubsidyCapFraction = config.subsidy.evmPostSwapDiscountSubsidyQuoteFraction; - const discountSubsidyCapUsd = Big(quoteOutputUsd).mul(discountSubsidyCapFraction); - if (Big(discountSubsidyUsd).gt(discountSubsidyCapUsd)) { - // Pause for operator intervention without moving the ramp to failed. - throw this.createRecoverableError( - `SubsidizePostSwapPhaseHandler: Required discount subsidy $${discountSubsidyUsd} exceeds cap $${discountSubsidyCapUsd.toFixed(2)} (${discountSubsidyCapFraction} of quote output $${quoteOutputUsd}).` - ); - } - - const subsidyUsd = Big(discrepancySubsidyUsd).plus(discountSubsidyUsd).toFixed(); - - // Do the actual subsidizing on EVM - logger.info( - `Subsidizing post-swap EVM with ${requiredAmount.toFixed()} ($${subsidyUsd}) to reach target value of ${expectedSwapOutputAmountRaw.toFixed(0, 0)}` - ); - - const evmClientManager = EvmClientManager.getInstance(); - const destinationNetwork = outputTokenDetails.network as EvmNetworks; - const fundingAccount = getEvmFundingAccount(destinationNetwork); - - // Get gas estimates - const publicClient = evmClientManager.getClient(destinationNetwork); - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - - // ERC-20 transfer. - const data = encodeFunctionData({ - abi: erc20Abi, - args: [evmEphemeralAddress as `0x${string}`, BigInt(requiredAmount.toFixed(0))], - functionName: "transfer" - }); - - const txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: outputTokenDetails.erc20AddressSourceChain as `0x${string}`, - value: 0n - }); - - const subsidyAmount = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.outputDecimals).toNumber(); - const subsidyToken = quote.metadata.nablaSwapEvm.outputCurrency as unknown as SubsidyToken; - - await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); - - const receipt = await publicClient.waitForTransactionReceipt({ - hash: txHash as `0x${string}` - }); - - if (!receipt || receipt.status !== "success") { - throw new Error(`SubsidizePostSwapPhaseHandler: Subsidy transaction ${txHash} failed or was not found`); - } - } - - return this.transitionToNextPhase(state, this.evmNextPhaseSelector(state, quote)); - } catch (e) { - logger.error("Error in subsidizePostSwap (EVM):", e); - if (e instanceof PhaseError) { - throw e; - } - throw this.createRecoverableError("SubsidizePostSwapPhaseHandler: Failed to subsidize post swap on EVM."); - } - } - - protected substrateNextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - // onramp cases - if (state.type === RampDirection.BUY) { - if (state.to === "assethub") { - if (quote.outputCurrency === AssetHubToken.USDC) { - // USDC can directly go to AssetHub - return "pendulumToAssethubXcm"; - } else { - // USDT and DOT need to go via Hydration - return "pendulumToHydrationXcm"; - } - } - return "pendulumToMoonbeamXcm"; - } - - // off ramp cases - if (quote.outputCurrency === FiatToken.BRL) { - return "pendulumToMoonbeamXcm"; - } - - if (state.type === RampDirection.SELL) { - throw new Error("SubsidizePostSwapPhaseHandler: Unsupported non-BRL offramp route after Stellar deprecation"); - } - - throw new Error( - `SubsidizePostSwapPhaseHandler: Unrecognized routing combination: direction=${state.type}, to=${state.to}, output=${quote.outputCurrency}` - ); - } - - protected evmNextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - if (state.type === RampDirection.BUY) { - return "squidRouterSwap"; - } - if (quote.outputCurrency === FiatToken.EURC) { - return "mykoboPayoutOnBase"; - } - return "brlaPayoutOnBase"; - } -} - -export default new SubsidizePostSwapPhaseHandler(); 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 deleted file mode 100644 index deaeb49c7..000000000 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { - ALFREDPAY_ERC20_DECIMALS, - ALFREDPAY_ERC20_TOKEN, - ALFREDPAY_EVM_TOKEN, - ApiManager, - checkEvmBalanceForToken, - EvmClientManager, - EvmNetworks, - EvmToken, - EvmTokenDetails, - FiatToken, - getOnChainTokenDetails, - isAlfredpayToken, - Networks, - nativeToDecimal, - RampCurrency, - RampDirection, - RampPhase, - waitUntilTrueWithTimeout -} from "@vortexfi/shared"; -import Big from "big.js"; -import { encodeFunctionData, erc20Abi } from "viem"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { SubsidyToken } from "../../../../models/subsidy.model"; -import { getFundingAccount } from "../../../controllers/subsidize.controller"; -import { PhaseError } from "../../../errors/phase-error"; -import { priceFeedService } from "../../priceFeed.service"; -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"; - } - - public getMaxRetries(): number { - return 200; - } - - protected async executePhase(state: RampState): Promise { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (quote.metadata.nablaSwapEvm) { - return this.executeEvmSubsidize(state, quote); - } - - if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return this.executeEvmSubsidize(state, quote); - } - - return this.executeSubstrateSubsidize(state, quote); - } - - private getEvmSubsidyConfig(state: RampState, quote: QuoteTicket) { - if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) { - if (!quote.metadata.evmToEvm) { - throw new Error("Missing evmToEvm information in quote metadata"); - } - - const inputTokenDetails = getOnChainTokenDetails(Networks.Polygon, ALFREDPAY_EVM_TOKEN) as EvmTokenDetails; - if (!inputTokenDetails) { - throw new Error("Could not find token details for Alfredpay token on Polygon. Invalid quote metadata."); - } - - return { - expectedInputAmountForSwapRaw: quote.metadata.evmToEvm.inputAmountRaw, - inputAmountDecimals: ALFREDPAY_ERC20_DECIMALS, - inputToken: ALFREDPAY_EVM_TOKEN, - inputTokenDetails, - logLabel: "Alfredpay", - nextPhase: "squidRouterSwap" as RampPhase, - subsidyToken: ALFREDPAY_EVM_TOKEN as unknown as SubsidyToken, - tokenContract: ALFREDPAY_ERC20_TOKEN - }; - } - - if (!quote.metadata.nablaSwapEvm) { - throw new Error("Missing nablaSwapEvm information in quote metadata"); - } - - const inputToken = quote.metadata.nablaSwapEvm.inputCurrency as EvmToken; - const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inputToken) as EvmTokenDetails; - if (!inputTokenDetails) { - throw new Error( - `Could not find token details for input token ${inputToken} on network ${Networks.Base}. Invalid quote metadata.` - ); - } - - return { - expectedInputAmountForSwapRaw: quote.metadata.nablaSwapEvm.inputAmountForSwapRaw, - inputAmountDecimals: quote.metadata.nablaSwapEvm.inputDecimals, - inputToken, - inputTokenDetails, - logLabel: "EVM", - nextPhase: "nablaApprove" as RampPhase, - subsidyToken: quote.metadata.nablaSwapEvm.inputCurrency as unknown as SubsidyToken, - tokenContract: inputTokenDetails.erc20AddressSourceChain as `0x${string}` - }; - } - - private async executeSubstrateSubsidize(state: RampState, quote: QuoteTicket): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - - const { substrateEphemeralAddress } = state.state as StateMetadata; - - if (!substrateEphemeralAddress) { - throw new Error("SubsidizePreSwapPhaseHandler: State metadata corrupted. This is a bug."); - } - - if (!quote.metadata.nablaSwap) { - throw new Error("Missing nablaSwap in quote metadata"); - } - - try { - const balanceResponse = await pendulumNode.api.query.tokens.accounts( - substrateEphemeralAddress, - quote.metadata.nablaSwap.inputCurrencyId - ); - - const balanceJson = balanceResponse.toJSON() as { free?: string | number } | null; - const currentBalance = Big(String(balanceJson?.free ?? "0")); - if (currentBalance.eq(Big(0))) { - throw new Error("Invalid phase: input token did not arrive yet on pendulum"); - } - - const expectedInputAmountForSwapRaw = quote.metadata.nablaSwap.inputAmountForSwapRaw; - - const requiredAmount = Big(expectedInputAmountForSwapRaw).sub(currentBalance); - - const didBalanceReachExpected = async () => { - const balanceResponse = await pendulumNode.api.query.tokens.accounts( - substrateEphemeralAddress, - quote.metadata.nablaSwap?.inputCurrencyId - ); - - const innerJson = balanceResponse.toJSON() as { free?: string | number } | null; - const currentBalance = Big(String(innerJson?.free ?? "0")); - return currentBalance.gte(Big(expectedInputAmountForSwapRaw)); - }; - - if (requiredAmount.gt(Big(0))) { - const fundingAccountKeypair = getFundingAccount(); - - const fundingBalanceResponse = await pendulumNode.api.query.tokens.accounts( - fundingAccountKeypair.address, - quote.metadata.nablaSwap?.inputCurrencyId - ); - const fundingBalanceJson = fundingBalanceResponse.toJSON() as { free?: string | number } | null; - const fundingBalance = Big(String(fundingBalanceJson?.free ?? "0")); - if (fundingBalance.lt(requiredAmount)) { - throw this.createUnrecoverableError( - `SubsidizePreSwapPhaseHandler: Funding account balance too low for subsidy: has ${fundingBalance.toFixed(0)}, needs ${requiredAmount.toFixed(0)}` - ); - } - - logger.info( - `Subsidizing pre-swap with ${requiredAmount.toFixed()} to reach target value of ${expectedInputAmountForSwapRaw}` - ); - - const result = await apiManager.executeApiCall( - api => - api.tx.tokens.transfer( - substrateEphemeralAddress, - quote.metadata.nablaSwap?.inputCurrencyId, - requiredAmount.toFixed(0, 0) - ), - fundingAccountKeypair, - networkName - ); - - const subsidyAmount = nativeToDecimal(requiredAmount, quote.metadata.nablaSwap.inputDecimals).toNumber(); - const subsidyToken = quote.metadata.nablaSwap.inputCurrency as unknown as SubsidyToken; - - await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccountKeypair.address, result.hash); - - await waitUntilTrueWithTimeout(didBalanceReachExpected, 5000); - } - - return this.transitionToNextPhase(state, "nablaApprove"); - } catch (e) { - logger.error("Error in subsidizePreSwap (substrate):", e); - throw this.createRecoverableError("SubsidizePreSwapPhaseHandler: Failed to subsidize pre swap."); - } - } - - private async executeEvmSubsidize(state: RampState, quote: QuoteTicket): Promise { - const { evmEphemeralAddress } = state.state as StateMetadata; - - if (!evmEphemeralAddress) { - throw new Error("SubsidizePreSwapPhaseHandler: State metadata corrupted. This is a bug."); - } - - try { - const { - inputAmountDecimals, - inputToken, - inputTokenDetails, - logLabel, - nextPhase, - expectedInputAmountForSwapRaw, - subsidyToken, - tokenContract - } = this.getEvmSubsidyConfig(state, quote); - - // Wait for token settlement before checking balance - await new Promise(resolve => setTimeout(resolve, EVM_SETTLEMENT_DELAY_MS)); - - // Check current balance on EVM - const currentBalance = await checkEvmBalanceForToken({ - amountDesiredRaw: "1", - chain: inputTokenDetails.network as EvmNetworks, - intervalMs: 1000, // Just check if there's any balance - ownerAddress: evmEphemeralAddress, - timeoutMs: 5000, - tokenDetails: inputTokenDetails - }); - - if (currentBalance.eq(Big(0))) { - throw new Error("Invalid phase: input token did not arrive yet on EVM"); - } - - const requiredAmount = Big(expectedInputAmountForSwapRaw).sub(currentBalance); - logger.debug(`SubsidizePreSwapHandler (${logLabel}): requiredAmount ${requiredAmount.toString()}`); - - if (requiredAmount.gt(Big(0))) { - const subsidyDecimal = nativeToDecimal(requiredAmount, inputAmountDecimals).toString(); - const subsidyUsd = await priceFeedService.convertCurrency( - subsidyDecimal, - inputToken as RampCurrency, - EvmToken.USDC as RampCurrency - ); - const quoteOutputUsd = await priceFeedService.convertCurrency( - quote.outputAmount, - quote.outputCurrency as RampCurrency, - EvmToken.USDC as RampCurrency - ); - const subsidyCapFraction = config.subsidy.evmSwapSubsidyQuoteFraction; - const subsidyCapUsd = Big(quoteOutputUsd).mul(subsidyCapFraction); - if (Big(subsidyUsd).gt(subsidyCapUsd)) { - // Pause for operator intervention without moving the ramp to failed. - throw this.createRecoverableError( - `SubsidizePreSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (${subsidyCapFraction} of quote output $${quoteOutputUsd}).` - ); - } - - // Do the actual subsidizing on EVM - logger.info( - `Subsidizing pre-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedInputAmountForSwapRaw}` - ); - - const evmClientManager = EvmClientManager.getInstance(); - const destinationNetwork = inputTokenDetails.network as EvmNetworks; - const fundingAccount = getEvmFundingAccount(destinationNetwork); - - // Get gas estimates - const publicClient = evmClientManager.getClient(destinationNetwork); - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - - // ERC-20 transfer. - const data = encodeFunctionData({ - abi: erc20Abi, - args: [evmEphemeralAddress as `0x${string}`, BigInt(requiredAmount.toFixed(0))], - functionName: "transfer" - }); - - const txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: tokenContract, - value: 0n - }); - - const subsidyAmount = nativeToDecimal(requiredAmount, inputAmountDecimals).toNumber(); - - await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); - - const receipt = await publicClient.waitForTransactionReceipt({ - hash: txHash as `0x${string}` - }); - - if (!receipt || receipt.status !== "success") { - throw new Error(`SubsidizePreSwapPhaseHandler: Subsidy transaction ${txHash} failed or was not found`); - } - } - - return this.transitionToNextPhase(state, nextPhase); - } catch (e) { - logger.error("Error in subsidizePreSwap (EVM):", e); - if (e instanceof PhaseError) { - throw e; - } - throw this.createRecoverableError("SubsidizePreSwapPhaseHandler: Failed to subsidize pre swap on EVM."); - } - } -} - -export default new SubsidizePreSwapPhaseHandler(); 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 deleted file mode 100644 index 0cbec2f74..000000000 --- a/apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import { syncAveniaOnHoldState } from "./brla-onramp-hold"; - -const getAveniaPayinTickets = mock(async () => [{ id: "ticket-1", status: "ON-HOLD" }]); - -const brlaApiService = { - getAveniaPayinTickets -}; - -function makeState(initialOnHold?: boolean) { - const state: { aveniaTicketId: string; onHold?: boolean } = { - aveniaTicketId: "ticket-1", - onHold: initialOnHold - }; - return { - state: { - ...state - } - }; -} - -describe("syncAveniaOnHoldState", () => { - beforeEach(() => { - getAveniaPayinTickets.mockClear(); - getAveniaPayinTickets.mockImplementation(async () => [{ id: "ticket-1", status: "ON-HOLD" }]); - }); - - it("marks the ramp as on hold when the Avenia pay-in ticket is ON-HOLD", async () => { - const state = makeState(false); - - const ticketFound = await syncAveniaOnHoldState(state.state, async nextState => { - Object.assign(state.state, nextState); - }, brlaApiService, "subaccount-1"); - - expect(ticketFound).toBe(true); - expect(getAveniaPayinTickets).toHaveBeenCalledWith("subaccount-1"); - expect(state.state.onHold).toBe(true); - }); - - it("normalizes Avenia ticket status casing", async () => { - getAveniaPayinTickets.mockImplementationOnce(async () => [{ id: "ticket-1", status: "on-hold" }]); - const state = makeState(false); - - await syncAveniaOnHoldState(state.state, async nextState => { - Object.assign(state.state, nextState); - }, brlaApiService, "subaccount-1"); - - expect(state.state.onHold).toBe(true); - }); - - it("clears the on-hold flag when the Avenia pay-in ticket is no longer ON-HOLD", async () => { - getAveniaPayinTickets.mockImplementationOnce(async () => [{ id: "ticket-1", status: "PAID" }]); - const state = makeState(true); - - await syncAveniaOnHoldState(state.state, async nextState => { - Object.assign(state.state, nextState); - }, brlaApiService, "subaccount-1"); - - expect(state.state.onHold).toBe(false); - }); - - 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, updateState, brlaApiService, "subaccount-1"); - - expect(ticketFound).toBe(false); - expect(updateState).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/api/src/api/services/phases/helpers/user-tx-verifier.ts b/apps/api/src/api/services/phases/helpers/user-tx-verifier.ts index 4e269165b..99a30f814 100644 --- a/apps/api/src/api/services/phases/helpers/user-tx-verifier.ts +++ b/apps/api/src/api/services/phases/helpers/user-tx-verifier.ts @@ -1,6 +1,7 @@ import { EvmClientManager, EvmNetworks, isEvmTransactionData, RampPhase, UnsignedTx } from "@vortexfi/shared"; import RampState from "../../../../models/rampState.model"; import { RecoverablePhaseError, UnrecoverablePhaseError } from "../../../errors/phase-error"; +import { abortableCall } from "../blocks/core/cancellation"; // Reads the unsigned blueprint from state.unsignedTxs — NOT state.presignedTxs. For user-wallet // phases the presignedTxs path is rejected by validation, so the blueprint is the only source of @@ -22,6 +23,7 @@ interface VerifyUserSubmittedTxOptions { fromNetwork: EvmNetworks; label: string; presignedPhase: RampPhase; + signal?: AbortSignal; } // Cross-checks an integrator-reported on-chain tx hash against the unsigned blueprint we issued @@ -32,7 +34,8 @@ export async function verifyUserSubmittedTxByHash({ hash, fromNetwork, label, - presignedPhase + presignedPhase, + signal }: VerifyUserSubmittedTxOptions): Promise { if (!hash) { throw new RecoverablePhaseError(`${label} hash not yet reported by frontend`); @@ -47,7 +50,7 @@ export async function verifyUserSubmittedTxByHash({ const publicClient = EvmClientManager.getInstance().getClient(fromNetwork); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); if (!receipt || receipt.status !== "success") { throw new RecoverablePhaseError(`${label} tx failed: ${hash}`); } @@ -61,7 +64,7 @@ export async function verifyUserSubmittedTxByHash({ ); } - const tx = await publicClient.getTransaction({ hash }); + const tx = await abortableCall(signal, () => publicClient.getTransaction({ hash })); if (tx.input.toLowerCase() !== expectedData) { throw new UnrecoverablePhaseError(`${label} tx ${hash} calldata does not match presigned payload`); } diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index 798aeffb7..048143e53 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -1,6 +1,35 @@ -import { AlfredpayFiatPaymentInstructions, ExtrinsicOptions, IbanPaymentData } from "@vortexfi/shared"; +import { + AlfredpayFiatPaymentInstructions, + EphemeralAccountType, + ExtrinsicOptions, + IbanPaymentData, + Networks, + RampPhase +} from "@vortexfi/shared"; +import type { FlowIdentity } from "./blocks/core/identity"; + +export interface SquidRouterDeliveryEvidence { + baselineRaw?: string; + destinationNetwork: Networks; + destinationToken: string; + expectedAmountRaw: string; + kind: "provider-terminal" | "destination-balance"; + minimumRatioBps?: number; + observedAt: string; + observedBalanceRaw?: string; + provider?: "axelar" | "squid"; + providerStatus?: string; + sourceTransactionHash: string; +} export interface StateMetadata { + flow?: FlowIdentity; + accountAddresses?: Partial>; + blockState?: Record; + transactionPlan?: { + nativePrefunding?: Record; + settlementBaselines?: Record; + }; nablaSoftMinimumOutputRaw: string; // Only used in offramp squidRouterReceiverId: string; @@ -33,6 +62,9 @@ export interface StateMetadata { squidRouterApproveHash: string; squidRouterSwapHash: string; squidRouterPayTxHash: string; + // Completion evidence for the exact Squid route. Provider-terminal evidence is + // preferred; an EVM balance delta may be used as an explicit bounded fallback. + squidRouterDeliveryEvidence?: SquidRouterDeliveryEvidence; // Timestamp of the last Axelar stuck-confirm recovery attempt, persisted so // retried phase executions respect the cooldown instead of re-broadcasting. axelarConfirmRecoveryAt?: string; @@ -76,7 +108,7 @@ export interface StateMetadata { squidRouterPermitExecutionValue?: string; nablaSwapTxHash?: string; isDirectTransfer?: boolean; - // Snapshot of destination-token raw balance on the ephemeral, recorded immediately before squidRouterPay so finalSettlementSubsidy can compute actual bridge delivery rather than total balance (which may include leftover dust from prior phases). + // Legacy settlement snapshot. Block flows use transactionPlan.settlementBaselines. preSettlementBalance?: string; // Fallback path used when input ERC20 does not support EIP-2612 permit. // The user submits the substituting transaction(s) from their own wallet and @@ -91,4 +123,7 @@ export interface StateMetadata { mykoboReceivablesAddress?: string; mykoboPayoutTxHash?: `0x${string}`; mykoboTransactionReference?: string; + // Explicit phase flow for this ramp (set at registration by route builder). + // When present, the PhaseProcessor follows this sequence instead of handler-driven routing. + phaseFlow?: RampPhase[]; } 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 f0bf47f23..0934124dc 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 @@ -10,7 +10,7 @@ import { mnemonicGenerate } from "@polkadot/util-crypto"; // 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", () => { +mock.module("./blocks/core/nabla", () => { return { calculateNablaSwapOutputEvm: async (request: { inputAmountForSwap: string; @@ -68,7 +68,7 @@ import RampState, { RampStateAttributes, RampStateCreationAttributes } from "../ import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; import { QuoteService } from "../quote"; import { RampService } from "../ramp/ramp.service"; -import registerPhaseHandlers from "./register-handlers"; +import { registerBlockFlowHandlers } from "./blocks/register-handlers"; import { StateMetadata } from "./meta-state-types"; const EVM_TESTING_ADDRESS = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; @@ -197,7 +197,7 @@ RampRecoveryWorker.prototype.start = mock(async (): Promise => { } // 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). +// Opt-in via RUN_LIVE_TESTS=1 (see docs/operations-testing.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) { @@ -253,7 +253,7 @@ describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR offramp contract test ( expect(Object.values(MykoboTransactionStatus)).toContain(fetched.transaction.status as MykoboTransactionStatus); }); - it("creates a EUR offramp quote on Base via QuoteService and populates nablaSwapEvm metadata", async () => { + it("creates a EUR offramp quote on Base via QuoteService and populates the nablaSwap block", async () => { const quoteService = new QuoteService(); const quote = await quoteService.createQuote({ @@ -272,16 +272,19 @@ describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR offramp contract test ( outputAmount: quote.outputAmount, totalFeeFiat: quote.totalFeeFiat }); - console.log("nablaSwapEvm metadata:", quoteTicket.metadata.nablaSwapEvm); + const metadata = quoteTicket.metadata as unknown as { + blocks: { nablaSwap?: { outputAmountDecimal?: string; outputAmountRaw?: string } }; + }; + console.log("nablaSwap metadata:", metadata.blocks.nablaSwap); expect(quote.inputCurrency).toBe(EvmToken.USDC); expect(quote.outputCurrency).toBe(FiatToken.EURC); expect(Number(quote.outputAmount)).toBeGreaterThan(0); expect(Number(quote.totalFeeFiat)).toBeGreaterThan(0); - expect(quoteTicket.metadata.nablaSwapEvm).toBeDefined(); - expect(quoteTicket.metadata.nablaSwapEvm?.outputAmountDecimal).toBeDefined(); - expect(quoteTicket.metadata.nablaSwapEvm?.outputAmountRaw).toBeDefined(); - expect(Number(quoteTicket.metadata.nablaSwapEvm?.outputAmountDecimal)).toBeGreaterThan(0); + expect(metadata.blocks.nablaSwap).toBeDefined(); + expect(metadata.blocks.nablaSwap?.outputAmountDecimal).toBeDefined(); + expect(metadata.blocks.nablaSwap?.outputAmountRaw).toBeDefined(); + expect(Number(metadata.blocks.nablaSwap?.outputAmountDecimal)).toBeGreaterThan(0); }); // SKIPPED: registerRamp unconditionally rejects EURC quotes with 503 "EUR ramps are @@ -291,7 +294,7 @@ describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR offramp contract test ( const rampService = new RampService(); const quoteService = new QuoteService(); - registerPhaseHandlers(); + registerBlockFlowHandlers(); const quote = await quoteService.createQuote({ from: Networks.Base as DestinationType, @@ -351,4 +354,4 @@ describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR offramp contract test ( 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 494f5e932..b9c4c6f23 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 @@ -10,7 +10,7 @@ import { mnemonicGenerate } from "@polkadot/util-crypto"; // 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", () => { +mock.module("./blocks/core/nabla", () => { return { calculateNablaSwapOutputEvm: async (request: { inputAmountForSwap: string; @@ -69,7 +69,7 @@ import RampState, { RampStateAttributes, RampStateCreationAttributes } from "../ import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; import { QuoteService } from "../quote"; import { RampService } from "../ramp/ramp.service"; -import registerPhaseHandlers from "./register-handlers"; +import { registerBlockFlowHandlers } from "./blocks/register-handlers"; import { StateMetadata } from "./meta-state-types"; const EVM_TESTING_ADDRESS = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; @@ -198,7 +198,7 @@ RampRecoveryWorker.prototype.start = mock(async (): Promise => { } // 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). +// Opt-in via RUN_LIVE_TESTS=1 (see docs/operations-testing.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) { @@ -255,7 +255,7 @@ describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR onramp contract test (r expect(Object.values(MykoboTransactionStatus)).toContain(fetched.transaction.status as MykoboTransactionStatus); }); - it("creates a EUR onramp quote on Base via QuoteService and populates mykoboMint metadata", async () => { + it("creates a EUR onramp quote on Base via QuoteService and populates the mykoboMint block", async () => { const quoteService = new QuoteService(); const quote = await quoteService.createQuote({ @@ -274,15 +274,18 @@ describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR onramp contract test (r outputAmount: quote.outputAmount, totalFeeFiat: quote.totalFeeFiat }); - console.log("mykoboMint metadata:", quoteTicket.metadata.mykoboMint); + const metadata = quoteTicket.metadata as unknown as { + blocks: { mykoboMint?: { mint: { outputAmountRaw?: string } } }; + }; + console.log("mykoboMint metadata:", metadata.blocks.mykoboMint); expect(quote.inputCurrency).toBe(FiatToken.EURC); expect(quote.outputCurrency).toBe(EvmToken.USDC); expect(Number(quote.outputAmount)).toBeGreaterThan(0); expect(Number(quote.totalFeeFiat)).toBeGreaterThanOrEqual(0); - expect(quoteTicket.metadata.mykoboMint).toBeDefined(); - expect(quoteTicket.metadata.mykoboMint?.outputAmountRaw).toBeDefined(); - expect(Number(quoteTicket.metadata.mykoboMint?.outputAmountRaw)).toBeGreaterThan(0); + expect(metadata.blocks.mykoboMint).toBeDefined(); + expect(metadata.blocks.mykoboMint?.mint.outputAmountRaw).toBeDefined(); + expect(Number(metadata.blocks.mykoboMint?.mint.outputAmountRaw)).toBeGreaterThan(0); }); // SKIPPED: registerRamp unconditionally rejects EURC quotes with 503 "EUR ramps are @@ -292,7 +295,7 @@ describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR onramp contract test (r const rampService = new RampService(); const quoteService = new QuoteService(); - registerPhaseHandlers(); + registerBlockFlowHandlers(); const quote = await quoteService.createQuote({ from: EPaymentMethod.SEPA as DestinationType, @@ -357,4 +360,4 @@ describe.skipIf(!process.env.RUN_LIVE_TESTS)("Mykobo EUR onramp contract test (r 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.cancellation.integration.test.ts b/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts index 0456ab0dd..6be3d4396 100644 --- a/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts +++ b/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts @@ -58,12 +58,12 @@ describe("PhaseProcessor execution cancellation", () => { beforeAll(async () => { await setupTestDatabase(); await resetTestDatabase(); - phaseRegistry.registerHandler(hangingHandler); + phaseRegistry.replaceHandlerForTest(hangingHandler); }); afterAll(() => { if (originalHandler) { - phaseRegistry.registerHandler(originalHandler); + phaseRegistry.replaceHandlerForTest(originalHandler); } else { // The registry has no unregister API; drop the shadow entry directly. (phaseRegistry as unknown as { handlers: Map }).handlers.delete(TEST_PHASE); @@ -117,7 +117,7 @@ describe("PhaseProcessor execution cancellation", () => { }, getPhaseName: () => TEST_PHASE }; - phaseRegistry.registerHandler(syncThrowHandler); + phaseRegistry.replaceHandlerForTest(syncThrowHandler); try { const state = await createTestRampState({ currentPhase: TEST_PHASE }); @@ -135,7 +135,7 @@ describe("PhaseProcessor execution cancellation", () => { expect(reloaded?.processingLock.locked).toBe(false); } finally { process.off("unhandledRejection", onUnhandled); - phaseRegistry.registerHandler(hangingHandler); + phaseRegistry.replaceHandlerForTest(hangingHandler); } }); 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 d96692db1..f76ee77e3 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 @@ -23,7 +23,7 @@ import RampState from "../../../models/rampState.model"; import {QuoteService} from "../quote"; import {RampService} from "../ramp/ramp.service"; import {PhaseProcessor} from "./phase-processor"; -import registerPhaseHandlers from "./register-handlers"; +import { registerBlockFlowHandlers } from "./blocks/register-handlers"; const TAX_ID = process.env.TAX_ID; @@ -159,7 +159,7 @@ QuoteTicket.create = mock(async (data: any) => { } // 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). +// Opt-in via RUN_LIVE_TESTS=1 (see docs/operations-testing.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 { @@ -167,7 +167,7 @@ describe.skipIf(!process.env.RUN_LIVE_TESTS)("Onramp PhaseProcessor Integration const rampService = new RampService(); const quoteService = new QuoteService(); - registerPhaseHandlers(); + registerBlockFlowHandlers(); const additionalData = { destinationAddress: EVM_DESTINATION_ADDRESS, 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 58eebe188..c5ff0fe1c 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 @@ -4,7 +4,7 @@ import path from "node:path"; import RampState, {RampStateAttributes, RampStateCreationAttributes} from "../../../models/rampState.model"; import {PhaseProcessor} from "./phase-processor"; -import registerPhaseHandlers from "./register-handlers"; +import { registerBlockFlowHandlers } from "./blocks/register-handlers"; const fixturePath = path.join(__dirname, "failedRampStateRecovery.json"); @@ -103,14 +103,14 @@ RampState.create = mock(async (data: RampStateCreationAttributes) => { } // Live test: replays a persisted failed ramp state against real services. -// Opt-in via RUN_LIVE_TESTS=1 (see docs/testing-strategy.md). +// Opt-in via RUN_LIVE_TESTS=1 (see docs/operations-testing.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(); // wait for handlers to be registered - registerPhaseHandlers(); + registerBlockFlowHandlers(); await new Promise(resolve => setTimeout(resolve, 1000)); await processor.processRamp(rampState.id); diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index 315f1241c..117ac8a52 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -1,10 +1,18 @@ +import { RampPhase } from "@vortexfi/shared"; import httpStatus from "http-status"; import logger from "../../../config/logger"; import { runWithRampContext } from "../../../config/ramp-context"; import { config } from "../../../config/vars"; import RampState from "../../../models/rampState.model"; import { APIError } from "../../errors/api-error"; -import { PhaseError, RecoverablePhaseError } from "../../errors/phase-error"; +import { + PhaseError, + ReconciliationRequiredPhaseError, + RecoverablePhaseError, + UnrecoverablePhaseError +} from "../../errors/phase-error"; +import { getBlockFlowByIdentity } from "./blocks/flows/catalog"; +import { StateMetadata } from "./meta-state-types"; import { getPhaseProcessorMaxExecutionTimeMs, getPhaseProcessorRetryDelayMs } from "./phase-processor-config"; import phaseRegistry from "./phase-registry"; @@ -175,6 +183,59 @@ export class PhaseProcessor { return now.getTime() - lockTime.getTime() > lockDuration; } + /** + * Resolve the next phase for a ramp. + * + * If the handler explicitly changed the phase (short-circuit override), honor it. + * Otherwise, if a phaseFlow is defined, advance to the next phase in the sequence. + * If no phaseFlow exists (legacy ramp), return the handler's result as-is. + */ + private resolveNextPhase(originalPhase: RampPhase, handlerResult: RampState, state: RampState): RampPhase { + const stateMetadata = state.state as StateMetadata; + const phaseFlow = stateMetadata.phaseFlow; + + // Legacy ramp without phaseFlow — handler must set the next phase + if (!phaseFlow) { + return handlerResult.currentPhase; + } + if (new Set(phaseFlow).size !== phaseFlow.length) { + throw new Error(`PhaseProcessor: phaseFlow contains duplicate phases for ramp ${state.id}`); + } + + const currentIndex = phaseFlow.indexOf(originalPhase); + if (currentIndex === -1) { + throw new Error(`PhaseProcessor: Phase "${originalPhase}" not found in phaseFlow for ramp ${state.id}`); + } + if (currentIndex >= phaseFlow.length - 1) { + throw new Error( + `PhaseProcessor: Phase "${originalPhase}" is the last phase in phaseFlow but not terminal for ramp ${state.id}` + ); + } + + const sequentialNext = phaseFlow[currentIndex + 1]; + + // Handler explicitly changed the phase. It may only use an edge declared by + // the persisted flow version; legacy flows are limited to the sequential edge + // or the universal fail-closed edge. + if (handlerResult.currentPhase !== originalPhase) { + const allowed = stateMetadata.flow + ? (() => { + const flow = getBlockFlowByIdentity(stateMetadata.flow); + flow.assertState(stateMetadata); + return flow.transitions[originalPhase] ?? []; + })() + : [sequentialNext, "failed"]; + if (!allowed.includes(handlerResult.currentPhase)) { + throw new Error( + `PhaseProcessor: transition ${originalPhase} -> ${handlerResult.currentPhase} is not allowed for ramp ${state.id}` + ); + } + return handlerResult.currentPhase; + } + + return sequentialNext; + } + /** * Process a phase * @param state The current ramp state @@ -191,8 +252,7 @@ export class PhaseProcessor { // Get the phase handler const handler = phaseRegistry.getHandler(currentPhase); if (!handler) { - logger.warn(`No handler found for phase ${currentPhase}`); - return; + throw new UnrecoverablePhaseError(`No handler registered for phase ${currentPhase}`); } // Execute the phase with a maximum waiting time @@ -220,11 +280,19 @@ export class PhaseProcessor { clearTimeout(timeoutId); }); + // Resolve the next phase: handler short-circuit > explicit flow > handler-driven (legacy) + const nextPhase = this.resolveNextPhase(currentPhase, pendingState, state); + + const phaseHistory = + nextPhase !== pendingState.currentPhase + ? [...pendingState.phaseHistory, { phase: nextPhase, timestamp: new Date() }] + : pendingState.phaseHistory; + // Single source of authority for phase transitions. // Persist only the phase-related fields on the original persisted instance // to avoid inserting new records or clobbering unrelated columns. const updatedState = await state.update( - { currentPhase: pendingState.currentPhase, phaseHistory: pendingState.phaseHistory }, + { currentPhase: nextPhase, phaseHistory }, { fields: ["currentPhase", "phaseHistory"] } ); @@ -259,23 +327,18 @@ export class PhaseProcessor { error instanceof RecoverablePhaseError ? (error as RecoverablePhaseError).minimumWaitSeconds : undefined; if (isRecoverable) { - const currentRetries = this.retriesMap.get(state.id) || 0; - - // Add error to the state - const errorLogs = [ - ...state.errorLogs, - { - details: error.stack || "", - error: error.message || "Unknown error", - isPhaseError, - phase: state.currentPhase, - recoverable: isRecoverable, - timestamp: new Date().toISOString() - } - ]; - - const errorUpdatedState = await state.update({ errorLogs }); + // BasePhaseHandler already persisted this execution error before rethrowing it. + const errorUpdatedState = state; + + if (error instanceof ReconciliationRequiredPhaseError) { + logger.error( + `Pausing ramp ${errorUpdatedState.id} in phase ${state.currentPhase}: financial outcome requires reconciliation` + ); + this.retriesMap.delete(errorUpdatedState.id); + return; + } + const currentRetries = this.retriesMap.get(state.id) || 0; const phaseHandler = phaseRegistry.getHandler(state.currentPhase); const maxRetries = phaseHandler?.getMaxRetries?.() ?? this.MAX_RETRIES; diff --git a/apps/api/src/api/services/phases/phase-registry.ts b/apps/api/src/api/services/phases/phase-registry.ts index e48805e4b..2f8e0a42a 100644 --- a/apps/api/src/api/services/phases/phase-registry.ts +++ b/apps/api/src/api/services/phases/phase-registry.ts @@ -25,10 +25,32 @@ export class PhaseRegistry { */ public registerHandler(handler: PhaseHandler): void { const phaseName = handler.getPhaseName(); + const existing = this.handlers.get(phaseName); + if (existing && existing !== handler) { + if (existing.constructor !== handler.constructor) { + throw new Error(`A different phase handler is already registered for ${phaseName}`); + } + logger.info(`Phase handler for ${phaseName} is already registered`); + return; + } this.handlers.set(phaseName, handler); logger.info(`Registered phase handler for ${phaseName}`); } + /** + * Tests occasionally need to shadow a production handler. Keeping that capability + * explicit prevents production startup code from silently overwriting registrations. + */ + public replaceHandlerForTest(handler: PhaseHandler): PhaseHandler | undefined { + if (process.env.NODE_ENV !== "test") { + throw new Error("replaceHandlerForTest is only available in tests"); + } + const phaseName = handler.getPhaseName(); + const previous = this.handlers.get(phaseName); + this.handlers.set(phaseName, handler); + return previous; + } + /** * Get a phase handler * @param phaseName The name of the phase diff --git a/apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts index a7f1b2548..8c5330775 100644 --- a/apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts +++ b/apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts @@ -3,7 +3,7 @@ import { Transaction as EvmTransaction } from "ethers"; import { erc20Abi } from "viem"; import logger from "../../../../config/logger"; import RampState from "../../../../models/rampState.model"; -import { getEvmFundingAccount } from "../evm-funding"; +import { getEvmFundingAccount } from "../blocks/core/evm-funding"; import { BasePostProcessHandler } from "./base-post-process-handler"; const BASE_CLEANUP_PHASES: CleanupPhase[] = ["baseCleanupBrla", "baseCleanupUsdc", "baseCleanupEurc", "baseCleanupAxlUsdc"]; diff --git a/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts index 4fc143356..55b93f758 100644 --- a/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts +++ b/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts @@ -4,7 +4,7 @@ import { erc20Abi } from "viem"; import logger from "../../../../config/logger"; import { config } from "../../../../config/vars"; import RampState from "../../../../models/rampState.model"; -import { getEvmFundingAccount } from "../evm-funding"; +import { getEvmFundingAccount } from "../blocks/core/evm-funding"; import { BasePostProcessHandler } from "./base-post-process-handler"; const POLYGON_BUY_CLEANUP_PHASES: CleanupPhase[] = ["polygonCleanup"]; diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts deleted file mode 100644 index eb48061c1..000000000 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ /dev/null @@ -1,65 +0,0 @@ -import logger from "../../../config/logger"; -import alfredpayOfframpTransferHandler from "./handlers/alfredpay-offramp-transfer-handler"; -import alfredpayOnrampMintHandler from "./handlers/alfredpay-onramp-mint-handler"; -import brlaOnrampMintHandler from "./handlers/brla-onramp-mint-handler"; -import brlaPayoutBaseHandler from "./handlers/brla-payout-base-handler"; -import destinationTransferHandler from "./handlers/destination-transfer-handler"; -import distributeFeesHandler from "./handlers/distribute-fees-handler"; -import finalSettlementSubsidy from "./handlers/final-settlement-subsidy"; -import fundEphemeralHandler from "./handlers/fund-ephemeral-handler"; -import hydrationSwapHandler from "./handlers/hydration-swap-handler"; -import hydrationToAssethubXcmPhaseHandler from "./handlers/hydration-to-assethub-xcm-phase-handler"; -import initialPhaseHandler from "./handlers/initial-phase-handler"; -import moonbeamToPendulumPhaseHandler from "./handlers/moonbeam-to-pendulum-handler"; -import moonbeamToPendulumXcmHandler from "./handlers/moonbeam-to-pendulum-xcm-handler"; -import mykoboOnrampDepositHandler from "./handlers/mykobo-onramp-deposit-handler"; -import mykoboPayoutHandler from "./handlers/mykobo-payout-handler"; -import nablaApproveHandler from "./handlers/nabla-approve-handler"; -import nablaSwapHandler from "./handlers/nabla-swap-handler"; -import pendulumToAssethubPhaseHandler from "./handlers/pendulum-to-assethub-phase-handler"; -import pendulumToHydrationXcmPhaseHandler from "./handlers/pendulum-to-hydration-xcm-phase-handler"; -import pendulumToMoonbeamXcmHandler from "./handlers/pendulum-to-moonbeam-xcm-handler"; -import squidRouterPayPhaseHandler from "./handlers/squid-router-pay-phase-handler"; -import squidRouterPhaseHandler from "./handlers/squid-router-phase-handler"; -import squidRouterPermitExecutionHandler from "./handlers/squidrouter-permit-execution-handler"; -import subsidizePostSwapPhaseHandler from "./handlers/subsidize-post-swap-handler"; -import subsidizePreSwapPhaseHandler from "./handlers/subsidize-pre-swap-handler"; -import phaseRegistry from "./phase-registry"; - -/** - * Register all phase handlers - */ -export function registerPhaseHandlers(): void { - logger.info("Registering phase handlers"); - - // Register handlers - phaseRegistry.registerHandler(initialPhaseHandler); - phaseRegistry.registerHandler(squidRouterPhaseHandler); - phaseRegistry.registerHandler(nablaApproveHandler); - phaseRegistry.registerHandler(nablaSwapHandler); - phaseRegistry.registerHandler(subsidizePostSwapPhaseHandler); - phaseRegistry.registerHandler(subsidizePreSwapPhaseHandler); - phaseRegistry.registerHandler(moonbeamToPendulumPhaseHandler); - phaseRegistry.registerHandler(brlaPayoutBaseHandler); - phaseRegistry.registerHandler(mykoboPayoutHandler); - phaseRegistry.registerHandler(mykoboOnrampDepositHandler); - phaseRegistry.registerHandler(fundEphemeralHandler); - phaseRegistry.registerHandler(alfredpayOnrampMintHandler); - phaseRegistry.registerHandler(alfredpayOfframpTransferHandler); - phaseRegistry.registerHandler(brlaOnrampMintHandler); - phaseRegistry.registerHandler(pendulumToAssethubPhaseHandler); - phaseRegistry.registerHandler(squidRouterPayPhaseHandler); - phaseRegistry.registerHandler(distributeFeesHandler); - phaseRegistry.registerHandler(moonbeamToPendulumXcmHandler); - phaseRegistry.registerHandler(pendulumToMoonbeamXcmHandler); - phaseRegistry.registerHandler(pendulumToHydrationXcmPhaseHandler); - phaseRegistry.registerHandler(hydrationToAssethubXcmPhaseHandler); - phaseRegistry.registerHandler(hydrationSwapHandler); - phaseRegistry.registerHandler(finalSettlementSubsidy); - phaseRegistry.registerHandler(destinationTransferHandler); - phaseRegistry.registerHandler(squidRouterPermitExecutionHandler); - - logger.info("Phase handlers registered"); -} - -export default registerPhaseHandlers; diff --git a/apps/api/src/api/services/priceFeed.schemas.ts b/apps/api/src/api/services/priceFeed.schemas.ts index 68fbd56db..8eeda9223 100644 --- a/apps/api/src/api/services/priceFeed.schemas.ts +++ b/apps/api/src/api/services/priceFeed.schemas.ts @@ -2,7 +2,7 @@ import { z } from "zod"; /** * External API contract schema for the CoinGecko price feed consumed by - * PriceFeedService (see docs/features/contract-tests.md). + * PriceFeedService (see docs/operations-testing.md). * * GET /simple/price returns `{ [tokenId]: { [vsCurrency]: number } }`; * getCryptoPrice reads exactly `data[tokenId][vsCurrency]` and treats a missing diff --git a/apps/api/src/api/services/quote/core/partner-resolution.ts b/apps/api/src/api/services/quote/core/partner-resolution.ts index b2d0a025d..3c8508b49 100644 --- a/apps/api/src/api/services/quote/core/partner-resolution.ts +++ b/apps/api/src/api/services/quote/core/partner-resolution.ts @@ -3,7 +3,7 @@ import { Op } from "sequelize"; import logger from "../../../../config/logger"; import ProfilePartnerAssignment from "../../../../models/profilePartnerAssignment.model"; import { findPartnerWithPricing, PartnerWithPricing } from "../../partners/partner-pricing.service"; -import { getTargetFiatCurrency } from "./helpers"; +import { getTargetFiatCurrency } from "../../phases/blocks/core/helpers"; import type { PartnerPricingSource } from "./types"; type QuotePartnerResolutionRequest = CreateQuoteRequest & { diff --git a/apps/api/src/api/services/quote/core/quote-context.ts b/apps/api/src/api/services/quote/core/quote-context.ts index 3e7097a4b..4ddd6e466 100644 --- a/apps/api/src/api/services/quote/core/quote-context.ts +++ b/apps/api/src/api/services/quote/core/quote-context.ts @@ -11,7 +11,7 @@ import { CreateQuoteRequest, RampCurrency, RampDirection } from "@vortexfi/share import type { QuoteContext as IQuoteContext, PartnerInfo, PartnerPricingSource } from "./types"; export function createQuoteContext(args: { - request: CreateQuoteRequest & { partnerName?: string | null; userId?: string }; + request: CreateQuoteRequest & { apiCredentialId?: string; partnerName?: string | null; userId?: string }; targetFeeFiatCurrency: RampCurrency; partner: PartnerInfo | null; partnerOwnerId?: string | null; diff --git a/apps/api/src/api/services/quote/core/quote-orchestrator.ts b/apps/api/src/api/services/quote/core/quote-orchestrator.ts deleted file mode 100644 index 5ea9bc539..000000000 --- a/apps/api/src/api/services/quote/core/quote-orchestrator.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { EnginesRegistry, IRouteStrategy, QuoteContext } from "./types"; - -// Coordinates execution of stages based on a resolved route strategy. -export class QuoteOrchestrator { - constructor(private readonly engines?: EnginesRegistry) {} - - async run(strategy: IRouteStrategy, ctx: QuoteContext): Promise { - const stages = strategy.getStages(ctx); - const engines = this.engines ? this.engines : strategy.getEngines(ctx); - - for (const stageKey of stages) { - const engine = engines[stageKey]; - if (!engine) { - throw new Error(`Engine for stage '${stageKey}' not registered in registry (strategy='${strategy.name}')`); - } - ctx.addNote?.(`Executing stage: ${stageKey}`); - await engine.execute(ctx); - } - - return ctx; - } -} diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index b57bca277..451721d46 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -1,6 +1,3 @@ -// Strategy + Pipeline architecture -// Shared types and contracts used by the quote pipeline. - import { AmountLimits, CreateQuoteRequest, @@ -15,31 +12,6 @@ import { } from "@vortexfi/shared"; import { Big } from "big.js"; -// Stage identifiers in the pipeline -export enum StageKey { - Initialize = "Initialize", - NablaSwap = "NablaSwap", - MergeSubsidy = "MergeSubsidy", - PendulumTransfer = "PendulumTransfer", - HydrationSwap = "HydrationSwap", - SquidRouter = "SquidRouter", - Fee = "Fee", - Discount = "Discount", - PartnerOperation = "PartnerOperation", - Finalize = "Finalize" -} - -// Minimal stage contract -export interface Stage { - readonly key: StageKey; - execute(ctx: QuoteContext): Promise; -} - -// Engines registry for orchestrator lookup -export type EnginesRegistry = { - [K in StageKey]?: Stage; -}; - export interface BridgeMeta { effectiveExchangeRate?: string; fromNetwork: string; @@ -75,22 +47,11 @@ export interface PartnerInfo { export type PartnerPricingSource = "request" | "publicKey" | "profileAssignment" | "none"; -// Strategy for a specific route/path -export interface IRouteStrategy { - // Optional: human-friendly name for logging - readonly name: string; - - // Ordered stages to execute for this route - getStages(ctx: QuoteContext): StageKey[]; - - getEngines(ctx: QuoteContext): EnginesRegistry; -} - // Quote context flows through all stages. Defined in quote-context.ts. // Re-export here for convenience to avoid deep imports. export interface QuoteContext { // immutable request details - readonly request: CreateQuoteRequest & { partnerName?: string | null; userId?: string }; + readonly request: CreateQuoteRequest & { apiCredentialId?: string; partnerName?: string | null; userId?: string }; readonly now: Date; // Partner info (if any) 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 deleted file mode 100644 index df309dfdd..000000000 --- a/apps/api/src/api/services/quote/engines/alfredpay-auth.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -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/index.ts b/apps/api/src/api/services/quote/engines/discount/index.ts deleted file mode 100644 index f32c1b896..000000000 --- a/apps/api/src/api/services/quote/engines/discount/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { QuoteContext, Stage, StageKey } from "../../core/types"; -import { ActivePartner, buildDiscountSubsidy, formatPartnerNote } from "./helpers"; - -export interface DiscountStageConfig { - direction: RampDirection; - skipNote: string; - isOfframp: boolean; -} - -export interface DiscountComputation { - expectedOutputAmountDecimal: Big; - expectedOutputAmountRaw: string; - actualOutputAmountDecimal: Big; - actualOutputAmountRaw: string; - targetOutputAmountDecimal: Big; - targetOutputAmountRaw: string; - idealSubsidyAmountInOutputTokenDecimal: Big; - idealSubsidyAmountInOutputTokenRaw: string; - subsidyAmountInOutputTokenDecimal: Big; - subsidyAmountInOutputTokenRaw: string; - partnerId: string | null; - subsidyRate: Big; - adjustedDifference: Big; - adjustedTargetDiscount: Big; -} - -export abstract class BaseDiscountEngine implements Stage { - abstract readonly config: DiscountStageConfig; - - readonly key = StageKey.Discount; - - protected abstract compute(ctx: QuoteContext, partner?: ActivePartner): Promise; - - protected abstract validate(ctx: QuoteContext): void; - - async execute(ctx: QuoteContext): Promise { - const { request } = ctx; - const { direction, skipNote } = this.config; - - if (request.rampType !== direction) { - ctx.addNote?.(skipNote); - return; - } - - this.validate(ctx); - - const computation = await this.compute(ctx); - - ctx.subsidy = buildDiscountSubsidy(computation); - - ctx.addNote?.(formatPartnerNote(ctx, computation)); - } -} diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts deleted file mode 100644 index ca57d2fca..000000000 --- a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - ALFREDPAY_ERC20_DECIMALS, - ALFREDPAY_ONCHAIN_CURRENCY, - multiplyByPowerOfTen, - RampCurrency, - RampDirection -} from "@vortexfi/shared"; -import Big from "big.js"; -import { priceFeedService } from "../../../priceFeed.service"; -import { QuoteContext } from "../../core/types"; -import { BaseDiscountEngine, DiscountComputation } from "."; -import { - calculateExpectedOutput, - calculateSubsidyAmount, - getUsdDenominatedInputAmount, - resolveDiscountPartner -} from "./helpers"; - -export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { - readonly config = { - direction: RampDirection.SELL, - isOfframp: true, - skipNote: "Skipped for on-ramp request" - } as const; - - protected validate(ctx: QuoteContext): void { - if (!ctx.evmToEvm) { - throw new Error("OffRampAlfredpayDiscountEngine requires evmToEvm to be defined"); - } - - if (!ctx.request.inputAmount) { - throw new Error("OffRampAlfredpayDiscountEngine requires request.inputAmount to be defined"); - } - } - - protected async compute(ctx: QuoteContext): Promise { - const { inputAmount, outputCurrency, rampType } = ctx.request; - - const partner = await resolveDiscountPartner(ctx, rampType); - const targetDiscount = partner?.targetDiscount ?? 0; - const maxSubsidy = partner?.maxSubsidy ?? 0; - - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const usdOnPolygon = ctx.evmToEvm!.outputAmountDecimal; - - // Oracle rate FIAT -> USD (e.g., 1 ARS = 0.0002657 USD). - // This block is required to avoid calling the Alfredpay API twice for a quote. - // Since setting the input amount for the Alfredpay operations comes after this, and uses the output of the - // discounted rate, we need to know or estimate the rate in advance. - const effectiveRateStr = await priceFeedService.convertCurrency( - "1", - outputCurrency as RampCurrency, - ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency - ); - const effectiveRate = new Big(effectiveRateStr); - - if (!effectiveRate.gt(0)) { - throw new Error( - `OffRampAlfredpayDiscountEngine: oracle returned non-positive rate (${effectiveRateStr}) for ${outputCurrency} -> ${ALFREDPAY_ONCHAIN_CURRENCY}` - ); - } - - // finalOutput uses the inverted rate (USD -> FIAT) for display/logging - const usdToFiatRate = new Big(1).div(effectiveRate); - const finalOutput = usdOnPolygon.mul(usdToFiatRate); - - // The inverted rate converts USD -> fiat, so a non-USD input (e.g. BRLA) must be valued in USD first. - const inputAmountUsd = await getUsdDenominatedInputAmount(ctx); - if (!inputAmountUsd.eq(inputAmount)) { - ctx.addNote?.( - `OffRampAlfredpayDiscountEngine: valued input ${inputAmount} ${ctx.request.inputCurrency} at ${inputAmountUsd.toFixed(6)} USD for discount calculation` - ); - } - - const { - expectedOutput: expectedOutputDecimal, - adjustedDifference, - adjustedTargetDiscount - } = calculateExpectedOutput(inputAmountUsd.toString(), effectiveRate, targetDiscount, this.config.isOfframp, partner); - - const idealSubsidyDecimal = expectedOutputDecimal.gt(finalOutput) ? expectedOutputDecimal.minus(finalOutput) : new Big(0); - - const actualSubsidyDecimal = - targetDiscount !== 0 ? calculateSubsidyAmount(expectedOutputDecimal, finalOutput, maxSubsidy) : new Big(0); - - const targetOutputDecimal = finalOutput.plus(actualSubsidyDecimal); - - const subsidyRate = expectedOutputDecimal.gt(0) ? actualSubsidyDecimal.div(expectedOutputDecimal) : new Big(0); - - return { - actualOutputAmountDecimal: finalOutput, - actualOutputAmountRaw: multiplyByPowerOfTen(finalOutput, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), - adjustedDifference, - adjustedTargetDiscount, - expectedOutputAmountDecimal: expectedOutputDecimal, - expectedOutputAmountRaw: multiplyByPowerOfTen(expectedOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), - idealSubsidyAmountInOutputTokenDecimal: idealSubsidyDecimal, - idealSubsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(idealSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), - partnerId: partner ? partner.id : null, - subsidyAmountInOutputTokenDecimal: actualSubsidyDecimal, - subsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(actualSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), - subsidyRate, - targetOutputAmountDecimal: targetOutputDecimal, - targetOutputAmountRaw: multiplyByPowerOfTen(targetOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0) - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/discount/offramp.ts b/apps/api/src/api/services/quote/engines/discount/offramp.ts deleted file mode 100644 index 2d4ccaa40..000000000 --- a/apps/api/src/api/services/quote/engines/discount/offramp.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../../config/logger"; -import { QuoteContext } from "../../core/types"; -import { BaseDiscountEngine, DiscountComputation } from "."; -import { - calculateExpectedOutput, - calculateSubsidyAmount, - getUsdDenominatedInputAmount, - resolveDiscountPartner -} from "./helpers"; - -export class OffRampDiscountEngine extends BaseDiscountEngine { - readonly config = { - direction: RampDirection.SELL, - isOfframp: true, - skipNote: "Skipped for on-ramp request" - } as const; - - protected validate(ctx: QuoteContext): void { - if (!ctx.nablaSwap && !ctx.nablaSwapEvm) { - throw new Error("OffRampDiscountEngine requires nablaSwap or nablaSwapEvm to be defined"); - } - - if (!ctx.nablaSwap?.oraclePrice && !ctx.nablaSwapEvm?.oraclePrice) { - throw new Error("OffRampDiscountEngine requires nablaSwap.oraclePrice or nablaSwapEvm.oraclePrice to be defined"); - } - - if (!ctx.request.inputAmount) { - throw new Error("OffRampDiscountEngine requires request.inputAmount to be defined"); - } - } - - protected async compute(ctx: QuoteContext): Promise { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const nablaSwap = ctx.nablaSwap! || ctx.nablaSwapEvm!; - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const oraclePrice = nablaSwap.oraclePrice!; - - const { inputAmount, inputCurrency, rampType } = ctx.request; - - const partner = await resolveDiscountPartner(ctx, rampType); - const targetDiscount = partner?.targetDiscount ?? 0; - const maxSubsidy = partner?.maxSubsidy ?? 0; - - // The expected-output math multiplies a USD amount by the inverted FIAT-USD oracle rate, - // so a non-USD input (e.g. BRLA) must be valued in USD first. - const inputAmountUsd = await getUsdDenominatedInputAmount(ctx); - if (!inputAmountUsd.eq(inputAmount)) { - ctx.addNote?.( - `OffRampDiscountEngine: valued input ${inputAmount} ${inputCurrency} at ${inputAmountUsd.toFixed(6)} USD for discount calculation` - ); - } - - // Calculate the oracle-based expected output in the target fiat currency. - const { - expectedOutput: oracleExpectedOutputDecimal, - adjustedDifference, - adjustedTargetDiscount - } = calculateExpectedOutput(inputAmountUsd.toString(), oraclePrice, targetDiscount, this.config.isOfframp, partner); - - // Account for the anchor fee deducted in the Finalize stage, which reduces the user's received amount. - // We need to add it back to the expected output to calculate the subsidy correctly. - const anchorFeeInBrl = ctx.fees?.displayFiat?.anchor ? new Big(ctx.fees.displayFiat.anchor) : new Big(0); - const adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.plus(anchorFeeInBrl); - - if (anchorFeeInBrl.gt(0)) { - logger.info( - `OffRampDiscountEngine: Adjusted expected BRL from ${oracleExpectedOutputDecimal.toFixed(6)} ` + - `to ${adjustedExpectedOutputDecimal.toFixed(6)} (anchor fee: ${anchorFeeInBrl.toFixed(6)} BRL)` - ); - ctx.addNote?.( - `OffRampDiscountEngine: Adjusted expected BRL output from ${oracleExpectedOutputDecimal.toFixed(4)} ` + - `to ${adjustedExpectedOutputDecimal.toFixed(4)} BRL to account for anchor fee of ${anchorFeeInBrl.toFixed(4)} BRL` - ); - } - - const expectedOutputAmountRaw = multiplyByPowerOfTen(adjustedExpectedOutputDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - const actualOutputAmountDecimal = nablaSwap.outputAmountDecimal; - const actualOutputAmountRaw = multiplyByPowerOfTen(actualOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - // Calculate ideal subsidy (uncapped - the full shortfall needed to reach adjusted expected output) - const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(adjustedExpectedOutputDecimal) - ? new Big(0) - : adjustedExpectedOutputDecimal.minus(actualOutputAmountDecimal); - const idealSubsidyAmountRaw = multiplyByPowerOfTen(idealSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - // Calculate actual subsidy (capped by maxSubsidy) - const actualSubsidyAmountDecimal = - targetDiscount !== 0 - ? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy) - : Big(0); - const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(actualSubsidyAmountDecimal); - const targetOutputAmountRaw = Big(actualOutputAmountRaw).plus(actualSubsidyAmountRaw).toFixed(0, 0); - - const subsidyRate = adjustedExpectedOutputDecimal.gt(0) - ? actualSubsidyAmountDecimal.div(adjustedExpectedOutputDecimal) - : new Big(0); - - return { - actualOutputAmountDecimal, - actualOutputAmountRaw, - adjustedDifference, - adjustedTargetDiscount, - expectedOutputAmountDecimal: adjustedExpectedOutputDecimal, - expectedOutputAmountRaw, - idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal, - idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw, - partnerId: partner ? partner.id : null, - subsidyAmountInOutputTokenDecimal: actualSubsidyAmountDecimal, - subsidyAmountInOutputTokenRaw: actualSubsidyAmountRaw, - subsidyRate, - targetOutputAmountDecimal, - targetOutputAmountRaw - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts deleted file mode 100644 index d6c71bacc..000000000 --- a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { ALFREDPAY_ERC20_DECIMALS, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { QuoteContext } from "../../core/types"; -import { BaseDiscountEngine, DiscountComputation } from "."; -import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; - -export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { - readonly config = { - direction: RampDirection.BUY, - isOfframp: false, - skipNote: "Skipped for off-ramp request" - } as const; - - protected validate(ctx: QuoteContext): void { - if (!ctx.alfredpayMint) { - throw new Error("OnRampAlfredpayDiscountEngine requires alfredpayMint to be defined"); - } - - if (!ctx.request.inputAmount) { - throw new Error("OnRampAlfredpayDiscountEngine requires request.inputAmount to be defined"); - } - - if (!ctx.fees?.usd) { - throw new Error("OnRampAlfredpayDiscountEngine requires fees.usd to be defined"); - } - } - - protected async compute(ctx: QuoteContext): Promise { - const { inputAmount, rampType } = ctx.request; - - const partner = await resolveDiscountPartner(ctx, rampType); - const targetDiscount = partner?.targetDiscount ?? 0; - const maxSubsidy = partner?.maxSubsidy ?? 0; - - const alfredpayMint = ctx.alfredpayMint; - if (!alfredpayMint) { - throw new Error("OnRampAlfredpayDiscountEngine requires alfredpayMint to be defined"); - } - - const effectiveRate = alfredpayMint.outputAmountDecimal.div(alfredpayMint.inputAmountDecimal); - - const usdFees = ctx.fees?.usd; - if (!usdFees) { - throw new Error("OnRampAlfredpayDiscountEngine requires fees.usd to be defined"); - } - const feesToDeduct = new Big(usdFees.vortex).plus(usdFees.partnerMarkup); - - const finalOutput = ctx.evmToEvm?.outputAmountDecimal ?? alfredpayMint.outputAmountDecimal.minus(feesToDeduct); - - const { - expectedOutput: expectedOutputDecimal, - adjustedDifference, - adjustedTargetDiscount - } = calculateExpectedOutput(inputAmount, effectiveRate, targetDiscount, this.config.isOfframp, partner); - - const idealSubsidyDecimal = expectedOutputDecimal.gt(finalOutput) ? expectedOutputDecimal.minus(finalOutput) : new Big(0); - - const actualSubsidyDecimal = - targetDiscount !== 0 ? calculateSubsidyAmount(expectedOutputDecimal, finalOutput, maxSubsidy) : new Big(0); - - const targetOutputDecimal = finalOutput.plus(actualSubsidyDecimal); - - const subsidyRate = expectedOutputDecimal.gt(0) ? actualSubsidyDecimal.div(expectedOutputDecimal) : new Big(0); - - return { - actualOutputAmountDecimal: finalOutput, - actualOutputAmountRaw: multiplyByPowerOfTen(finalOutput, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), - adjustedDifference, - adjustedTargetDiscount, - expectedOutputAmountDecimal: expectedOutputDecimal, - expectedOutputAmountRaw: multiplyByPowerOfTen(expectedOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), - idealSubsidyAmountInOutputTokenDecimal: idealSubsidyDecimal, - idealSubsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(idealSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), - partnerId: partner ? partner.id : null, - subsidyAmountInOutputTokenDecimal: actualSubsidyDecimal, - subsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(actualSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), - subsidyRate, - targetOutputAmountDecimal: targetOutputDecimal, - targetOutputAmountRaw: multiplyByPowerOfTen(targetOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0) - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/discount/onramp.ts b/apps/api/src/api/services/quote/engines/discount/onramp.ts deleted file mode 100644 index 1b7e0f66c..000000000 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { - EvmToken, - getNetworkFromDestination, - multiplyByPowerOfTen, - Networks, - OnChainToken, - RampDirection -} from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../../config/logger"; -import { getEvmBridgeQuote } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../utils"; -import { BaseDiscountEngine, DiscountComputation } from "."; -import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; - -export class OnRampDiscountEngine extends BaseDiscountEngine { - readonly config = { - direction: RampDirection.BUY, - isOfframp: false, - skipNote: "Skipped for off-ramp request" - } as const; - - protected validate(ctx: QuoteContext): void { - // Handle both Base USDC flows and Moonbeam axlUSDC flows - if (!ctx.nablaSwap && !ctx.nablaSwapEvm) { - throw new Error("OnRampDiscountEngine requires either nablaSwap or nablaSwapEvm to be defined"); - } - - // Direct fiat->own-stablecoin passthrough (EUR->EURC, BRL->BRLA on Base) is 1:1: the nabla - // engine intentionally skips the oracle, so don't require oraclePrice here. - if (isFiatToOwnStablecoinDirect(ctx)) { - return; - } - - const nablaSwap = ctx.nablaSwap || ctx.nablaSwapEvm; - if (!nablaSwap?.oraclePrice) { - throw new Error("OnRampDiscountEngine requires nablaSwap.oraclePrice to be defined"); - } - - if (!ctx.request.inputAmount) { - throw new Error("OnRampDiscountEngine requires request.inputAmount to be defined"); - } - - if (!ctx.fees?.usd) { - throw new Error("OnRampDiscountEngine requires fees.usd to be defined"); - } - } - - /** - * Queries squidrouter to determine the actual conversion rate from axlUSDC on Moonbeam - * to the final destination token on the target EVM chain. - * - * The oracle price is the fastforex USD mid-market fiat rate, but the Nabla swap on Pendulum - * outputs axlUSDC (not USD). Since axlUSDC may trade at a discount to USD via - * squidrouter, using the oracle USD rate as the axlUSDC subsidy target means the user - * would receive slightly less than the oracle-promised amount after the squidrouter step. - * - * This method fetches the actual axlUSDC → destination token rate so the discount engine - * can back-calculate the precise axlUSDC amount required on Pendulum. - * - * @param ctx - The quote context (must have request.outputCurrency and request.to set) - * @param expectedAxlUSDCDecimal - The oracle-based expected axlUSDC amount used as probe input - * @returns The conversion rate (destination token units per axlUSDC) or null on failure - */ - private async getSquidRouterAxlUSDCConversionRate(ctx: QuoteContext, expectedAxlUSDCDecimal: Big): Promise { - const req = ctx.request; - const toNetwork = getNetworkFromDestination(req.to); - - if (!toNetwork) { - return null; - } - - try { - const bridgeQuote = await getEvmBridgeQuote({ - amountDecimal: expectedAxlUSDCDecimal.toString(), - fromNetwork: Networks.Moonbeam, - inputCurrency: EvmToken.AXLUSDC as unknown as OnChainToken, - outputCurrency: req.outputCurrency as OnChainToken, - rampType: req.rampType, - toNetwork - }); - - if (expectedAxlUSDCDecimal.lte(0) || bridgeQuote.outputAmountDecimal.lte(0)) { - return null; - } - - const conversionRate = bridgeQuote.outputAmountDecimal.div(expectedAxlUSDCDecimal); - logger.info( - `OnRampDiscountEngine: SquidRouter axlUSDC→${req.outputCurrency} rate: ${conversionRate.toFixed(6)} ` + - `(input: ${expectedAxlUSDCDecimal.toFixed(6)} axlUSDC, output: ${bridgeQuote.outputAmountDecimal.toFixed(6)} ${req.outputCurrency})` - ); - return conversionRate; - } catch (error) { - logger.warn( - `OnRampDiscountEngine: Could not fetch SquidRouter axlUSDC→${req.outputCurrency} conversion rate, ` + - `falling back to 1:1 assumption. Error: ${error}` - ); - return null; - } - } - - /** - * Queries squidrouter to determine the actual conversion rate from USDC on Base - * to the final destination token on the target EVM chain. - * - * The oracle price is the fastforex USD mid-market fiat rate, but the Nabla swap on Base - * outputs USDC (not USD). Since USDC may trade at a discount to USD via - * squidrouter, using the oracle USD rate as the USDC subsidy target means the user - * may receive slightly less than the oracle-promised amount after the squidrouter step. - * - * This method fetches the actual USDC → destination token rate so the discount engine - * can back-calculate the precise USDC amount required on Base. - * - * @param ctx - The quote context (must have request.outputCurrency and request.to set) - * @param expectedUSDCDecimal - The oracle-based expected USDC amount used as probe input - * @returns The conversion rate (destination token units per USDC) or null on failure - */ - private async getSquidRouterUSDCConversionRate(ctx: QuoteContext, expectedUSDCDecimal: Big): Promise { - const req = ctx.request; - const toNetwork = getNetworkFromDestination(req.to); - - if (!toNetwork) { - return null; - } - - // Trivial case: USDC on Base is also the requested output. No bridge runs, so the - // conversion rate is exactly 1:1 - skip the Squid call (which would fail with same-chain - // same-token) and avoid the misleading 1:1 fallback log. - if (toNetwork === Networks.Base && req.outputCurrency === EvmToken.USDC) { - return new Big(1); - } - - try { - const bridgeQuote = await getEvmBridgeQuote({ - amountDecimal: expectedUSDCDecimal.toString(), - fromNetwork: Networks.Base, - inputCurrency: EvmToken.USDC as unknown as OnChainToken, - outputCurrency: req.outputCurrency as OnChainToken, - rampType: req.rampType, - toNetwork - }); - - if (expectedUSDCDecimal.lte(0) || bridgeQuote.outputAmountDecimal.lte(0)) { - return null; - } - - const conversionRate = bridgeQuote.outputAmountDecimal.div(expectedUSDCDecimal); - logger.info( - `OnRampDiscountEngine: SquidRouter USDC→${req.outputCurrency} rate: ${conversionRate.toFixed(6)} ` + - `(input: ${expectedUSDCDecimal.toFixed(6)} USDC, output: ${bridgeQuote.outputAmountDecimal.toFixed(6)} ${req.outputCurrency})` - ); - return conversionRate; - } catch (error) { - logger.warn( - `OnRampDiscountEngine: Could not fetch SquidRouter USDC→${req.outputCurrency} conversion rate, ` + - `falling back to 1:1 assumption. Error: ${error}` - ); - return null; - } - } - - protected async compute(ctx: QuoteContext): Promise { - if (isFiatToOwnStablecoinDirect(ctx)) { - return buildPassthroughDiscountComputation(ctx); - } - - // Determine which nabla swap we're using (Base EVM or Pendulum) - const isBaseFlow = !!ctx.nablaSwapEvm; - const nablaSwap = ctx.nablaSwapEvm ?? ctx.nablaSwap; - if (!nablaSwap) { - throw new Error("OnRampDiscountEngine requires nablaSwap or nablaSwapEvm in context"); - } - const oraclePrice = nablaSwap.oraclePrice; - if (!oraclePrice) { - throw new Error("OnRampDiscountEngine requires oraclePrice in swap metadata"); - } - const usdFees = ctx.fees?.usd; - if (!usdFees) { - throw new Error("OnRampDiscountEngine requires fees.usd in context"); - } - - const { inputAmount, rampType } = ctx.request; - - const partner = await resolveDiscountPartner(ctx, rampType); - const targetDiscount = partner?.targetDiscount ?? 0; - const maxSubsidy = partner?.maxSubsidy ?? 0; - - // Calculate the oracle-based expected output - const { - expectedOutput: oracleExpectedOutputDecimal, - adjustedDifference, - adjustedTargetDiscount - } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - - // For onramps to EVM chains (not AssetHub), adjust for the actual bridge conversion rate - let adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal; - if (ctx.request.to !== "assethub") { - const squidRouterRate = isBaseFlow - ? await this.getSquidRouterUSDCConversionRate(ctx, oracleExpectedOutputDecimal) - : await this.getSquidRouterAxlUSDCConversionRate(ctx, oracleExpectedOutputDecimal); - - if (squidRouterRate !== null && squidRouterRate.gt(0)) { - adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.div(squidRouterRate); - const tokenName = isBaseFlow ? "USDC" : "axlUSDC"; - ctx.addNote?.( - `OnRampDiscountEngine: Adjusted expected ${tokenName} from ${oracleExpectedOutputDecimal.toFixed(6)} ` + - `to ${adjustedExpectedOutputDecimal.toFixed(6)} (squidRouter rate: ${squidRouterRate.toFixed(6)})` - ); - } - } - - const expectedOutputAmountRaw = multiplyByPowerOfTen(adjustedExpectedOutputDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - // For onramps, fees are deducted from the nabla output (not before the swap) - const deductedFeesAfterSwap = Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup); - const actualOutputAmountDecimal = nablaSwap.outputAmountDecimal.minus(deductedFeesAfterSwap); - const actualOutputAmountRaw = multiplyByPowerOfTen(actualOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - // Calculate ideal subsidy (uncapped - the full shortfall needed to reach adjusted expected output) - const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(adjustedExpectedOutputDecimal) - ? new Big(0) - : adjustedExpectedOutputDecimal.minus(actualOutputAmountDecimal); - const idealSubsidyAmountRaw = multiplyByPowerOfTen(idealSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - // Calculate actual subsidy (capped by maxSubsidy) - const actualSubsidyAmountDecimal = - targetDiscount !== 0 - ? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy) - : Big(0); - const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(actualSubsidyAmountDecimal); - const targetOutputAmountRaw = Big(actualOutputAmountRaw).plus(actualSubsidyAmountRaw).toFixed(0, 0); - - const subsidyRate = adjustedExpectedOutputDecimal.gt(0) - ? actualSubsidyAmountDecimal.div(adjustedExpectedOutputDecimal) - : new Big(0); - - return { - actualOutputAmountDecimal, - actualOutputAmountRaw, - adjustedDifference, - adjustedTargetDiscount, - expectedOutputAmountDecimal: adjustedExpectedOutputDecimal, - expectedOutputAmountRaw, - idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal, - idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw, - partnerId: partner ? partner.id : null, - subsidyAmountInOutputTokenDecimal: actualSubsidyAmountDecimal, - subsidyAmountInOutputTokenRaw: actualSubsidyAmountRaw, - subsidyRate, - targetOutputAmountDecimal, - targetOutputAmountRaw - }; - } -} - -function isFiatToOwnStablecoinDirect(ctx: QuoteContext): boolean { - const { inputCurrency, outputCurrency, to } = ctx.request; - return isEurToEurcBaseDirect(inputCurrency, outputCurrency, to) || isBrlToBrlaBaseDirect(inputCurrency, outputCurrency, to); -} - -function buildPassthroughDiscountComputation(ctx: QuoteContext): DiscountComputation { - // biome-ignore lint/style/noNonNullAssertion: validate() guarantees one nabla swap is set - const nablaSwap = ctx.nablaSwapEvm || ctx.nablaSwap!; - const outputAmountDecimal = nablaSwap.outputAmountDecimal; - const outputAmountRaw = multiplyByPowerOfTen(outputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - const zero = new Big(0); - - return { - actualOutputAmountDecimal: outputAmountDecimal, - actualOutputAmountRaw: outputAmountRaw, - adjustedDifference: zero, - adjustedTargetDiscount: zero, - expectedOutputAmountDecimal: outputAmountDecimal, - expectedOutputAmountRaw: outputAmountRaw, - idealSubsidyAmountInOutputTokenDecimal: zero, - idealSubsidyAmountInOutputTokenRaw: "0", - partnerId: null, - subsidyAmountInOutputTokenDecimal: zero, - subsidyAmountInOutputTokenRaw: "0", - subsidyRate: zero, - targetOutputAmountDecimal: outputAmountDecimal, - targetOutputAmountRaw: outputAmountRaw - }; -} diff --git a/apps/api/src/api/services/quote/engines/fee/index.ts b/apps/api/src/api/services/quote/engines/fee/index.ts deleted file mode 100644 index 600aad36a..000000000 --- a/apps/api/src/api/services/quote/engines/fee/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { EvmToken, RampCurrency, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { config } from "../../../../../config/vars"; -import { priceFeedService } from "../../../priceFeed.service"; -import { calculateFeeComponents } from "../../core/quote-fees"; -import { QuoteContext, Stage, StageKey } from "../../core/types"; - -export interface FeeComponentInput { - amount: string; - currency: RampCurrency; -} - -export interface FeeSummaryInput { - vortex: FeeComponentInput; - anchor: FeeComponentInput; - partnerMarkup: FeeComponentInput; - network?: FeeComponentInput; -} - -export interface FeeConfig { - direction: RampDirection; - skipNote: string; -} - -export interface FeeComputation { - anchor: FeeComponentInput; - network: FeeComponentInput; - // Optional fees that may not be applicable to all engines, but can be included in the summary if present - // Override the vortex and partner markup fees from the fee components - forcedVortexFee?: FeeComponentInput; - forcedPartnerMarkupFee?: FeeComponentInput; -} - -export abstract class BaseFeeEngine implements Stage { - abstract readonly config: FeeConfig; - - readonly key = StageKey.Fee; - - async execute(ctx: QuoteContext): Promise { - const { request } = ctx; - const { direction, skipNote } = this.config; - - if (request.rampType !== direction) { - ctx.addNote?.(skipNote); - return; - } - - this.validate(ctx); - - const { anchorFee, feeCurrency, partnerMarkupFee, vortexFee } = await calculateFeeComponents({ - from: request.from, - inputAmount: request.inputAmount, - inputCurrency: request.inputCurrency, - outputAmountOfframp: ctx.nablaSwap?.outputAmountDecimal?.toString() ?? "0", - outputCurrency: request.outputCurrency, - partnerId: ctx.partner?.id || undefined, - rampType: request.rampType, - to: request.to - }); - - const { anchor, network, forcedVortexFee, forcedPartnerMarkupFee } = await this.compute(ctx, anchorFee, feeCurrency); - - await assignFeeSummary(ctx, { - anchor, - network, - partnerMarkup: forcedPartnerMarkupFee ? forcedPartnerMarkupFee : { amount: partnerMarkupFee, currency: feeCurrency }, - vortex: forcedVortexFee ? forcedVortexFee : { amount: vortexFee, currency: feeCurrency } - }); - } - - protected abstract validate(ctx: QuoteContext): void; - - protected abstract compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise; -} - -/** - * Single source of truth for all fee representations on a quote. - * - * Produces both `fees.usd` (used for on-chain distribution) and `fees.displayFiat` - * (used for user-facing display) from the same source components in a single atomic - * operation. Both are persisted together inside `QuoteTicket.metadata.fees`. - * - * Do NOT assign `ctx.fees` outside this function. - */ -export async function assignFeeSummary(ctx: QuoteContext, components: FeeSummaryInput): Promise { - const USD_CURRENCY = EvmToken.USDC as RampCurrency; - const networkComponent = components.network ?? { amount: "0", currency: USD_CURRENCY }; - - const convert = (amount: string, from: RampCurrency, to: RampCurrency) => priceFeedService.convertCurrency(amount, from, to); - - const [vortexUsd, anchorUsd, partnerUsd, networkUsd, vortexDisplay, anchorDisplay, partnerDisplay, networkDisplay] = - await Promise.all([ - convert(components.vortex.amount, components.vortex.currency, USD_CURRENCY), - convert(components.anchor.amount, components.anchor.currency, USD_CURRENCY), - convert(components.partnerMarkup.amount, components.partnerMarkup.currency, USD_CURRENCY), - convert(networkComponent.amount, networkComponent.currency, USD_CURRENCY), - convert(components.vortex.amount, components.vortex.currency, ctx.targetFeeFiatCurrency), - convert(components.anchor.amount, components.anchor.currency, ctx.targetFeeFiatCurrency), - convert(components.partnerMarkup.amount, components.partnerMarkup.currency, ctx.targetFeeFiatCurrency), - convert(networkComponent.amount, networkComponent.currency, ctx.targetFeeFiatCurrency) - ]); - - const totalUsd = new Big(vortexUsd).plus(anchorUsd).plus(partnerUsd).plus(networkUsd).toFixed(6); - const totalDisplay = new Big(vortexDisplay).plus(anchorDisplay).plus(partnerDisplay).plus(networkDisplay).toFixed(2); - - const vortexFeePenPercentage = config.vortexFeePenPercentage ?? 0; - - ctx.fees = { - displayFiat: { - anchor: anchorDisplay, - currency: ctx.targetFeeFiatCurrency, - network: networkDisplay, - partnerMarkup: partnerDisplay, - total: totalDisplay, - vortex: vortexDisplay - }, - usd: { - anchor: anchorUsd, - network: networkUsd, - partnerMarkup: partnerUsd, - total: totalUsd, - vortex: vortexUsd - }, - vortexFeePenPercentage - }; - - const note = `Fees: usd[vortex=${ctx.fees.usd?.vortex ?? "0"}, anchor=${ctx.fees.usd?.anchor ?? "0"}, partner=${ctx.fees.usd?.partnerMarkup ?? "0"}, network=${ctx.fees.usd?.network ?? "0"}] display=${ctx.targetFeeFiatCurrency}`; - ctx.addNote?.(note); -} diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts b/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts deleted file mode 100644 index 2f17fa459..000000000 --- a/apps/api/src/api/services/quote/engines/fee/offramp-avenia.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { BrlaApiService, EvmToken, FiatToken, RampCurrency, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { QuoteContext } from "../../core/types"; -import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; - -export class OffRampFeeAveniaEngine extends BaseFeeEngine { - readonly config: FeeConfig = { - direction: RampDirection.SELL, - skipNote: "Skipped for on-ramp request" - }; - - protected validate(ctx: QuoteContext): void { - if (!ctx.nablaSwap && !ctx.nablaSwapEvm) { - throw new Error("OffRampFeeAveniaEngine requires nablaSwap or nablaSwapEvm in context"); - } - } - - protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - const swap = ctx.nablaSwap ?? ctx.nablaSwapEvm; - if (!swap) { - throw new Error("OffRampFeeAveniaEngine requires nablaSwap or nablaSwapEvm in context"); - } - const outputAmountOfframp = swap.outputAmountDecimal.toFixed(2, 0); - - const brlaApiService = BrlaApiService.getInstance(); - const aveniaQuote = await brlaApiService.createPayOutQuote( - { - outputAmount: outputAmountOfframp, - outputThirdParty: false - }, - { useCache: true } - ); - - const computedAnchorFee = new Big(aveniaQuote.inputAmount).minus(aveniaQuote.outputAmount).toString(); - const anchorFeeCurrency = FiatToken.BRL as RampCurrency; - - return { - anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, - network: { amount: "0", currency: EvmToken.USDC as RampCurrency } - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts b/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts deleted file mode 100644 index 271343fe1..000000000 --- a/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ALFREDPAY_EVM_TOKEN, RampCurrency, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; - -export class OffRampEvmToAlfredpayFeeEngine extends BaseFeeEngine { - readonly config: FeeConfig = { - direction: RampDirection.SELL, - skipNote: "Skipped for off-ramp request" - }; - - protected validate(ctx: QuoteContext): void { - if (!ctx.alfredpayOfframp) { - throw new Error("OffRampEvmToAlfredpayFeeEngine requires alfredpayOfframp in context"); - } - } - - protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const alfredpayFee = ctx.alfredpayOfframp!.fee.toString(); - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const alfredpayFeeCurrency = ctx.alfredpayOfframp!.currency as RampCurrency; - - return { - anchor: { amount: alfredpayFee, currency: alfredpayFeeCurrency }, - network: { amount: "0", currency: ALFREDPAY_EVM_TOKEN as RampCurrency } - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts b/apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts deleted file mode 100644 index ee4e5405a..000000000 --- a/apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { EvmToken, FiatToken, RampCurrency, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { resolveMykoboWithdrawFee } from "../mykobo-fee"; -import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; - -export class OffRampFeeMykoboEngine extends BaseFeeEngine { - readonly config: FeeConfig = { - direction: RampDirection.SELL, - skipNote: "Skipped for on-ramp request" - }; - - protected validate(ctx: QuoteContext): void { - if (!ctx.nablaSwapEvm) { - throw new Error("OffRampFeeMykoboEngine requires nablaSwapEvm in context"); - } - } - - protected async compute(ctx: QuoteContext, _anchorFee: string, _feeCurrency: RampCurrency): Promise { - // biome-ignore lint/style/noNonNullAssertion: validated above - const swapOutputEurc = ctx.nablaSwapEvm!.outputAmountDecimal.toFixed(2, 0); - - const mykoboFeeTotal = await resolveMykoboWithdrawFee(swapOutputEurc); - const anchorFeeCurrency = FiatToken.EURC as RampCurrency; - - return { - anchor: { amount: mykoboFeeTotal, currency: anchorFeeCurrency }, - network: { amount: "0", currency: EvmToken.USDC as RampCurrency } - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts deleted file mode 100644 index 3e803d394..000000000 --- a/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ALFREDPAY_EVM_TOKEN, RampCurrency, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; - -export class OnRampAlfredpayToEvmFeeEngine extends BaseFeeEngine { - readonly config: FeeConfig = { - direction: RampDirection.BUY, - skipNote: "Skipped for off-ramp request" - }; - - protected validate(ctx: QuoteContext): void { - if (!ctx.alfredpayMint) { - throw new Error("OnRampAlfredpayToEvmFeeEngine requires alfredpayMint in context"); - } - } - - protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const alfredpayFee = ctx.alfredpayMint!.fee.toString(); - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const alfredpayFeeCurrency = ctx.alfredpayMint!.currency as RampCurrency; - - return { - anchor: { amount: alfredpayFee, currency: alfredpayFeeCurrency }, - network: { amount: "0", currency: ALFREDPAY_EVM_TOKEN as RampCurrency } - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-assethub.ts b/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-assethub.ts deleted file mode 100644 index 5a045cc37..000000000 --- a/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-assethub.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getNetworkFromDestination, RampCurrency, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; - -export class OnRampAveniaToAssethubFeeEngine extends BaseFeeEngine { - readonly config: FeeConfig = { - direction: RampDirection.BUY, - skipNote: "Skipped for off-ramp request" - }; - - protected validate(ctx: QuoteContext): void { - if (!ctx.aveniaMint) { - throw new Error("OnRampAveniaToAssethubFeeEngine requires aveniaMint in context"); - } - if (!ctx.aveniaTransfer) { - throw new Error("OnRampAveniaToAssethubFeeEngine requires aveniaTransfer in context"); - } - } - - protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - const { request } = ctx; - - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const computedAnchorFee = ctx.aveniaMint!.fee.plus(ctx.aveniaTransfer!.fee).toString(); - - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const anchorFeeCurrency = ctx.aveniaMint!.currency as RampCurrency; - - const toNetwork = getNetworkFromDestination(request.to); - if (!toNetwork) { - throw new Error(`OnRampAveniaToAssethubFeeEngine: invalid network for destination: ${request.to}`); - } - - const networkFeeUsd = "0.03"; // FIXME We don't have a good estimate for XCM fees yet - - return { - anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, - network: { amount: networkFeeUsd, currency: "USD" as RampCurrency } - }; - } -} 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 deleted file mode 100644 index 3d643370b..000000000 --- a/apps/api/src/api/services/quote/engines/fee/onramp-brl-to-evm.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - EvmNetworks, - EvmToken, - evmTokenConfig, - getNetworkFromDestination, - isNetworkEVM, - multiplyByPowerOfTen, - Networks, - OnChainToken, - RampCurrency, - RampDirection -} 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 { - readonly config: FeeConfig = { - direction: RampDirection.BUY, - skipNote: "Skipped for off-ramp request" - }; - - constructor( - private readonly fromNetwork: Networks, - private readonly fromToken: EvmToken - ) { - super(); - if (!isNetworkEVM(fromNetwork)) { - throw new Error(`OnRampAveniaToEvmFeeEngine: ${fromNetwork} is not an EVM network`); - } - } - - protected validate(ctx: QuoteContext): void { - if (!ctx.aveniaMint) { - throw new Error("OnRampAveniaToEvmFeeEngine requires aveniaMint in context"); - } - if (!ctx.aveniaTransfer) { - throw new Error("OnRampAveniaToEvmFeeEngine requires aveniaTransfer in context"); - } - } - - protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - const { request } = ctx; - - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const computedAnchorFee = ctx.aveniaMint!.fee.plus(ctx.aveniaTransfer!.fee).toString(); - // 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}`); - } - - const toToken = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; - - const swapNetwork = this.fromNetwork as EvmNetworks; - // Get token details from evmTokenConfig - const fromTokenDetails = evmTokenConfig[swapNetwork]?.[this.fromToken]; - if (!fromTokenDetails) { - throw new Error(`OnRampAveniaToEvmFeeEngine: invalid token configuration for ${this.fromToken} on ${swapNetwork}`); - } - - // Same-chain same-token: Nabla swap output already matches the destination token (e.g. BRL → Base USDC). - // No bridge needed, so skip the Squid route call (which would fail with "same token same chain") and report zero network fee. - if (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) { - return { - anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, - network: { amount: "0", currency: "USD" as RampCurrency } - }; - } - - // For simplicity, we just use the input amount and convert it to the raw amount here - // It's not the actual amount that will be bridged but it doesn't matter for the network fee calculation - const amountRaw = multiplyByPowerOfTen(request.inputAmount, fromTokenDetails.decimals).toFixed(0, 0); - - const bridgeResult = await calculateEvmBridgeAndNetworkFee({ - amountRaw, - fromNetwork: swapNetwork, - fromToken: fromTokenDetails.erc20AddressSourceChain, - originalInputAmountForRateCalc: request.inputAmount, - rampType: request.rampType, - toNetwork, - toToken - }); - - return { - anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, - network: { amount: bridgeResult.networkFeeUSD, currency: "USD" as RampCurrency } - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts deleted file mode 100644 index f0cc3d81a..000000000 --- a/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - EvmNetworks, - EvmToken, - evmTokenConfig, - getNetworkFromDestination, - isNetworkEVM, - multiplyByPowerOfTen, - Networks, - OnChainToken, - RampCurrency, - RampDirection -} from "@vortexfi/shared"; -import { calculateEvmBridgeAndNetworkFee, getTokenDetailsForEvmDestination } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; - -export class OnRampMykoboToEvmFeeEngine extends BaseFeeEngine { - readonly config: FeeConfig = { - direction: RampDirection.BUY, - skipNote: "Skipped for off-ramp request" - }; - - constructor( - private readonly fromNetwork: Networks, - private readonly fromToken: EvmToken - ) { - super(); - if (!isNetworkEVM(fromNetwork)) { - throw new Error(`OnRampMykoboToEvmFeeEngine: ${fromNetwork} is not an EVM network`); - } - } - - protected validate(ctx: QuoteContext): void { - if (!ctx.mykoboMint) { - throw new Error("OnRampMykoboToEvmFeeEngine requires mykoboMint in context"); - } - } - - protected async compute(ctx: QuoteContext, _anchorFee: string, _feeCurrency: RampCurrency): Promise { - const { request } = ctx; - - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const computedAnchorFee = ctx.mykoboMint!.fee.toString(); - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - const anchorFeeCurrency = ctx.mykoboMint!.currency as RampCurrency; - - const toNetwork = getNetworkFromDestination(request.to); - if (!toNetwork) { - throw new Error(`OnRampMykoboToEvmFeeEngine: invalid network for destination: ${request.to}`); - } - - const toToken = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; - - const swapNetwork = this.fromNetwork as EvmNetworks; - const fromTokenDetails = evmTokenConfig[swapNetwork]?.[this.fromToken]; - if (!fromTokenDetails) { - throw new Error(`OnRampMykoboToEvmFeeEngine: invalid token configuration for ${this.fromToken} on ${swapNetwork}`); - } - - if (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) { - return { - anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, - network: { amount: "0", currency: "USD" as RampCurrency } - }; - } - - const amountRaw = multiplyByPowerOfTen(request.inputAmount, fromTokenDetails.decimals).toFixed(0, 0); - - const bridgeResult = await calculateEvmBridgeAndNetworkFee({ - amountRaw, - fromNetwork: swapNetwork, - fromToken: fromTokenDetails.erc20AddressSourceChain, - originalInputAmountForRateCalc: request.inputAmount, - rampType: request.rampType, - toNetwork, - toToken - }); - - return { - anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, - network: { amount: bridgeResult.networkFeeUSD, currency: "USD" as RampCurrency } - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/finalize/index.test.ts b/apps/api/src/api/services/quote/engines/finalize/index.test.ts deleted file mode 100644 index b6fcca3c2..000000000 --- a/apps/api/src/api/services/quote/engines/finalize/index.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import {afterEach, describe, expect, it, mock} from "bun:test"; -import {EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection} from "@vortexfi/shared"; -import Big from "big.js"; -import QuoteTicket from "../../../../../models/quoteTicket.model"; -import {priceFeedService} from "../../../priceFeed.service"; -import {QuoteContext} from "../../core/types"; -import {BaseFinalizeEngine, FinalizeComputation} from "."; - -class TestFinalizeEngine extends BaseFinalizeEngine { - readonly config = { - direction: RampDirection.BUY, - missingFeesMessage: "Missing test fees", - skipNote: "Skip sell quotes" - }; - - protected async computeOutput(_ctx: QuoteContext): Promise { - return { - amount: new Big(99), - decimals: 2 - }; - } -} - -describe("BaseFinalizeEngine", () => { - const originalQuoteTicketCreate = QuoteTicket.create; - const originalConvertCurrency = priceFeedService.convertCurrency; - const originalConvertCurrencyOrNull = priceFeedService.convertCurrencyOrNull; - - afterEach(() => { - QuoteTicket.create = originalQuoteTicketCreate; - priceFeedService.convertCurrency = originalConvertCurrency; - priceFeedService.convertCurrencyOrNull = originalConvertCurrencyOrNull; - }); - - it("persists profile-priced quotes as user-owned with a separate pricing partner", async () => { - const createdAt = new Date("2026-06-03T12:00:00.000Z"); - const expiresAt = new Date("2026-06-03T12:10:00.000Z"); - const quoteCreateMock = mock(async data => ({ - ...data, - createdAt, - expiresAt, - id: "quote-1" - })); - QuoteTicket.create = quoteCreateMock as unknown as typeof QuoteTicket.create; - - const ctx = { - addNote: mock(() => undefined), - fees: { - displayFiat: { - anchor: "1", - currency: FiatToken.BRL, - network: "0", - partnerMarkup: "2", - total: "13", - vortex: "10" - }, - usd: { - anchor: "0.2", - network: "0", - partnerMarkup: "0.4", - total: "2.6", - vortex: "2" - } - }, - partnerOwnerId: null, - pricingPartnerId: "pricing-partner-id", - request: { - from: EPaymentMethod.PIX, - inputAmount: "100", - inputCurrency: FiatToken.BRL, - network: Networks.Base, - outputCurrency: EvmToken.USDC, - rampType: RampDirection.BUY, - to: Networks.Base, - userId: "user-1" - } - } as unknown as QuoteContext; - - await new TestFinalizeEngine().execute(ctx); - - expect(quoteCreateMock).toHaveBeenCalledTimes(1); - expect(quoteCreateMock.mock.calls[0][0]).toMatchObject({ - partnerId: null, - pricingPartnerId: "pricing-partner-id", - status: "pending", - userId: "user-1" - }); - }); - - it("serializes applied subsidy as a separate public discount benefit", async () => { - const createdAt = new Date("2026-06-03T12:00:00.000Z"); - const expiresAt = new Date("2026-06-03T12:10:00.000Z"); - const quoteCreateMock = mock(async data => ({ - ...data, - createdAt, - expiresAt, - id: "quote-1" - })); - QuoteTicket.create = quoteCreateMock as unknown as typeof QuoteTicket.create; - priceFeedService.convertCurrencyOrNull = mock(async (amount, _from, to) => { - if (to === FiatToken.BRL) { - return new Big(amount).mul(5).toString(); - } - return amount; - }) as typeof priceFeedService.convertCurrencyOrNull; - - const ctx = { - addNote: mock(() => undefined), - fees: { - displayFiat: { - anchor: "1", - currency: FiatToken.BRL, - network: "0", - partnerMarkup: "2", - total: "13", - vortex: "10" - }, - usd: { - anchor: "0.2", - network: "0", - partnerMarkup: "0.4", - total: "2.6", - vortex: "2" - } - }, - nablaSwapEvm: { - inputAmountForSwapDecimal: "100", - inputAmountForSwapRaw: "100000000", - inputCurrency: EvmToken.BRLA, - inputDecimals: 6, - inputToken: "0xbrla", - outputAmountDecimal: new Big("98"), - outputAmountRaw: "98000000", - outputCurrency: EvmToken.USDC, - outputDecimals: 6, - outputToken: "0xusdc" - }, - request: { - from: EPaymentMethod.PIX, - inputAmount: "100", - inputCurrency: FiatToken.BRL, - network: Networks.Base, - outputCurrency: EvmToken.USDC, - rampType: RampDirection.BUY, - to: Networks.Base - }, - subsidy: { - actualOutputAmountDecimal: new Big("98"), - actualOutputAmountRaw: "98000000", - applied: true, - expectedOutputAmountDecimal: new Big("100"), - expectedOutputAmountRaw: "100000000", - idealSubsidyAmountInOutputTokenDecimal: new Big("2"), - idealSubsidyAmountInOutputTokenRaw: "2000000", - partnerId: "partner-1", - subsidyAmountInOutputTokenDecimal: new Big("2"), - subsidyAmountInOutputTokenRaw: "2000000", - subsidyRate: new Big("0.02"), - targetOutputAmountDecimal: new Big("100"), - targetOutputAmountRaw: "100000000" - }, - targetFeeFiatCurrency: FiatToken.BRL - } as unknown as QuoteContext; - - await new TestFinalizeEngine().execute(ctx); - - expect(quoteCreateMock.mock.calls[0][0].metadata.subsidyDisplay).toEqual({ - currency: FiatToken.BRL, - fiat: "10.00", - usd: "2.000000" - }); - expect(ctx.builtResponse).toMatchObject({ - discountCurrency: FiatToken.BRL, - discountFiat: "10.00", - discountUsd: "2.000000" - }); - }); - - it("omits subsidy display when the subsidy currency cannot be inferred", async () => { - const createdAt = new Date("2026-06-03T12:00:00.000Z"); - const expiresAt = new Date("2026-06-03T12:10:00.000Z"); - const quoteCreateMock = mock(async data => ({ - ...data, - createdAt, - expiresAt, - id: "quote-1" - })); - QuoteTicket.create = quoteCreateMock as unknown as typeof QuoteTicket.create; - - const ctx = { - addNote: mock(() => undefined), - fees: { - displayFiat: { - anchor: "1", - currency: FiatToken.BRL, - network: "0", - partnerMarkup: "2", - total: "13", - vortex: "10" - }, - usd: { - anchor: "0.2", - network: "0", - partnerMarkup: "0.4", - total: "2.6", - vortex: "2" - } - }, - request: { - from: EPaymentMethod.PIX, - inputAmount: "100", - inputCurrency: FiatToken.BRL, - network: Networks.Base, - outputCurrency: EvmToken.USDC, - rampType: RampDirection.BUY, - to: Networks.Base - }, - subsidy: { - actualOutputAmountDecimal: new Big("98"), - actualOutputAmountRaw: "98000000", - applied: true, - expectedOutputAmountDecimal: new Big("100"), - expectedOutputAmountRaw: "100000000", - idealSubsidyAmountInOutputTokenDecimal: new Big("2"), - idealSubsidyAmountInOutputTokenRaw: "2000000", - partnerId: "partner-1", - subsidyAmountInOutputTokenDecimal: new Big("2"), - subsidyAmountInOutputTokenRaw: "2000000", - subsidyRate: new Big("0.02"), - targetOutputAmountDecimal: new Big("100"), - targetOutputAmountRaw: "100000000" - }, - targetFeeFiatCurrency: FiatToken.BRL - } as unknown as QuoteContext; - - await new TestFinalizeEngine().execute(ctx); - - expect(quoteCreateMock.mock.calls[0][0].metadata.subsidyDisplay).toBeUndefined(); - expect(ctx.builtResponse).not.toHaveProperty("discountFiat"); - }); - - it("omits subsidy display when display currency conversion fails", async () => { - const createdAt = new Date("2026-06-03T12:00:00.000Z"); - const expiresAt = new Date("2026-06-03T12:10:00.000Z"); - const quoteCreateMock = mock(async data => ({ - ...data, - createdAt, - expiresAt, - id: "quote-1" - })); - QuoteTicket.create = quoteCreateMock as unknown as typeof QuoteTicket.create; - priceFeedService.convertCurrencyOrNull = mock(async () => null) as typeof priceFeedService.convertCurrencyOrNull; - - const ctx = { - addNote: mock(() => undefined), - fees: { - displayFiat: { - anchor: "1", - currency: FiatToken.BRL, - network: "0", - partnerMarkup: "2", - total: "13", - vortex: "10" - }, - usd: { - anchor: "0.2", - network: "0", - partnerMarkup: "0.4", - total: "2.6", - vortex: "2" - } - }, - nablaSwapEvm: { - inputAmountForSwapDecimal: "100", - inputAmountForSwapRaw: "100000000", - inputCurrency: EvmToken.BRLA, - inputDecimals: 6, - inputToken: "0xbrla", - outputAmountDecimal: new Big("98"), - outputAmountRaw: "98000000", - outputCurrency: EvmToken.USDC, - outputDecimals: 6, - outputToken: "0xusdc" - }, - request: { - from: EPaymentMethod.PIX, - inputAmount: "100", - inputCurrency: FiatToken.BRL, - network: Networks.Base, - outputCurrency: EvmToken.USDC, - rampType: RampDirection.BUY, - to: Networks.Base - }, - subsidy: { - actualOutputAmountDecimal: new Big("98"), - actualOutputAmountRaw: "98000000", - applied: true, - expectedOutputAmountDecimal: new Big("100"), - expectedOutputAmountRaw: "100000000", - idealSubsidyAmountInOutputTokenDecimal: new Big("2"), - idealSubsidyAmountInOutputTokenRaw: "2000000", - partnerId: "partner-1", - subsidyAmountInOutputTokenDecimal: new Big("2"), - subsidyAmountInOutputTokenRaw: "2000000", - subsidyRate: new Big("0.02"), - targetOutputAmountDecimal: new Big("100"), - targetOutputAmountRaw: "100000000" - }, - targetFeeFiatCurrency: FiatToken.BRL - } as unknown as QuoteContext; - - await new TestFinalizeEngine().execute(ctx); - - expect(quoteCreateMock.mock.calls[0][0].metadata.subsidyDisplay).toBeUndefined(); - expect(ctx.builtResponse).not.toHaveProperty("discountFiat"); - }); -}); diff --git a/apps/api/src/api/services/quote/engines/finalize/index.ts b/apps/api/src/api/services/quote/engines/finalize/index.ts deleted file mode 100644 index d3fa516f7..000000000 --- a/apps/api/src/api/services/quote/engines/finalize/index.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { EvmToken, getPaymentMethodFromDestinations, QuoteResponse, RampCurrency, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import httpStatus from "http-status"; -import { config } from "../../../../../config/vars"; -import QuoteTicket from "../../../../../models/quoteTicket.model"; -import { APIError } from "../../../../errors/api-error"; -import { priceFeedService } from "../../../priceFeed.service"; -import { trimTrailingZeros } from "../../core/helpers"; -import { QuoteContext, Stage, StageKey } from "../../core/types"; - -export interface FinalizeStageConfig { - direction: RampDirection; - skipNote: string; - missingFeesMessage: string; -} - -export interface FinalizeComputation { - amount: Big; - decimals: number; -} - -function getExpirationDate(ctx: QuoteContext): Date { - if (ctx.alfredpayMint?.expirationDate) { - return ctx.alfredpayMint.expirationDate; - } - if (ctx.alfredpayOfframp?.expirationDate) { - return ctx.alfredpayOfframp.expirationDate; - } - return new Date(Date.now() + 10 * 60 * 1000); -} - -function getSubsidySourceCurrency(ctx: QuoteContext): RampCurrency | null { - return ( - ctx.nablaSwapEvm?.outputCurrency ?? - ctx.nablaSwap?.outputCurrency ?? - ctx.alfredpayMint?.currency ?? - ctx.alfredpayOfframp?.currency ?? - null - ); -} - -async function assignSubsidyDisplay(ctx: QuoteContext): Promise { - const subsidy = ctx.subsidy; - if (!subsidy?.applied || subsidy.subsidyAmountInOutputTokenDecimal.lte(0)) { - ctx.subsidyDisplay = undefined; - return; - } - - const sourceCurrency = getSubsidySourceCurrency(ctx); - if (!sourceCurrency) { - ctx.subsidyDisplay = undefined; - return; - } - - const subsidyAmount = subsidy.subsidyAmountInOutputTokenDecimal.toString(); - const [discountFiat, discountUsd] = await Promise.all([ - priceFeedService.convertCurrencyOrNull(subsidyAmount, sourceCurrency, ctx.targetFeeFiatCurrency), - priceFeedService.convertCurrencyOrNull(subsidyAmount, sourceCurrency, EvmToken.USDC as RampCurrency) - ]); - - if (!discountFiat || !discountUsd) { - ctx.subsidyDisplay = undefined; - return; - } - - ctx.subsidyDisplay = { - currency: ctx.targetFeeFiatCurrency, - fiat: new Big(discountFiat).toFixed(2), - usd: new Big(discountUsd).toFixed(6) - }; -} - -export function buildQuoteResponse(quoteTicket: QuoteTicket): QuoteResponse { - const usdFees = quoteTicket.metadata.fees?.usd; - const fiatFees = quoteTicket.metadata.fees?.displayFiat; - - if (!usdFees || !fiatFees) { - throw new APIError({ message: "Missing fee information in quote record", status: httpStatus.INTERNAL_SERVER_ERROR }); - } - - // Calculate processing fees - const processingFeeFiat = new Big(fiatFees.anchor).plus(fiatFees.vortex).toFixed(); - const processingFeeUsd = new Big(usdFees.anchor).plus(usdFees.vortex).toFixed(); - - return { - alfredpayInputLimits: quoteTicket.metadata.alfredpayInputLimits, - anchorFeeFiat: fiatFees.anchor, - anchorFeeUsd: usdFees.anchor, - createdAt: quoteTicket.createdAt, - expiresAt: quoteTicket.expiresAt, - feeCurrency: fiatFees.currency, - from: quoteTicket.from, - id: quoteTicket.id, - inputAmount: trimTrailingZeros(quoteTicket.inputAmount), - inputCurrency: quoteTicket.inputCurrency, - network: quoteTicket.network, - networkFeeFiat: fiatFees.network, - networkFeeUsd: usdFees.network, - outputAmount: trimTrailingZeros(quoteTicket.outputAmount), - outputCurrency: quoteTicket.outputCurrency, - partnerFeeFiat: fiatFees.partnerMarkup, - partnerFeeUsd: usdFees.partnerMarkup, - paymentMethod: quoteTicket.paymentMethod, - processingFeeFiat, - processingFeeUsd, - rampType: quoteTicket.rampType, - ...(quoteTicket.metadata.subsidyDisplay - ? { - discountCurrency: quoteTicket.metadata.subsidyDisplay.currency, - discountFiat: quoteTicket.metadata.subsidyDisplay.fiat, - discountUsd: quoteTicket.metadata.subsidyDisplay.usd - } - : {}), - to: quoteTicket.to, - totalFeeFiat: fiatFees.total, - totalFeeUsd: usdFees.total, - vortexFeeFiat: fiatFees.vortex, - vortexFeeUsd: usdFees.vortex - }; -} - -export abstract class BaseFinalizeEngine implements Stage { - abstract readonly config: FinalizeStageConfig; - - readonly key = StageKey.Finalize; - - async execute(ctx: QuoteContext): Promise { - const { request } = ctx; - const { direction, skipNote, missingFeesMessage } = this.config; - - if (request.rampType !== direction) { - ctx.addNote?.(skipNote); - return; - } - - if (!ctx.fees?.displayFiat) { - throw new APIError({ message: missingFeesMessage, status: httpStatus.INTERNAL_SERVER_ERROR }); - } - - const computation = await this.computeOutput(ctx); - await this.validate(ctx, computation); - - const outputAmountStr = computation.amount.toFixed(computation.decimals, 0); - await assignSubsidyDisplay(ctx); - - const paymentMethod = getPaymentMethodFromDestinations(request.from, request.to); - - // Check if we should skip persistence (for best quote comparison) - if (ctx.skipPersistence) { - // Build response without saving to database - const usdFees = ctx.fees.usd; - const fiatFees = ctx.fees.displayFiat; - - if (!usdFees || !fiatFees) { - throw new APIError({ message: "Missing fee information", status: httpStatus.INTERNAL_SERVER_ERROR }); - } - - const processingFeeFiat = new Big(fiatFees.anchor).plus(fiatFees.vortex).toFixed(); - const processingFeeUsd = new Big(usdFees.anchor).plus(usdFees.vortex).toFixed(); - - const expiresAt = getExpirationDate(ctx); - - ctx.builtResponse = { - alfredpayInputLimits: ctx.alfredpayInputLimits, - anchorFeeFiat: fiatFees.anchor, - anchorFeeUsd: usdFees.anchor, - createdAt: new Date(), - expiresAt, - feeCurrency: fiatFees.currency, - from: request.from, - id: "temp-" + Date.now(), // Temporary ID for comparison - inputAmount: trimTrailingZeros(request.inputAmount), - inputCurrency: request.inputCurrency, - network: request.network, - networkFeeFiat: fiatFees.network, - networkFeeUsd: usdFees.network, - outputAmount: trimTrailingZeros(outputAmountStr), - outputCurrency: request.outputCurrency, - partnerFeeFiat: fiatFees.partnerMarkup, - partnerFeeUsd: usdFees.partnerMarkup, - paymentMethod, - processingFeeFiat, - processingFeeUsd, - rampType: request.rampType, - ...(ctx.subsidyDisplay - ? { - discountCurrency: ctx.subsidyDisplay.currency, - discountFiat: ctx.subsidyDisplay.fiat, - discountUsd: ctx.subsidyDisplay.usd - } - : {}), - to: request.to, - totalFeeFiat: fiatFees.total, - totalFeeUsd: usdFees.total, - vortexFeeFiat: fiatFees.vortex, - vortexFeeUsd: usdFees.vortex - }; - - ctx.addNote?.("Built in-memory quote response (no persistence)"); - return; - } - - // Normal flow: persist to database - const expiresAt = getExpirationDate(ctx); - - const record = await QuoteTicket.create({ - apiKey: request.apiKey || null, - countryCode: request.countryCode, - expiresAt, - flowVariant: config.flowVariant, - from: request.from, - inputAmount: request.inputAmount, - inputCurrency: request.inputCurrency, - metadata: ctx, - network: request.network, - outputAmount: outputAmountStr, - outputCurrency: request.outputCurrency, - partnerId: ctx.partnerOwnerId || null, - paymentMethod, - pricingPartnerId: ctx.pricingPartnerId || null, - rampType: request.rampType, - status: "pending", - to: request.to, - userId: request.userId || null - }); - - ctx.builtResponse = buildQuoteResponse(record); - - ctx.addNote?.("Persisted quote and built response"); - } - - protected abstract computeOutput(ctx: QuoteContext): Promise; - - protected async validate(_ctx: QuoteContext, _result: FinalizeComputation): Promise { - // Implemented by subclasses when necessary - } -} diff --git a/apps/api/src/api/services/quote/engines/finalize/offramp.ts b/apps/api/src/api/services/quote/engines/finalize/offramp.ts deleted file mode 100644 index c3adfea42..000000000 --- a/apps/api/src/api/services/quote/engines/finalize/offramp.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { FiatToken, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import httpStatus from "http-status"; -import { APIError } from "../../../../errors/api-error"; -import { QuoteContext } from "../../core/types"; -import { applyAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; -import { BaseFinalizeEngine, FinalizeComputation } from "."; - -export class OffRampFinalizeEngine extends BaseFinalizeEngine { - readonly config = { - direction: RampDirection.SELL, - missingFeesMessage: "OffRampFinalizeEngine requires computed anchor fees", - skipNote: "Skipped for on-ramp request" - } as const; - - protected async computeOutput(ctx: QuoteContext): Promise { - const offrampAmount = - ctx.request.to === "pix" - ? (ctx.nablaSwapEvm?.outputAmountDecimal ?? ctx.pendulumToMoonbeamXcm?.outputAmountDecimal) - : ctx.request.to === "sepa" - ? ctx.nablaSwapEvm?.outputAmountDecimal - : ctx.alfredpayOfframp - ? ctx.alfredpayOfframp.outputAmountDecimal - : undefined; - - if (!offrampAmount) { - throw new APIError({ - message: "OffRampFinalizeEngine requires nablaSwapEvm, pendulumToMoonbeamXcm or alfredpayOfframp output", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - - // AlfredPay's toAmount is already net-of-fees, so no fee subtraction needed. - // For other providers (e.g. BRLA), the anchor fee must still be subtracted. - const isAlfredpay = !!ctx.alfredpayOfframp; - let amount: Big; - - if (isAlfredpay) { - amount = new Big(offrampAmount); - } else { - const anchorFee = ctx.fees?.displayFiat?.anchor; - if (anchorFee === undefined) { - throw new APIError({ - message: "OffRampFinalizeEngine requires computed anchor fees", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - amount = new Big(offrampAmount).minus(anchorFee); - } - - return { - amount, - decimals: 2 - }; - } - - protected async validate(ctx: QuoteContext, { amount }: FinalizeComputation): Promise { - if (await applyAlfredpayLimits(ctx, ctx.request.inputAmount)) return; - validateAmountLimits(amount, ctx.request.outputCurrency as FiatToken, "min", ctx.request.rampType); - validateAmountLimits(amount, ctx.request.outputCurrency as FiatToken, "max", ctx.request.rampType); - } -} diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts deleted file mode 100644 index ed6c96078..000000000 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {describe, expect, it} from "bun:test"; -import {EvmToken, FiatToken, Networks, RampDirection} from "@vortexfi/shared"; -import Big from "big.js"; -import {OnRampFinalizeEngine} from "./onramp"; - -class TestOnRampFinalizeEngine extends OnRampFinalizeEngine { - compute(ctx: Parameters[0]) { - return this.computeOutput(ctx); - } -} - -describe("OnRampFinalizeEngine", () => { - it("uses destination EVM token decimals for BRL onramp output precision", async () => { - const result = await new TestOnRampFinalizeEngine().compute({ - evmToEvm: { - outputAmountDecimal: new Big("4817.805726163073314321") - }, - request: { - inputCurrency: FiatToken.BRL, - outputCurrency: EvmToken.USDT, - rampType: RampDirection.BUY, - to: Networks.BSC - } - } as never); - - expect(result.decimals).toBe(18); - expect(result.amount.toFixed(result.decimals, 0)).toBe("4817.805726163073314321"); - }); - - it("uses destination EVM token decimals for Alfredpay routed onramp output precision", async () => { - const result = await new TestOnRampFinalizeEngine().compute({ - evmToEvm: { - outputAmountDecimal: new Big("4817.805726163073314321") - }, - request: { - inputCurrency: FiatToken.USD, - outputCurrency: EvmToken.USDT, - rampType: RampDirection.BUY, - to: Networks.BSC - } - } as never); - - expect(result.decimals).toBe(18); - expect(result.amount.toFixed(result.decimals, 0)).toBe("4817.805726163073314321"); - }); -}); diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.ts deleted file mode 100644 index 0e3f6f4f6..000000000 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { AssetHubToken, FiatToken, OnChainToken, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import httpStatus from "http-status"; -import { APIError } from "../../../../errors/api-error"; -import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { applyAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; -import { BaseFinalizeEngine, FinalizeComputation } from "."; - -export class OnRampFinalizeEngine extends BaseFinalizeEngine { - readonly config = { - direction: RampDirection.BUY, - missingFeesMessage: "OnRampFinalizeEngine requires displayFiat", - skipNote: "Skipped for off-ramp request" - } as const; - - protected async computeOutput(ctx: QuoteContext): Promise { - const { request } = ctx; - - let finalOutputAmountDecimal: Big; - let finalOutputDecimals = 6; - if (request.to === "assethub") { - if (request.outputCurrency === AssetHubToken.USDC) { - const output = ctx.pendulumToAssethubXcm?.outputAmountDecimal; - if (!output) { - throw new APIError({ - message: "OnRampFinalizeEngine requires pendulumToAssethubXcm output for AssetHub non-USDC", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - finalOutputAmountDecimal = new Big(output); - } else { - const output = ctx.hydrationToAssethubXcm?.outputAmountDecimal; - if (!output) { - throw new APIError({ - message: "OnRampFinalizeEngine requires hydrationToAssethubXcm output for AssetHub non-USDC", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - finalOutputAmountDecimal = output; - } - } else if (request.inputCurrency === FiatToken.EURC || request.inputCurrency === FiatToken.BRL) { - const output = ctx.evmToEvm?.outputAmountDecimal; - if (!output) { - throw new APIError({ - message: "OnRampFinalizeEngine requires bridge output for EVM", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - finalOutputAmountDecimal = new Big(output); - finalOutputDecimals = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, request.to).decimals; - } else if ( - request.inputCurrency === FiatToken.USD || - request.inputCurrency === FiatToken.MXN || - request.inputCurrency === FiatToken.COP || - request.inputCurrency === FiatToken.ARS - ) { - // evmToEvm is set when Squid Router ran (e.g. USDC Polygon → USDT Arbitrum). - // When destination is USDC on Polygon, Squid Router is skipped (skipRouteCalculation) - // because Alfredpay already minted USDC there — use the mint output directly. - const output = ctx.evmToEvm?.outputAmountDecimal ?? ctx.alfredpayMint?.outputAmountDecimal; - if (!output) { - throw new APIError({ - message: "OnRampFinalizeEngine requires evmToEvm or alfredpayMint output for EVM", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - let amount = new Big(output); - if (ctx.evmToEvm) { - finalOutputDecimals = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, request.to).decimals; - } - if (!ctx.evmToEvm && ctx.alfredpayMint) { - const usdFees = ctx.fees?.usd; - const feesToDeduct = usdFees ? new Big(usdFees.vortex).plus(usdFees.partnerMarkup) : new Big(0); - amount = amount.minus(feesToDeduct); - } - finalOutputAmountDecimal = amount; - } else { - const output = ctx.moonbeamToEvm?.outputAmountDecimal; - if (!output) { - throw new APIError({ - message: "OnRampFinalizeEngine requires moonbeamToEvm bridge output for EVM", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - finalOutputAmountDecimal = new Big(output); - } - - if (finalOutputAmountDecimal.lte(0)) { - throw new APIError({ - message: "Input amount too low to cover calculated fees", - status: httpStatus.BAD_REQUEST - }); - } - - return { - amount: finalOutputAmountDecimal, - decimals: finalOutputDecimals - }; - } - - protected async validate(ctx: QuoteContext): Promise { - if (await applyAlfredpayLimits(ctx, ctx.request.inputAmount)) return; - validateAmountLimits(ctx.request.inputAmount, ctx.request.inputCurrency as FiatToken, "min", ctx.request.rampType); - validateAmountLimits(ctx.request.inputAmount, ctx.request.inputCurrency as FiatToken, "max", ctx.request.rampType); - } -} diff --git a/apps/api/src/api/services/quote/engines/hydration/onramp.ts b/apps/api/src/api/services/quote/engines/hydration/onramp.ts deleted file mode 100644 index dfb8f3832..000000000 --- a/apps/api/src/api/services/quote/engines/hydration/onramp.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { AssetHubToken, assetHubTokenConfig, multiplyByPowerOfTen, RampCurrency, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import HydrationRouter from "../../../hydration/swap"; -import { priceFeedService } from "../../../priceFeed.service"; -import { QuoteContext, Stage, StageKey } from "../../core/types"; - -export class OnRampHydrationEngine implements Stage { - readonly key = StageKey.HydrationSwap; - - private price = priceFeedService; - - async execute(ctx: QuoteContext): Promise { - const req = ctx.request; - - if (req.rampType !== RampDirection.BUY) { - ctx.addNote?.("Skipped for off-ramp request"); - return; - } - - if (!ctx.pendulumToHydrationXcm) { - throw new Error("OnRampHydrationEngine requires pendulumToHydrationXcm in context"); - } - - // We will always use Assethub USDC as the input token of the swap - const inputTokenDetails = assetHubTokenConfig[AssetHubToken.USDC]; - const outputTokenDetails = assetHubTokenConfig[req.outputCurrency as AssetHubToken]; - - const assetIn = inputTokenDetails.hydrationId; - const assetOut = outputTokenDetails.hydrationId; - const amountIn = ctx.pendulumToHydrationXcm.outputAmountDecimal.toString(); - - const trade = await HydrationRouter.getBestSellPriceFor(assetIn, assetOut, amountIn); - - const amountInRaw = trade.amountIn.toFixed(0, 0); - const amountOutRaw = trade.amountOut.toFixed(0, 0); - const assetOutDecimals = trade.swaps[trade.swaps.length - 1].assetOutDecimals; - const amountOut = multiplyByPowerOfTen(amountOutRaw, -assetOutDecimals).toFixed(assetOutDecimals); - - const slippagePercent = 0.05; // We hardcode slippage to 0.05 for now - const amountOutMin = new Big(amountOut).mul(new Big(1).minus(slippagePercent / 100)).toFixed(assetOutDecimals); - const amountOutMinRaw = multiplyByPowerOfTen(amountOutMin, assetOutDecimals).toFixed(0, 0); - - const xcmFees = await HydrationRouter.getXcmTransactionFeeToAssethub(assetOut); - - ctx.hydrationSwap = { - inputAmountDecimal: amountIn, - inputAmountRaw: amountInRaw, - inputAsset: assetIn, - minOutputAmountDecimal: amountOutMin, - minOutputAmountRaw: amountOutMinRaw, - outputAmountDecimal: amountOut, - outputAmountRaw: amountOutRaw, - outputAsset: assetOut, - slippagePercent - }; - - // Calculations for XCM transfer - // To be safe, we use the minimum output amount for XCM transfer calculations - const xcmInputAmountDecimal = Big(amountOutMin); - const xcmInputAmountRaw = Big(amountOutMinRaw); - - // Calculate gross output after subtracting XCM fees - const originFeeInTargetCurrency = await this.price.convertCurrency( - xcmFees.origin.amount, - xcmFees.origin.currency as RampCurrency, - req.outputCurrency - ); - const destinationFeeInTargetCurrency = await this.price.convertCurrency( - xcmFees.destination.amount, - xcmFees.destination.currency as RampCurrency, - req.outputCurrency - ); - - const outputAmountDecimal = new Big(xcmInputAmountDecimal) - .minus(originFeeInTargetCurrency) - .minus(destinationFeeInTargetCurrency); - const outputAmountRaw = multiplyByPowerOfTen(outputAmountDecimal, outputTokenDetails.decimals).toFixed(0, 0); - - ctx.hydrationToAssethubXcm = { - fromToken: outputTokenDetails.assetSymbol, - inputAmountDecimal: xcmInputAmountDecimal, - inputAmountRaw: xcmInputAmountRaw.toFixed(0, 0), - outputAmountDecimal, - outputAmountRaw, - toToken: outputTokenDetails.assetSymbol, - xcmFees - }; - - ctx.addNote?.(`Swap ${amountIn} ${inputTokenDetails.assetSymbol} to ${amountOut} ${outputTokenDetails.assetSymbol}`); - } -} diff --git a/apps/api/src/api/services/quote/engines/initialize/index.ts b/apps/api/src/api/services/quote/engines/initialize/index.ts deleted file mode 100644 index a1589201e..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/index.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - getNetworkFromDestination, - getPendulumDetails, - multiplyByPowerOfTen, - Networks, - OnChainToken, - RampCurrency, - RampDirection, - XcmFees -} from "@vortexfi/shared"; -import Big from "big.js"; -import httpStatus from "http-status"; -import { APIError } from "../../../../errors/api-error"; -import { priceFeedService } from "../../../priceFeed.service"; -import { calculatePreNablaDeductibleFees } from "../../core/quote-fees"; -import { QuoteContext, Stage, StageKey } from "../../core/types"; - -export interface InitializeStageConfig { - direction: RampDirection; - skipNote: string; -} - -export abstract class BaseInitializeEngine implements Stage { - abstract readonly config: InitializeStageConfig; - - readonly key = StageKey.Initialize; - - async execute(ctx: QuoteContext): Promise { - const { direction, skipNote } = this.config; - - if (ctx.request.rampType !== direction) { - ctx.addNote?.(skipNote); - return; - } - - await this.executeInternal(ctx); - } - - protected abstract executeInternal(ctx: QuoteContext): Promise; -} - -export async function assignPreNablaContext(ctx: QuoteContext): Promise { - const req = ctx.request; - - const { preNablaDeductibleFeeAmount: deductibleFeeAmountInFeeCurrency, feeCurrency } = await calculatePreNablaDeductibleFees( - req.inputAmount, - req.inputCurrency, - req.outputCurrency, - req.rampType, - req.from, - req.to, - ctx.partner?.id || undefined - ); - - const fromNetwork = getNetworkFromDestination(req.from); - if (!fromNetwork) { - throw new APIError({ message: `Invalid source network: ${req.from}`, status: httpStatus.BAD_REQUEST }); - } - - const representativeCurrency = getPendulumDetails(req.inputCurrency, fromNetwork).currency; - - const deductibleFeeAmountInSwapCurrency = await priceFeedService.convertCurrency( - deductibleFeeAmountInFeeCurrency.toString(), - feeCurrency, - representativeCurrency - ); - - ctx.preNabla = { - deductibleFeeAmountInFeeCurrency, - deductibleFeeAmountInSwapCurrency: new Big(deductibleFeeAmountInSwapCurrency), - feeCurrency, - representativeInputCurrency: representativeCurrency - }; -} - -export function buildXcmMeta(): XcmFees { - return { - destination: { amount: "0.01", amountRaw: "10000", currency: "USDC" }, - origin: { amount: "0.01", amountRaw: "10000", currency: "USDC" } - }; -} - -export async function assignAssethubToPendulumXcm(ctx: QuoteContext, xcmFees: XcmFees): Promise { - const req = ctx.request; - - const fromToken = req.inputCurrency as OnChainToken; - const fromTokenDecimals = getPendulumDetails(fromToken, Networks.AssetHub).decimals; - const inputAmountDecimal = new Big(req.inputAmount); - const inputAmountRaw = multiplyByPowerOfTen(inputAmountDecimal, fromTokenDecimals).toFixed(0); - - // Calculate gross output after subtracting XCM fees - const originFeeInTargetCurrency = await priceFeedService.convertCurrency( - xcmFees.origin.amount, - xcmFees.origin.currency as RampCurrency, - req.inputCurrency - ); - const destinationFeeInTargetCurrency = await priceFeedService.convertCurrency( - xcmFees.destination.amount, - xcmFees.destination.currency as RampCurrency, - req.inputCurrency - ); - - const outputAmountDecimal = inputAmountDecimal.minus(originFeeInTargetCurrency).minus(destinationFeeInTargetCurrency); - const outputAmountRaw = multiplyByPowerOfTen(outputAmountDecimal, fromTokenDecimals).toFixed(0); - - ctx.assethubToPendulumXcm = { - fromToken, - inputAmountDecimal, - inputAmountRaw, - outputAmountDecimal, - outputAmountRaw, - toToken: fromToken, // Input and output token are the same for XCM transfer - xcmFees - }; -} - -export async function assignMoonbeamToPendulumXcm( - ctx: QuoteContext, - xcmFees: XcmFees, - inputAmountDecimal: Big, - inputAmountRaw: string -): Promise { - ctx.moonbeamToPendulumXcm = { - fromToken: ctx.request.inputCurrency, - inputAmountDecimal, - inputAmountRaw, - outputAmountDecimal: inputAmountDecimal, - outputAmountRaw: inputAmountRaw, - toToken: ctx.request.inputCurrency, - xcmFees - }; -} diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-assethub.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-assethub.ts deleted file mode 100644 index 96b453d4a..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-assethub.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { assignAssethubToPendulumXcm, assignPreNablaContext, BaseInitializeEngine, buildXcmMeta } from "./index"; - -export class OffRampFromAssethubInitializeEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.SELL, - skipNote: "OffRampFromAssethubInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - await assignPreNablaContext(ctx); - - const xcmFees = buildXcmMeta(); - - await assignAssethubToPendulumXcm(ctx, xcmFees); - - const meta = ctx.assethubToPendulumXcm; - if (!meta) { - throw new Error( - "OffRampFromAssethubInitializeEngine: Assethub XCM context not assigned - ensure assignAssethubToPendulumXcm ran successfully" - ); - } - - ctx.addNote?.( - `Initialized: input=${meta.inputAmountDecimal.toString()} ${meta.fromToken}, raw=${meta.inputAmountRaw}, output=${meta.outputAmountDecimal.toString()} ${meta.fromToken}, raw=${meta.outputAmountRaw}` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts deleted file mode 100644 index 5df6ced51..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ALFREDPAY_EVM_TOKEN, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { assignPreNablaContext, BaseInitializeEngine } from "./index"; - -export class OffRampFromEvmInitializeAlfredpayEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.SELL, - skipNote: - "OffRampFromEvmInitializeAlfredpayEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - const req = ctx.request; - - await assignPreNablaContext(ctx); - - const quoteRequest: EvmBridgeQuoteRequest = { - amountDecimal: req.inputAmount, - fromNetwork: req.from as Networks, - inputCurrency: req.inputCurrency as OnChainToken, - outputCurrency: ALFREDPAY_EVM_TOKEN, - rampType: req.rampType, - toNetwork: Networks.Polygon - }; - const bridgeQuote = await getEvmBridgeQuote(quoteRequest); - - ctx.evmToEvm = { - ...quoteRequest, - fromToken: bridgeQuote.fromToken, - inputAmountDecimal: Big(quoteRequest.amountDecimal), - inputAmountRaw: bridgeQuote.inputAmountRaw, - networkFeeUSD: bridgeQuote.networkFeeUSD, - outputAmountDecimal: bridgeQuote.outputAmountDecimal, - outputAmountRaw: bridgeQuote.outputAmountRaw, - toToken: bridgeQuote.toToken - }; - - ctx.addNote?.( - `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToEvm?.inputAmountRaw}, output=${ctx.evmToEvm?.outputAmountDecimal.toString()} ${ctx.evmToEvm?.toToken}, raw=${ctx.evmToEvm?.outputAmountRaw}` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-avenia.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-avenia.ts deleted file mode 100644 index 450eac9b2..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-avenia.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { EvmToken, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { assignPreNablaContext, BaseInitializeEngine } from "./index"; - -export class OffRampFromEvmInitializeAveniaEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.SELL, - skipNote: "OffRampFromEvmInitializeAveniaEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - const req = ctx.request; - - await assignPreNablaContext(ctx); - - const quoteRequest: EvmBridgeQuoteRequest = { - amountDecimal: req.inputAmount, - fromNetwork: req.from as Networks, - inputCurrency: req.inputCurrency as OnChainToken, - outputCurrency: EvmToken.USDC, - rampType: req.rampType, - toNetwork: Networks.Base - }; - - const bridgeQuote = await getEvmBridgeQuote(quoteRequest); - - ctx.evmToEvm = { - ...quoteRequest, - fromToken: bridgeQuote.fromToken, - inputAmountDecimal: Big(quoteRequest.amountDecimal), - inputAmountRaw: bridgeQuote.inputAmountRaw, - networkFeeUSD: bridgeQuote.networkFeeUSD, - outputAmountDecimal: bridgeQuote.outputAmountDecimal, - outputAmountRaw: bridgeQuote.outputAmountRaw, - toToken: bridgeQuote.toToken - }; - - ctx.addNote?.( - `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToEvm?.inputAmountRaw}, output=${ctx.evmToEvm?.outputAmountDecimal.toString()} ${ctx.evmToEvm?.toToken}, raw=${ctx.evmToEvm?.outputAmountRaw}` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-mykobo.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-mykobo.ts deleted file mode 100644 index 6f9fb8117..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-mykobo.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { EvmToken, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { assignPreNablaContext, BaseInitializeEngine } from "./index"; - -export class OffRampFromEvmInitializeMykoboEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.SELL, - skipNote: "OffRampFromEvmInitializeMykoboEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - const req = ctx.request; - - await assignPreNablaContext(ctx); - - const quoteRequest: EvmBridgeQuoteRequest = { - amountDecimal: req.inputAmount, - fromNetwork: req.from as Networks, - inputCurrency: req.inputCurrency as OnChainToken, - outputCurrency: EvmToken.USDC, - rampType: req.rampType, - toNetwork: Networks.Base - }; - - const bridgeQuote = await getEvmBridgeQuote(quoteRequest); - - ctx.evmToEvm = { - ...quoteRequest, - fromToken: bridgeQuote.fromToken, - inputAmountDecimal: Big(quoteRequest.amountDecimal), - inputAmountRaw: bridgeQuote.inputAmountRaw, - networkFeeUSD: bridgeQuote.networkFeeUSD, - outputAmountDecimal: bridgeQuote.outputAmountDecimal, - outputAmountRaw: bridgeQuote.outputAmountRaw, - toToken: bridgeQuote.toToken - }; - - ctx.addNote?.( - `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToEvm?.inputAmountRaw}, output=${ctx.evmToEvm?.outputAmountDecimal.toString()} ${ctx.evmToEvm?.toToken}, raw=${ctx.evmToEvm?.outputAmountRaw}` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts deleted file mode 100644 index 54f254ed6..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { EvmToken, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { assignPreNablaContext, BaseInitializeEngine } from "./index"; - -export class OffRampFromEvmInitializeEngineMoonbeam extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.SELL, - skipNote: "OffRampFromEvmInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - await assignPreNablaContext(ctx); - - const req = ctx.request; - - const quoteRequest: EvmBridgeQuoteRequest = { - amountDecimal: req.inputAmount, - fromNetwork: req.from as Networks, - inputCurrency: req.inputCurrency as OnChainToken, - outputCurrency: EvmToken.AXLUSDC as unknown as OnChainToken, - rampType: req.rampType, - toNetwork: Networks.Moonbeam - }; - - const bridgeQuote = await getEvmBridgeQuote(quoteRequest); - - ctx.evmToPendulum = { - ...quoteRequest, - fromToken: bridgeQuote.fromToken, - inputAmountDecimal: Big(quoteRequest.amountDecimal), - inputAmountRaw: bridgeQuote.inputAmountRaw, - networkFeeUSD: bridgeQuote.networkFeeUSD, - outputAmountDecimal: bridgeQuote.outputAmountDecimal, - outputAmountRaw: bridgeQuote.outputAmountRaw, - toToken: bridgeQuote.toToken - }; - - ctx.addNote?.( - `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToPendulum?.inputAmountRaw}, output=${ctx.evmToPendulum?.outputAmountDecimal.toString()} ${ctx.evmToPendulum?.toToken}, raw=${ctx.evmToPendulum?.outputAmountRaw}` - ); - } -} 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 deleted file mode 100644 index 3481c3348..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - ALFREDPAY_ERC20_DECIMALS, - ALFREDPAY_ONCHAIN_CURRENCY, - AlfredpayApiService, - AlfredpayChain, - AlfredpayFiatCurrency, - AlfredpayPaymentMethodType, - CreateAlfredpayOnrampQuoteRequest, - multiplyByPowerOfTen, - RampDirection -} from "@vortexfi/shared"; -import Big from "big.js"; -import { resolveAlfredpayQuoteCustomerId } from "../../alfredpay-customer"; -import { QuoteContext } from "../../core/types"; -import { BaseInitializeEngine } from "./index"; - -export class OnRampInitializeAlfredpayEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.BUY, - skipNote: "OnRampInitializeAlfredpayEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - const req = ctx.request; - - 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 - }, // Mints hardcoded to Polygon. - paymentMethodType: AlfredpayPaymentMethodType.BANK, - toCurrency: ALFREDPAY_ONCHAIN_CURRENCY - }; - - const quote = await alfredpayService.createOnrampQuote(quoteRequest); - - const fromAmount = new Big(quote.fromAmount); - const toAmount = new Big(quote.toAmount); - - const alfredpayFee = AlfredpayApiService.sumFeesByCurrency( - quote.fees, - req.inputCurrency as unknown as AlfredpayFiatCurrency - ); - - ctx.alfredpayMint = { - currency: ctx.request.inputCurrency, - expirationDate: new Date(quote.expiration), - fee: alfredpayFee, - inputAmountDecimal: fromAmount, - inputAmountRaw: multiplyByPowerOfTen(fromAmount, 2).toFixed(0, 0), // Fiat uses 2 decimals - outputAmountDecimal: toAmount, - outputAmountRaw: multiplyByPowerOfTen(toAmount, usdTokenDecimals).toFixed(0, 0), - quoteId: quote.quoteId - }; - - ctx.addNote?.( - `Initialized: ${inputAmountDecimal.toString()} ${req.inputCurrency} -> ${toAmount.toString()} ${req.outputCurrency}` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts b/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts deleted file mode 100644 index 0bff9b50a..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/onramp-avenia.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - AveniaPaymentMethod, - BlockchainSendMethod, - BrlaApiService, - BrlaCurrency, - FiatToken, - getAnyFiatTokenDetailsMoonbeam, - multiplyByPowerOfTen, - Networks, - RampDirection -} from "@vortexfi/shared"; -import Big from "big.js"; -import { QuoteContext } from "../../core/types"; -import { assignMoonbeamToPendulumXcm, BaseInitializeEngine, buildXcmMeta } from "./index"; - -export class OnRampInitializeAveniaEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.BUY, - skipNote: "OnRampInitializeAveniaEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - const req = ctx.request; - - const brlaTokenDetails = getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL); - const inputAmountDecimal = new Big(req.inputAmount); - const inputAmountRaw = multiplyByPowerOfTen(inputAmountDecimal, brlaTokenDetails.decimals).toFixed(0, 0); - - const brlaApiService = BrlaApiService.getInstance(); - const aveniaPayInToInternalQuote = await brlaApiService.createPayInQuote( - { - inputAmount: inputAmountDecimal.toString(), - inputCurrency: BrlaCurrency.BRL, - inputPaymentMethod: AveniaPaymentMethod.PIX, - inputThirdParty: false, - outputCurrency: BrlaCurrency.BRLA, - outputPaymentMethod: AveniaPaymentMethod.INTERNAL, - outputThirdParty: false - }, - { useCache: true } - ); - - const aveniaTransferToMoonbeamQuote = await brlaApiService.createPayInQuote( - { - blockchainSendMethod: BlockchainSendMethod.PERMIT, - inputAmount: aveniaPayInToInternalQuote.outputAmount.toString(), - inputCurrency: BrlaCurrency.BRLA, - inputPaymentMethod: AveniaPaymentMethod.INTERNAL, - inputThirdParty: false, - outputCurrency: BrlaCurrency.BRLA, - outputPaymentMethod: AveniaPaymentMethod.MOONBEAM, - outputThirdParty: false - }, - { useCache: true } - ); - - // We add a small buffer for the gas fees - const gasFeePayIn = aveniaPayInToInternalQuote.appliedFees.find(fee => fee.type === "Gas Fee"); - const receivedBrlaDecimal = new Big(aveniaPayInToInternalQuote.outputAmount).minus(gasFeePayIn?.amount || 0); - const receivedBrlaRaw = multiplyByPowerOfTen(receivedBrlaDecimal, brlaTokenDetails.decimals).toFixed(0, 0); - - ctx.aveniaMint = { - currency: FiatToken.BRL, - fee: inputAmountDecimal.minus(aveniaPayInToInternalQuote.outputAmount), - inputAmountDecimal, - inputAmountRaw, - outputAmountDecimal: receivedBrlaDecimal, - outputAmountRaw: receivedBrlaRaw - }; - - const gasFeeMoonbeam = aveniaTransferToMoonbeamQuote.appliedFees.find(fee => fee.type === "Gas Fee"); - let gasFeeBuffer = new Big(0.1); // Default to 0.1 BRL if we can't find the gas fee - if (gasFeePayIn || gasFeeMoonbeam) { - const gasFeeAmount = new Big(gasFeePayIn?.amount || 0).plus(gasFeeMoonbeam?.amount || 0); - // We add a 50% buffer to the applied gas fee - gasFeeBuffer = gasFeeAmount.mul(0.5); - } - - // We received minted BRLA on the ephemeral account - const mintedBrlaDecimal = new Big(aveniaTransferToMoonbeamQuote.outputAmount).minus(gasFeeBuffer); - const mintedBrlaRaw = multiplyByPowerOfTen(mintedBrlaDecimal, brlaTokenDetails.decimals).toFixed(0, 0); - const transferFee = receivedBrlaDecimal.minus(mintedBrlaDecimal); - - ctx.aveniaTransfer = { - currency: FiatToken.BRL, - fee: transferFee, - inputAmountDecimal: ctx.aveniaMint.outputAmountDecimal, - inputAmountRaw: ctx.aveniaMint.outputAmountRaw, - outputAmountDecimal: mintedBrlaDecimal, - outputAmountRaw: mintedBrlaRaw - }; - - const xcmFees = buildXcmMeta(); - if (ctx.to === Networks.AssetHub) { - await assignMoonbeamToPendulumXcm(ctx, xcmFees, mintedBrlaDecimal, mintedBrlaRaw); - } - - ctx.addNote?.(`Assuming ${mintedBrlaDecimal.toFixed()} BRLA minted on ephemeral account`); - } -} diff --git a/apps/api/src/api/services/quote/engines/initialize/onramp-mykobo.ts b/apps/api/src/api/services/quote/engines/initialize/onramp-mykobo.ts deleted file mode 100644 index 89d645654..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/onramp-mykobo.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { EvmToken, FiatToken, getOnChainTokenDetails, multiplyByPowerOfTen, Networks, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { QuoteContext } from "../../core/types"; -import { resolveMykoboDepositFee } from "../mykobo-fee"; -import { BaseInitializeEngine } from "./index"; - -export class OnRampInitializeMykoboEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.BUY, - skipNote: "OnRampInitializeMykoboEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - const req = ctx.request; - - const eurcBaseDetails = getOnChainTokenDetails(Networks.Base, EvmToken.EURC); - if (!eurcBaseDetails) { - throw new Error("OnRampInitializeMykoboEngine: EURC token details not found for Base"); - } - - const inputAmountDecimal = new Big(req.inputAmount); - const inputAmountRaw = multiplyByPowerOfTen(inputAmountDecimal, eurcBaseDetails.decimals).toFixed(0, 0); - - const mykoboFeeTotal = await resolveMykoboDepositFee(inputAmountDecimal.toFixed(2, 0)); - const mykoboFeeDecimal = new Big(mykoboFeeTotal); - - const deliveredEurcDecimal = inputAmountDecimal.minus(mykoboFeeDecimal); - if (deliveredEurcDecimal.lte(0)) { - throw new Error( - `OnRampInitializeMykoboEngine: Mykobo deposit fee ${mykoboFeeDecimal.toFixed()} EUR is greater than or equal to input amount ${inputAmountDecimal.toFixed()} EUR` - ); - } - const deliveredEurcRaw = multiplyByPowerOfTen(deliveredEurcDecimal, eurcBaseDetails.decimals).toFixed(0, 0); - - ctx.mykoboMint = { - currency: FiatToken.EURC, - fee: mykoboFeeDecimal, - inputAmountDecimal, - inputAmountRaw, - outputAmountDecimal: deliveredEurcDecimal, - outputAmountRaw: deliveredEurcRaw - }; - - ctx.addNote?.( - `Assuming ${deliveredEurcDecimal.toFixed()} EURC delivered on Base ephemeral after ${mykoboFeeDecimal.toFixed()} EUR Mykobo deposit fee` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts deleted file mode 100644 index 2d45e593f..000000000 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {describe, expect, it, mock} from "bun:test"; -import {RampDirection} from "@vortexfi/shared"; -import Big from "big.js"; -import {QuoteContext} from "../../core/types"; -import {OffRampMergeSubsidyEvmEngine} from "./offramp-evm"; - -function createContext(nablaSwapEvm: QuoteContext["nablaSwapEvm"]): QuoteContext { - return { - addNote: mock(() => undefined), - nablaSwapEvm, - request: { - rampType: RampDirection.SELL - }, - subsidy: { - actualOutputAmountDecimal: new Big("100"), - actualOutputAmountRaw: "100000000", - adjustedDifference: new Big("0"), - adjustedTargetDiscount: 0, - expectedOutputAmountDecimal: new Big("110"), - expectedOutputAmountRaw: "110000000", - idealSubsidyAmountInOutputTokenDecimal: new Big("10"), - idealSubsidyAmountInOutputTokenRaw: "10000000", - partnerId: "partner-1", - subsidyAmountInOutputTokenDecimal: new Big("10"), - subsidyAmountInOutputTokenRaw: "10000000", - subsidyRate: new Big("0.1"), - targetOutputAmountDecimal: new Big("110"), - targetOutputAmountRaw: "110000000" - } - } as unknown as QuoteContext; -} - -describe("OffRampMergeSubsidyEvmEngine", () => { - it("preserves AMM-only output before writing the merged subsidized output", async () => { - const ctx = createContext({ - ammOutputAmountDecimal: new Big("100"), - ammOutputAmountRaw: "100000000", - inputAmountForSwapDecimal: "100", - inputAmountForSwapRaw: "100000000", - inputCurrency: "USDC", - inputDecimals: 6, - inputToken: "0xinput", - outputAmountDecimal: new Big("100"), - outputAmountRaw: "100000000", - outputCurrency: "BRLA", - outputDecimals: 6, - outputToken: "0xoutput" - } as QuoteContext["nablaSwapEvm"]); - - await new OffRampMergeSubsidyEvmEngine().execute(ctx); - - expect(ctx.nablaSwapEvm?.ammOutputAmountDecimal?.toFixed()).toBe("100"); - expect(ctx.nablaSwapEvm?.ammOutputAmountRaw).toBe("100000000"); - expect(ctx.nablaSwapEvm?.outputAmountDecimal.toFixed()).toBe("110"); - expect(ctx.nablaSwapEvm?.outputAmountRaw).toBe("110000000"); - }); - - it("does not change the AMM-only output when subsidy is merged again", async () => { - const ctx = createContext({ - ammOutputAmountDecimal: new Big("100"), - ammOutputAmountRaw: "100000000", - inputAmountForSwapDecimal: "100", - inputAmountForSwapRaw: "100000000", - inputCurrency: "USDC", - inputDecimals: 6, - inputToken: "0xinput", - outputAmountDecimal: new Big("110"), - outputAmountRaw: "110000000", - outputCurrency: "BRLA", - outputDecimals: 6, - outputToken: "0xoutput" - } as QuoteContext["nablaSwapEvm"]); - - await new OffRampMergeSubsidyEvmEngine().execute(ctx); - - expect(ctx.nablaSwapEvm?.ammOutputAmountDecimal?.toFixed()).toBe("100"); - expect(ctx.nablaSwapEvm?.ammOutputAmountRaw).toBe("100000000"); - expect(ctx.nablaSwapEvm?.outputAmountDecimal.toFixed()).toBe("120"); - expect(ctx.nablaSwapEvm?.outputAmountRaw).toBe("120000000"); - }); -}); diff --git a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts b/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts deleted file mode 100644 index 10c0317cd..000000000 --- a/apps/api/src/api/services/quote/engines/merge-subsidy/offramp-evm.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { RampDirection } from "@vortexfi/shared"; -import { QuoteContext, Stage, StageKey } from "../../core/types"; - -interface MergeSubsidyConfig { - direction: RampDirection; - skipNote: string; -} - -export class OffRampMergeSubsidyEvmEngine implements Stage { - readonly key = StageKey.MergeSubsidy; - - readonly config: MergeSubsidyConfig = { - direction: RampDirection.SELL, - skipNote: "OffRampMergeSubsidyEvmEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - async execute(ctx: QuoteContext): Promise { - const { direction, skipNote } = this.config; - - if (ctx.request.rampType !== direction) { - ctx.addNote?.(skipNote); - return; - } - - if (!ctx.nablaSwapEvm) { - throw new Error("OffRampMergeSubsidyEvmEngine requires nablaSwapEvm in context"); - } - - if (!ctx.subsidy) { - throw new Error("OffRampMergeSubsidyEvmEngine requires subsidy in context"); - } - - ctx.nablaSwapEvm = { - ...ctx.nablaSwapEvm, - outputAmountDecimal: ctx.nablaSwapEvm.outputAmountDecimal.plus(ctx.subsidy.subsidyAmountInOutputTokenDecimal), - outputAmountRaw: (BigInt(ctx.nablaSwapEvm.outputAmountRaw) + BigInt(ctx.subsidy.subsidyAmountInOutputTokenRaw)).toString() - }; - - ctx.addNote?.( - `OffRampMergeSubsidyEvmEngine: merged subsidy ${ctx.subsidy.subsidyAmountInOutputTokenDecimal.toFixed(6)} into nablaSwapEvm output` - ); - } -} 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 deleted file mode 100644 index 44ced4cf2..000000000 --- a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -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" -} as const; - -const mockedNetworks = { - Base: "base" -} as const; - -const mockedRampDirection = { - BUY: "BUY", - SELL: "SELL" -} as const; - -mock.module("@vortexfi/shared", () => ({ - ...sharedReal, - EvmToken: mockedEvmToken, - getOnChainTokenDetails: (_network: string, token: string) => ({ - assetSymbol: token, - decimals: 6, - erc20AddressSourceChain: token === mockedEvmToken.USDC ? "0xusdc" : "0xbrla", - isNative: false, - network: mockedNetworks.Base, - type: "evm" - }), - Networks: mockedNetworks, - RampDirection: mockedRampDirection -})); - -mock.module("../../core/nabla", () => ({ - calculateNablaSwapOutputEvm: mock(async () => ({ - effectiveExchangeRate: "0.99", - nablaOutputAmountDecimal: new Big("99"), - nablaOutputAmountRaw: "99000000" - })) -})); - -mock.module("../../../priceFeed.service", () => ({ - priceFeedService: { - getFiatToUsdExchangeRate: mock(async () => new Big("1")) - } -})); - -mock.module("../../../../../config/logger", () => ({ - default: { - warn: mock(() => undefined) - } -})); - -const {EvmToken, RampDirection} = await import("@vortexfi/shared"); -const {BaseNablaSwapEngineEvm} = await import("./base-evm"); - -class TestNablaSwapEngineEvm extends BaseNablaSwapEngineEvm { - readonly config = { - direction: RampDirection.SELL, - skipNote: "skip" - } as const; - - protected validate(): void {} - - protected compute() { - return { - inputAmountPreFees: new Big("100"), - inputToken: EvmToken.USDC, - outputToken: EvmToken.BRLA - }; - } -} - -describe("BaseNablaSwapEngineEvm", () => { - it("stores AMM-only output fields when assigning Nabla swap context", async () => { - const ctx = { - addNote: mock(() => undefined), - request: { - outputCurrency: "BRL", - rampType: RampDirection.SELL - } - } as unknown as QuoteContext; - - await new TestNablaSwapEngineEvm().execute(ctx); - - expect(ctx.nablaSwapEvm?.ammOutputAmountDecimal?.toFixed()).toBe("99"); - expect(ctx.nablaSwapEvm?.ammOutputAmountRaw).toBe("99000000"); - expect(ctx.nablaSwapEvm?.outputAmountDecimal.toFixed()).toBe("99"); - expect(ctx.nablaSwapEvm?.outputAmountRaw).toBe("99000000"); - }); -}); diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts deleted file mode 100644 index bd709f949..000000000 --- a/apps/api/src/api/services/quote/engines/nabla-swap/base-evm.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { EvmToken, EvmTokenDetails, getOnChainTokenDetails, Networks, RampDirection } from "@vortexfi/shared"; -import { Big } from "big.js"; -import { priceFeedService } from "../../../priceFeed.service"; -import { calculateNablaSwapOutputEvm } from "../../core/nabla"; -import { QuoteContext, Stage, StageKey } from "../../core/types"; - -export interface NablaSwapEvmConfig { - direction: RampDirection; - skipNote: string; -} - -export interface NablaSwapEvmComputation { - oraclePrice?: Big; - inputAmountPreFees: Big; - inputToken: EvmToken; - outputToken: EvmToken; -} - -export abstract class BaseNablaSwapEngineEvm implements Stage { - abstract readonly config: NablaSwapEvmConfig; - - readonly key = StageKey.NablaSwap; - - async execute(ctx: QuoteContext): Promise { - const { request } = ctx; - const { direction, skipNote } = this.config; - - if (request.rampType !== direction) { - ctx.addNote?.(skipNote); - return; - } - - this.validate(ctx); - - const { inputAmountPreFees, inputToken, outputToken } = this.compute(ctx); - - // Get token details for Base network - const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inputToken) as EvmTokenDetails; - const outputTokenDetails = getOnChainTokenDetails(Networks.Base, outputToken) as EvmTokenDetails; - - if (!inputTokenDetails || !outputTokenDetails) { - throw new Error("BaseNablaSwapEngineEvm: Could not find EVM token details for the requested tokens"); - } - - const deductibleFeeAmount = this.getDeductibleFeeAmount(ctx); - const inputAmountForSwap = inputAmountPreFees.minus(deductibleFeeAmount).toString(); - const inputAmountForSwapRaw = this.calculateInputAmountForSwapRaw(inputAmountForSwap, inputTokenDetails); - - const result = await calculateNablaSwapOutputEvm({ - inputAmountForSwap, - inputTokenDetails, - outputTokenDetails, - rampType: request.rampType - }); - - const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate( - request.rampType === RampDirection.BUY ? request.inputCurrency : request.outputCurrency - ); - - this.assignNablaSwapContext( - ctx, - result, - inputAmountForSwap, - inputAmountForSwapRaw, - inputToken, - outputToken, - inputTokenDetails, - outputTokenDetails, - oraclePrice - ); - - this.addNote(ctx, inputTokenDetails, outputTokenDetails, inputAmountForSwap, result); - } - - protected abstract validate(ctx: QuoteContext): void; - - protected abstract compute(ctx: QuoteContext): NablaSwapEvmComputation; - - protected getDeductibleFeeAmount(ctx: QuoteContext): Big { - if (ctx.request.rampType === RampDirection.SELL) { - return ctx.preNabla?.deductibleFeeAmountInSwapCurrency || new Big(0); - } else { - // For onramps, the fees are deducted after the nabla swap, so no deductible fee before the swap - return new Big(0); - } - } - - protected calculateInputAmountForSwapRaw(inputAmountForSwap: string, inputToken: EvmTokenDetails): string { - return new Big(inputAmountForSwap).times(new Big(10).pow(inputToken.decimals)).toFixed(0); - } - - private assignNablaSwapContext( - ctx: QuoteContext, - result: { effectiveExchangeRate?: string; nablaOutputAmountDecimal: Big; nablaOutputAmountRaw: string }, - inputAmountForSwapDecimal: string, - inputAmountForSwapRaw: string, - inputToken: EvmToken, - outputToken: EvmToken, - inputTokenDetails: EvmTokenDetails, - outputTokenDetails: EvmTokenDetails, - oraclePrice?: Big - ): void { - ctx.nablaSwapEvm = { - ...ctx.nablaSwapEvm, - ammOutputAmountDecimal: result.nablaOutputAmountDecimal, - ammOutputAmountRaw: result.nablaOutputAmountRaw, - effectiveExchangeRate: result.effectiveExchangeRate, - inputAmountForSwapDecimal, - inputAmountForSwapRaw, - inputCurrency: inputToken, - inputDecimals: inputTokenDetails.decimals, - inputToken: inputTokenDetails.erc20AddressSourceChain, - oraclePrice, - outputAmountDecimal: result.nablaOutputAmountDecimal, - outputAmountRaw: result.nablaOutputAmountRaw, - outputCurrency: outputToken, - outputDecimals: outputTokenDetails.decimals, - outputToken: outputTokenDetails.erc20AddressSourceChain - }; - } - - private addNote( - ctx: QuoteContext, - inputToken: EvmTokenDetails, - outputToken: EvmTokenDetails, - inputAmountForSwap: string, - result: { nablaOutputAmountDecimal: Big } - ): void { - ctx.addNote?.( - `Nabla swap from ${inputToken.assetSymbol} to ${outputToken.assetSymbol}, input amount ${inputAmountForSwap}, output amount ${result.nablaOutputAmountDecimal.toFixed()}` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/index.ts b/apps/api/src/api/services/quote/engines/nabla-swap/index.ts deleted file mode 100644 index 372eed801..000000000 --- a/apps/api/src/api/services/quote/engines/nabla-swap/index.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { PendulumTokenDetails, RampDirection } from "@vortexfi/shared"; -import { Big } from "big.js"; -import { priceFeedService } from "../../../priceFeed.service"; -import { calculateNablaSwapOutput } from "../../core/nabla"; -import { QuoteContext, Stage, StageKey } from "../../core/types"; - -export interface NablaSwapConfig { - direction: RampDirection; - skipNote: string; -} - -export interface NablaSwapComputation { - oraclePrice?: Big; - inputAmountPreFees: Big; - inputTokenPendulumDetails: PendulumTokenDetails; - outputTokenPendulumDetails: PendulumTokenDetails; -} - -export abstract class BaseNablaSwapEngine implements Stage { - abstract readonly config: NablaSwapConfig; - - readonly key = StageKey.NablaSwap; - - async execute(ctx: QuoteContext): Promise { - const { request } = ctx; - const { direction, skipNote } = this.config; - - if (request.rampType !== direction) { - ctx.addNote?.(skipNote); - return; - } - - this.validate(ctx); - - const { inputAmountPreFees, inputTokenPendulumDetails, outputTokenPendulumDetails } = this.compute(ctx); - - const deductibleFeeAmount = this.getDeductibleFeeAmount(ctx); - const inputAmountForSwap = inputAmountPreFees.minus(deductibleFeeAmount).toString(); - const inputAmountForSwapRaw = this.calculateInputAmountForSwapRaw(inputAmountForSwap, inputTokenPendulumDetails); - - const result = await calculateNablaSwapOutput({ - inputAmountForSwap, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - rampType: request.rampType - }); - - const oraclePrice = await priceFeedService.getFiatToUsdExchangeRate( - request.rampType === RampDirection.BUY ? request.inputCurrency : request.outputCurrency - ); - - this.assignNablaSwapContext( - ctx, - result, - inputAmountForSwap, - inputAmountForSwapRaw, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - oraclePrice - ); - - this.addNote(ctx, inputTokenPendulumDetails, outputTokenPendulumDetails, inputAmountForSwap, result); - } - - protected abstract validate(ctx: QuoteContext): void; - - protected abstract compute(ctx: QuoteContext): NablaSwapComputation; - - protected getDeductibleFeeAmount(ctx: QuoteContext): Big { - if (ctx.request.rampType === RampDirection.SELL) { - return ctx.preNabla?.deductibleFeeAmountInSwapCurrency || new Big(0); - } else { - // For onramps, the fees are deducted after the nabla swap, so no deductible fee before the swap - return new Big(0); - } - } - - protected calculateInputAmountForSwapRaw(inputAmountForSwap: string, inputToken: PendulumTokenDetails): string { - return new Big(inputAmountForSwap).times(new Big(10).pow(inputToken.decimals)).toFixed(0); - } - - private assignNablaSwapContext( - ctx: QuoteContext, - result: { effectiveExchangeRate?: string; nablaOutputAmountDecimal: Big; nablaOutputAmountRaw: string }, - inputAmountForSwapDecimal: string, - inputAmountForSwapRaw: string, - inputToken: PendulumTokenDetails, - outputToken: PendulumTokenDetails, - oraclePrice?: Big - ): void { - ctx.nablaSwap = { - ...ctx.nablaSwap, - effectiveExchangeRate: result.effectiveExchangeRate, - inputAmountForSwapDecimal, - inputAmountForSwapRaw, - inputCurrency: inputToken.currency, - inputCurrencyId: inputToken.currencyId, - inputDecimals: inputToken.decimals, - inputToken: inputToken.erc20WrapperAddress, - oraclePrice, - outputAmountDecimal: result.nablaOutputAmountDecimal, - outputAmountRaw: result.nablaOutputAmountRaw, - outputCurrency: outputToken.currency, - outputCurrencyId: outputToken.currencyId, - outputDecimals: outputToken.decimals, - outputToken: outputToken.erc20WrapperAddress - }; - } - - private addNote( - ctx: QuoteContext, - inputToken: PendulumTokenDetails, - outputToken: PendulumTokenDetails, - inputAmountForSwap: string, - result: { nablaOutputAmountDecimal: Big } - ): void { - ctx.addNote?.( - `Nabla swap from ${inputToken.currency} to ${outputToken.currency}, input amount ${inputAmountForSwap}, output amount ${result.nablaOutputAmountDecimal.toFixed()}` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts deleted file mode 100644 index 33825ff27..000000000 --- a/apps/api/src/api/services/quote/engines/nabla-swap/offramp-evm.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { EvmToken, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; - -export class OffRampSwapEngineEvm extends BaseNablaSwapEngineEvm { - readonly outputToken: EvmToken; - - constructor(outputToken: EvmToken) { - super(); - this.outputToken = outputToken; - } - - readonly config = { - direction: RampDirection.SELL, - skipNote: "OffRampSwapEngineEvm: Skipped because rampType is BUY, this engine handles SELL operations only" - } as const; - - protected validate(ctx: QuoteContext): void { - if (!ctx.preNabla?.deductibleFeeAmountInSwapCurrency) { - throw new Error( - "OffRampSwapEngineEvm: Missing deductibleFeeAmountInSwapCurrency in preNabla context - ensure initialize stage ran successfully" - ); - } - } - - protected compute(ctx: QuoteContext): NablaSwapEvmComputation { - const inputAmountPreFees = ctx.evmToEvm?.outputAmountDecimal; - if (!inputAmountPreFees) { - throw new Error( - "OffRampSwapEngineEvm: Missing input amount from previous stage - ensure initialize stage ran successfully" - ); - } - - // We receive USDC on Base. - const inputToken = EvmToken.USDC; - return { - inputAmountPreFees, - inputToken, - outputToken: this.outputToken - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/offramp.ts b/apps/api/src/api/services/quote/engines/nabla-swap/offramp.ts deleted file mode 100644 index 6fa4e1a3b..000000000 --- a/apps/api/src/api/services/quote/engines/nabla-swap/offramp.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { FiatToken, getPendulumDetails, Networks, PENDULUM_USDC_AXL, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { BaseNablaSwapEngine, NablaSwapComputation } from "./index"; - -export class OffRampSwapEngine extends BaseNablaSwapEngine { - readonly config = { - direction: RampDirection.SELL, - skipNote: "OffRampSwapEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - } as const; - - protected validate(ctx: QuoteContext): void { - if (!ctx.preNabla?.deductibleFeeAmountInSwapCurrency) { - throw new Error( - "OffRampSwapEngine: Missing deductibleFeeAmountInSwapCurrency in preNabla context - ensure initialize stage ran successfully" - ); - } - } - - protected compute(ctx: QuoteContext): NablaSwapComputation { - const { request } = ctx; - - const inputAmountPreFees = - request.from === "assethub" ? ctx.assethubToPendulumXcm?.outputAmountDecimal : ctx.evmToPendulum?.outputAmountDecimal; - if (!inputAmountPreFees) { - throw new Error("OffRampSwapEngine: Missing input amount from previous stage - ensure initialize stage ran successfully"); - } - - const inputTokenPendulumDetails = - request.from === "assethub" ? getPendulumDetails(request.inputCurrency, Networks.AssetHub) : PENDULUM_USDC_AXL; - const outputTokenPendulumDetails = getPendulumDetails(request.outputCurrency as FiatToken); - - return { - inputAmountPreFees, - inputTokenPendulumDetails, - outputTokenPendulumDetails - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts deleted file mode 100644 index 3b8436d29..000000000 --- a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { EvmToken, getOnChainTokenDetails, Networks, RampDirection } from "@vortexfi/shared"; -import { Big } from "big.js"; -import { QuoteContext } from "../../core/types"; -import { isBrlToBrlaBaseDirect } from "../../utils"; -import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; - -export class OnRampSwapEngineEvm extends BaseNablaSwapEngineEvm { - readonly config = { - direction: RampDirection.BUY, - skipNote: "OnRampSwapEngineEvm: Skipped because rampType is SELL, this engine handles BUY operations only" - } as const; - - protected validate(ctx: QuoteContext): void { - if (!ctx.fees?.usd) { - throw new Error("OnRampSwapEngineEvm: Fees in USD must be calculated first - ensure fee stage ran successfully"); - } - } - - async execute(ctx: QuoteContext): Promise { - if (ctx.request.rampType !== RampDirection.BUY) { - ctx.addNote?.(this.config.skipNote); - return; - } - - this.validate(ctx); - - if (isBrlToBrlaBaseDirect(ctx.request.inputCurrency, ctx.request.outputCurrency, ctx.request.to)) { - if (!ctx.aveniaTransfer) { - throw new Error( - "OnRampSwapEngineEvm: Missing aveniaTransfer quote data from previous stage - ensure initialize stage ran successfully" - ); - } - const inputAmountPreFees = ctx.aveniaTransfer.outputAmountDecimal; - const brlaTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.BRLA); - if (!brlaTokenDetails || brlaTokenDetails.type !== "evm") { - throw new Error("OnRampSwapEngineEvm: BRLA token details not found for Base"); - } - - const inputAmountForSwapRaw = inputAmountPreFees.times(new Big(10).pow(brlaTokenDetails.decimals)).toFixed(0, 0); - ctx.nablaSwapEvm = { - ammOutputAmountDecimal: inputAmountPreFees, - ammOutputAmountRaw: inputAmountForSwapRaw, - effectiveExchangeRate: "1", - inputAmountForSwapDecimal: inputAmountPreFees.toString(), - inputAmountForSwapRaw, - inputCurrency: EvmToken.BRLA, - inputDecimals: brlaTokenDetails.decimals, - inputToken: brlaTokenDetails.erc20AddressSourceChain, - outputAmountDecimal: inputAmountPreFees, - outputAmountRaw: inputAmountForSwapRaw, - outputCurrency: EvmToken.BRLA, - outputDecimals: brlaTokenDetails.decimals, - outputToken: brlaTokenDetails.erc20AddressSourceChain - }; - ctx.addNote?.(`Nabla swap bypassed for BRL→BRLA on Base, passthrough amount ${inputAmountPreFees.toFixed()} BRLA (1:1)`); - return; - } - - await super.execute(ctx); - } - - protected compute(ctx: QuoteContext): NablaSwapEvmComputation { - if (!ctx.aveniaTransfer) { - throw new Error( - "OnRampSwapEngineEvm: Missing aveniaTransfer quote data from previous stage - ensure initialize stage ran successfully" - ); - } - - const inputAmountPreFees = ctx.aveniaTransfer.outputAmountDecimal; - - // For Onramp EVM, the input token for Nabla is the output of Avenia transfer (BRLA on Base) - // The output token is fixed at USDC. - const inputToken = EvmToken.BRLA; - const outputToken = EvmToken.USDC; - - return { - inputAmountPreFees, - inputToken, - outputToken - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts deleted file mode 100644 index 9218533b5..000000000 --- a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { EvmToken, getOnChainTokenDetails, Networks, RampDirection } from "@vortexfi/shared"; -import { Big } from "big.js"; -import { QuoteContext } from "../../core/types"; -import { isEurToEurcBaseDirect } from "../../utils"; -import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; - -export class OnRampSwapEngineMykoboEvm extends BaseNablaSwapEngineEvm { - readonly config = { - direction: RampDirection.BUY, - skipNote: "OnRampSwapEngineMykoboEvm: Skipped because rampType is SELL, this engine handles BUY operations only" - } as const; - - protected validate(ctx: QuoteContext): void { - if (!ctx.fees?.usd) { - throw new Error("OnRampSwapEngineMykoboEvm: Fees in USD must be calculated first - ensure fee stage ran successfully"); - } - if (!ctx.mykoboMint) { - throw new Error( - "OnRampSwapEngineMykoboEvm: Missing mykoboMint quote data from previous stage - ensure initialize stage ran successfully" - ); - } - } - - async execute(ctx: QuoteContext): Promise { - if (ctx.request.rampType !== RampDirection.BUY) { - ctx.addNote?.(this.config.skipNote); - return; - } - - this.validate(ctx); - - if (isEurToEurcBaseDirect(ctx.request.inputCurrency, ctx.request.outputCurrency, ctx.request.to)) { - // biome-ignore lint/style/noNonNullAssertion: validated above - const inputAmountPreFees = ctx.mykoboMint!.outputAmountDecimal; - const eurcTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.EURC); - if (!eurcTokenDetails || eurcTokenDetails.type !== "evm") { - throw new Error("OnRampSwapEngineMykoboEvm: EURC token details not found for Base"); - } - - const inputAmountForSwapRaw = inputAmountPreFees.times(new Big(10).pow(eurcTokenDetails.decimals)).toFixed(0, 0); - ctx.nablaSwapEvm = { - ammOutputAmountDecimal: inputAmountPreFees, - ammOutputAmountRaw: inputAmountForSwapRaw, - effectiveExchangeRate: "1", - inputAmountForSwapDecimal: inputAmountPreFees.toString(), - inputAmountForSwapRaw, - inputCurrency: EvmToken.EURC, - inputDecimals: eurcTokenDetails.decimals, - inputToken: eurcTokenDetails.erc20AddressSourceChain, - outputAmountDecimal: inputAmountPreFees, - outputAmountRaw: inputAmountForSwapRaw, - outputCurrency: EvmToken.EURC, - outputDecimals: eurcTokenDetails.decimals, - outputToken: eurcTokenDetails.erc20AddressSourceChain - }; - ctx.addNote?.(`Nabla swap bypassed for EUR→EURC on Base, passthrough amount ${inputAmountPreFees.toFixed()} EURC (1:1)`); - return; - } - - await super.execute(ctx); - } - - protected compute(ctx: QuoteContext): NablaSwapEvmComputation { - // biome-ignore lint/style/noNonNullAssertion: validated above - const inputAmountPreFees = ctx.mykoboMint!.outputAmountDecimal; - - return { - inputAmountPreFees, - inputToken: EvmToken.EURC, - outputToken: EvmToken.USDC - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp.ts deleted file mode 100644 index db434d657..000000000 --- a/apps/api/src/api/services/quote/engines/nabla-swap/onramp.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { AssetHubToken, FiatToken, getPendulumDetails, Networks, PENDULUM_USDC_AXL, RampDirection } from "@vortexfi/shared"; -import { Big } from "big.js"; -import { QuoteContext } from "../../core/types"; -import { BaseNablaSwapEngine, NablaSwapComputation } from "./index"; - -export class OnRampSwapEngine extends BaseNablaSwapEngine { - readonly config = { - direction: RampDirection.BUY, - skipNote: "OnRampSwapEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - } as const; - - protected validate(ctx: QuoteContext): void { - if (!ctx.fees?.usd) { - throw new Error("OnRampSwapEngine: Fees in USD must be calculated first - ensure fee stage ran successfully"); - } - } - - protected compute(ctx: QuoteContext): NablaSwapComputation { - const { request } = ctx; - - let amountReceivedOnPendulum: Big; - if (ctx.evmToMoonbeam) { - // Amount received on Pendulum via Squidrouter postcall hook - amountReceivedOnPendulum = ctx.evmToMoonbeam.outputAmountDecimal; - } else if (ctx.moonbeamToPendulumXcm) { - amountReceivedOnPendulum = ctx.moonbeamToPendulumXcm.outputAmountDecimal; - } else { - throw new Error( - "OnRampSwapEngine: Missing evmToMoonbeam or moonbeamToPendulumXcm quote data from previous stage - ensure initialize stage ran successfully" - ); - } - - const inputTokenPendulumDetails = request.from === "pix" ? getPendulumDetails(FiatToken.BRL) : PENDULUM_USDC_AXL; - const outputTokenPendulumDetails = - request.to === "assethub" ? getPendulumDetails(AssetHubToken.USDC, Networks.AssetHub) : PENDULUM_USDC_AXL; - - return { - inputAmountPreFees: amountReceivedOnPendulum, - inputTokenPendulumDetails, - outputTokenPendulumDetails - }; - } -} 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 deleted file mode 100644 index 07fa95034..000000000 --- a/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - ALFREDPAY_ERC20_DECIMALS, - ALFREDPAY_ONCHAIN_CURRENCY, - AlfredpayApiService, - AlfredpayChain, - AlfredpayFiatCurrency, - AlfredpayPaymentMethodType, - CreateAlfredpayOfframpQuoteRequest, - multiplyByPowerOfTen, - RampCurrency, - RampDirection -} 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"; - -export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.SELL, - skipNote: "OfframpTransactionAlfredpayEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - const req = ctx.request; - - if (!ctx.evmToEvm) { - throw new Error("OfframpTransactionAlfredpayEngine: No evmToEvm quote"); - } - - if (!ctx.subsidy) { - throw new Error("OfframpTransactionAlfredpayEngine: Missing ctx.subsidy (Discount stage must run first)"); - } - - // Use the same oracle rate as Discount to back-solve the subsidized USD input. - const oneUnitInFiat = await priceFeedService.convertCurrency( - "1", - ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency, - req.outputCurrency as RampCurrency - ); - const effectiveRate = new Big(oneUnitInFiat); - - const deductibleFee = ctx.preNabla?.deductibleFeeAmountInSwapCurrency ?? new Big(0); - const inputAmountDecimal = effectiveRate.gt(0) - ? 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, - fromAmount: inputAmountDecimal.toString(), - fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - metadata: { - businessId: "vortex", - customerId - }, - paymentMethodType: AlfredpayPaymentMethodType.BANK, - toCurrency: req.outputCurrency as unknown as AlfredpayFiatCurrency - }; - - const quote = await alfredpayService.createOfframpQuote(quoteRequest); - - const toAmount = new Big(quote.toAmount); - const alfredpayFee = AlfredpayApiService.sumFeesByCurrency( - quote.fees, - req.outputCurrency as unknown as AlfredpayFiatCurrency - ); - - ctx.alfredpayOfframp = { - currency: req.outputCurrency, - expirationDate: new Date(quote.expiration), - fee: alfredpayFee, - inputAmountDecimal, - inputAmountRaw: multiplyByPowerOfTen(inputAmountDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), - outputAmountDecimal: toAmount, - outputAmountRaw: multiplyByPowerOfTen(toAmount, 2).toFixed(0, 0), - quoteId: quote.quoteId - }; - - ctx.addNote?.( - `OfframpTransactionAlfredpayEngine: ${inputAmountDecimal.toString()} ${ALFREDPAY_ONCHAIN_CURRENCY} -> ${toAmount.toString()} ${req.outputCurrency} (fee ${alfredpayFee.toString()}, rate ${effectiveRate.toString()})` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/pendulum-transfers/index.ts b/apps/api/src/api/services/quote/engines/pendulum-transfers/index.ts deleted file mode 100644 index 1e1723f26..000000000 --- a/apps/api/src/api/services/quote/engines/pendulum-transfers/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { QuoteContext, Stage, StageKey, XcmMeta } from "../../core/types"; - -export interface PendulumTransferConfig { - direction: RampDirection; - skipNote: string; -} - -export interface PendulumTransferComputation { - type: "xcm"; - data: XcmMeta; -} - -export abstract class BasePendulumTransferEngine implements Stage { - abstract readonly config: PendulumTransferConfig; - - readonly key = StageKey.PendulumTransfer; - - async execute(ctx: QuoteContext): Promise { - const { request } = ctx; - const { direction, skipNote } = this.config; - - if (request.rampType !== direction) { - ctx.addNote?.(skipNote); - return; - } - - this.validate(ctx); - - const computation = await this.compute(ctx); - - this.assign(ctx, computation); - - this.addNote(ctx, computation); - } - - protected abstract validate(ctx: QuoteContext): void; - - protected abstract compute(ctx: QuoteContext): Promise; - - protected abstract assign(ctx: QuoteContext, computation: PendulumTransferComputation): void; - - protected createXcmFees(ctx: QuoteContext): { - origin: { amount: string; amountRaw: string; currency: string }; - destination: { amount: string; amountRaw: string; currency: string }; - } { - // We currently can't really estimate XCM fees on Pendulum because we don't have the dry-run API available. - return { - destination: { - amount: "0.01", - amountRaw: "10000", - currency: "USDC" - }, - origin: { - amount: "0.01", - amountRaw: "10000", - currency: "USDC" - } - }; - } - - protected mergeSubsidy(ctx: QuoteContext, outputAmountDecimal: Big): Big { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - return outputAmountDecimal.plus(ctx.subsidy!.subsidyAmountInOutputTokenDecimal); - } - - protected mergeSubsidyRaw(ctx: QuoteContext, outputAmountRaw: Big): Big { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - return outputAmountRaw.plus(ctx.subsidy!.subsidyAmountInOutputTokenRaw); - } - - private addNote(ctx: QuoteContext, computation: PendulumTransferComputation): void { - const xcmData = computation.data; - ctx.addNote?.( - `Calculated XCM transfer with ${xcmData.xcmFees.origin.amount} ${xcmData.xcmFees.origin.currency} origin fee and ${xcmData.xcmFees.destination.amount} ${xcmData.xcmFees.destination.currency} destination fee` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/pendulum-transfers/offramp-avenia.ts b/apps/api/src/api/services/quote/engines/pendulum-transfers/offramp-avenia.ts deleted file mode 100644 index 9ca2a0c4d..000000000 --- a/apps/api/src/api/services/quote/engines/pendulum-transfers/offramp-avenia.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { QuoteContext, XcmMeta } from "../../core/types"; -import { BasePendulumTransferEngine, PendulumTransferComputation, PendulumTransferConfig } from "./index"; - -export class OffRampToAveniaPendulumTransferEngine extends BasePendulumTransferEngine { - readonly config: PendulumTransferConfig = { - direction: RampDirection.SELL, - skipNote: "OffRampToAveniaPendulumTransferEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - protected validate(ctx: QuoteContext): void { - if (!ctx.nablaSwap) { - throw new Error( - "OffRampToAveniaPendulumTransferEngine: Missing nablaSwap in context - ensure nabla-swap stage ran successfully" - ); - } - - if (!ctx.subsidy) { - throw new Error( - "OffRampToAveniaPendulumTransferEngine: Missing subsidy in context - ensure subsidy calculation ran successfully" - ); - } - } - - protected async compute(ctx: QuoteContext): Promise { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const nablaSwap = ctx.nablaSwap!; - - const xcmFees = this.createXcmFees(ctx); - - // We don't need to deduct the XCM fees from the output amount because the fees are not paid in the token - // being transferred but in GLMR - const inputAmountDecimal = this.mergeSubsidy(ctx, new Big(nablaSwap.outputAmountDecimal)); - const inputAmountRaw = multiplyByPowerOfTen(inputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - const xcmMeta: XcmMeta = { - fromToken: nablaSwap.outputCurrency, - inputAmountDecimal, - inputAmountRaw, - // The fees are not paid in the token being transferred, so amountOut = amountIn - outputAmountDecimal: inputAmountDecimal, - outputAmountRaw: inputAmountRaw, - toToken: nablaSwap.outputCurrency, - xcmFees - }; - - return { - data: xcmMeta, - type: "xcm" - }; - } - - protected assign(ctx: QuoteContext, computation: PendulumTransferComputation): void { - ctx.pendulumToMoonbeamXcm = computation.data as XcmMeta; - } -} diff --git a/apps/api/src/api/services/quote/engines/pendulum-transfers/onramp.ts b/apps/api/src/api/services/quote/engines/pendulum-transfers/onramp.ts deleted file mode 100644 index 6fe551c9a..000000000 --- a/apps/api/src/api/services/quote/engines/pendulum-transfers/onramp.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { AssetHubToken, EvmToken, multiplyByPowerOfTen, Networks, RampCurrency, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { priceFeedService } from "../../../priceFeed.service"; -import { QuoteContext, XcmMeta } from "../../core/types"; -import { BasePendulumTransferEngine, PendulumTransferComputation, PendulumTransferConfig } from "./index"; - -export class OnRampPendulumTransferEngine extends BasePendulumTransferEngine { - readonly config: PendulumTransferConfig = { - direction: RampDirection.BUY, - skipNote: "OnRampPendulumTransferEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - private price = priceFeedService; - - protected validate(ctx: QuoteContext): void { - if (!ctx.nablaSwap) { - throw new Error("OnRampPendulumTransferEngine: Missing nablaSwap in context - ensure nabla-swap stage ran successfully"); - } - - if (!ctx.subsidy) { - throw new Error("OnRampPendulumTransferEngine: Missing subsidy in context - ensure subsidy calculation ran successfully"); - } - - if (!ctx.fees?.usd || !ctx.fees?.displayFiat) { - throw new Error("OnRampPendulumTransferEngine: Missing fees in context - ensure fee calculation ran successfully"); - } - } - - protected async compute(ctx: QuoteContext): Promise { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const nablaSwap = ctx.nablaSwap!; - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const usdFees = ctx.fees!.usd!; - const req = ctx.request; - - const hydrationDestinationFee = { - amount: "0.15", - amountRaw: "150000", - currency: "USDC" - }; - - const assethubDestinationFee = { - amount: "0.018", - amountRaw: "18000", - currency: "USDC" - }; - - const moonbeamDestinationFee = { - amount: "0.15", - amountRaw: "150000", - currency: "USDC" - }; - - // We currently can't really estimate XCM fees on Pendulum because we don't have the dry-run API available. - const xcmFees = { - destination: - req.to === Networks.AssetHub - ? req.outputCurrency !== AssetHubToken.USDC - ? hydrationDestinationFee - : assethubDestinationFee - : moonbeamDestinationFee, - origin: { - amount: "0.01", - amountRaw: "10000", - currency: "USDC" - } - }; - - // Deduce fees distributed after Nabla swap and before transfer to next destination - // Onramps always have a USD-stablecoin as output, so we can use the USD fee structure - const usdFeesDistributedDecimal = Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup); - const usdFeesDistributedRaw = multiplyByPowerOfTen(usdFeesDistributedDecimal, nablaSwap.outputDecimals); - - const inputAmountDecimal = this.mergeSubsidy(ctx, new Big(nablaSwap.outputAmountDecimal)).minus(usdFeesDistributedDecimal); - const inputAmountRaw = this.mergeSubsidyRaw(ctx, new Big(nablaSwap.outputAmountRaw)) - .minus(usdFeesDistributedRaw) - .toFixed(0, 0); - - let outputAmountDecimal = inputAmountDecimal; - if (req.to === Networks.AssetHub) { - // Only the Hydration and Assethub transfer needs to deduct the fees like this. - // For Moonbeam, the fee is either paid in GLMR - outputAmountDecimal = await this.adjustFeesForAssetHub(ctx, outputAmountDecimal, xcmFees); - } - const outputAmountRaw = multiplyByPowerOfTen(outputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - - const xcmMeta: XcmMeta = { - fromToken: nablaSwap.outputCurrency, - inputAmountDecimal, - inputAmountRaw, - outputAmountDecimal, - outputAmountRaw, - toToken: nablaSwap.outputCurrency, - xcmFees - }; - - return { - data: xcmMeta, - type: "xcm" - }; - } - - protected assign(ctx: QuoteContext, computation: PendulumTransferComputation): void { - const req = ctx.request; - const xcmMeta = computation.data as XcmMeta; - - if (req.to === "assethub") { - if (req.outputCurrency !== AssetHubToken.USDC) { - // Transfer to Hydration first for non-USDC AssetHub tokens - ctx.pendulumToHydrationXcm = xcmMeta; - } else { - // Direct transfer from Pendulum to AssetHub - ctx.pendulumToAssethubXcm = xcmMeta; - } - } else { - // Transfer from Pendulum to Moonbeam - ctx.pendulumToMoonbeamXcm = xcmMeta; - } - } - - private async adjustFeesForAssetHub( - ctx: QuoteContext, - outputAmountDecimal: Big, - xcmFees: { origin: { amount: string; currency: string }; destination: { amount: string; currency: string } } - ): Promise { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const fiatFees = ctx.fees!.displayFiat!; - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const usdFees = ctx.fees!.usd!; - - const originFeeUsd = await this.price.convertCurrency( - xcmFees.origin.amount, - xcmFees.origin.currency as RampCurrency, - EvmToken.USDC - ); - const destinationFeeUsd = await this.price.convertCurrency( - xcmFees.destination.amount, - xcmFees.destination.currency as RampCurrency, - EvmToken.USDC - ); - - const originFeeDisplayFiat = await this.price.convertCurrency( - xcmFees.origin.amount, - xcmFees.origin.currency as RampCurrency, - fiatFees.currency as RampCurrency - ); - const destinationFeeDisplayFiat = await this.price.convertCurrency( - xcmFees.destination.amount, - xcmFees.destination.currency as RampCurrency, - fiatFees.currency as RampCurrency - ); - - // Adjust network fee in ctx - const extraFeeUsd = Big(originFeeUsd).plus(destinationFeeUsd); - const extraFeeFiat = Big(originFeeDisplayFiat).plus(destinationFeeDisplayFiat); - usdFees.network = Big(usdFees.network).plus(extraFeeUsd).toString(); - usdFees.total = Big(usdFees.total).plus(extraFeeUsd).toFixed(2); - fiatFees.network = Big(fiatFees.network).plus(extraFeeFiat).toString(); - fiatFees.total = Big(fiatFees.total).plus(extraFeeFiat).toFixed(2); - - outputAmountDecimal = outputAmountDecimal.minus(originFeeUsd).minus(destinationFeeUsd); - return outputAmountDecimal; - } -} 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 deleted file mode 100644 index 11f658837..000000000 --- a/apps/api/src/api/services/quote/engines/squidrouter/index.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -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"), - finalGrossOutputAmountRaw: BSC_USDT_OUTPUT_RAW, - networkFeeUSD: "0.061741", - outputTokenDecimals: 18 - })) -})); - -const { BaseSquidRouterEngine } = await import("./index"); - -class TestSquidRouterEngine extends BaseSquidRouterEngine { - readonly config = { - direction: RampDirection.BUY, - skipNote: "skip" - } as const; - - protected validate(): void {} - - protected compute() { - return { - data: { - amountRaw: "4817744605", - fromNetwork: Networks.Base, - fromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const, - inputAmountDecimal: new Big("4817.744605"), - inputAmountRaw: "4817744605", - outputDecimals: 6, - toNetwork: Networks.BSC, - toToken: "0x55d398326f99059fF775485246999027B3197955" as const - }, - type: "evm-to-evm" as const - }; - } -} - -describe("BaseSquidRouterEngine", () => { - it("stores Squid destination raw output instead of rebuilding it with source decimals", async () => { - const ctx = { - addNote: mock(() => undefined), - request: { - outputCurrency: EvmToken.USDT, - rampType: RampDirection.BUY - } - } as unknown as QuoteContext; - - await new TestSquidRouterEngine().execute(ctx); - - expect(ctx.evmToEvm?.outputAmountDecimal.toFixed()).toBe("4817.805726163073314321"); - expect(ctx.evmToEvm?.outputAmountRaw).toBe(BSC_USDT_OUTPUT_RAW); - }); -}); diff --git a/apps/api/src/api/services/quote/engines/squidrouter/index.ts b/apps/api/src/api/services/quote/engines/squidrouter/index.ts deleted file mode 100644 index c6885283e..000000000 --- a/apps/api/src/api/services/quote/engines/squidrouter/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { CreateQuoteRequest, Networks, RampDirection } from "@vortexfi/shared"; -import { Big } from "big.js"; -import { calculateEvmBridgeAndNetworkFee, EvmBridgeRequest, EvmBridgeResult } from "../../core/squidrouter"; -import { QuoteContext, Stage, StageKey } from "../../core/types"; - -export interface SquidRouterConfig { - direction: RampDirection; - skipNote: string; -} - -export interface SquidRouterComputation { - type: "moonbeam-to-evm" | "evm-to-evm" | "evm-to-moonbeam"; - data: SquidRouterData; -} - -export interface SquidRouterData { - amountRaw: string; - fromNetwork: Networks; - fromToken: `0x${string}`; - toNetwork: Networks; - toToken: `0x${string}`; - inputAmountDecimal: Big; - inputAmountRaw: string; - outputDecimals: number; - skipRouteCalculation?: boolean; -} - -export abstract class BaseSquidRouterEngine implements Stage { - abstract readonly config: SquidRouterConfig; - - readonly key = StageKey.SquidRouter; - - async execute(ctx: QuoteContext): Promise { - const { request } = ctx; - const { direction, skipNote } = this.config; - - if (request.rampType !== direction) { - ctx.addNote?.(skipNote); - return; - } - - this.validate(ctx); - - const computation = this.compute(ctx); - - if (computation.data.skipRouteCalculation) { - // Same-chain same-token passthrough: no Squid route is fetched, but downstream stages - // (finalize, discount) require the corresponding bridge meta to be set. Mirror the - // input as the output so the meta represents a 1:1 passthrough bridge. - const passthroughResult: EvmBridgeResult = { - finalEffectiveExchangeRate: "1", - finalGrossOutputAmountDecimal: computation.data.inputAmountDecimal, - finalGrossOutputAmountRaw: computation.data.inputAmountRaw, - networkFeeUSD: "0", - outputTokenDecimals: computation.data.outputDecimals - }; - this.assignContext(computation.type, ctx, passthroughResult, computation.data); - this.addNote(computation.type, ctx, passthroughResult, computation.data); - return; - } - - const bridgeRequest = this.buildBridgeRequest(computation.data, request); - - const bridgeResult = await this.calculateBridge(bridgeRequest); - - this.assignContext(computation.type, ctx, bridgeResult, computation.data); - - this.addNote(computation.type, ctx, bridgeResult, computation.data); - } - - protected abstract validate(ctx: QuoteContext): void; - - protected abstract compute(ctx: QuoteContext): SquidRouterComputation; - - protected mergeSubsidy(ctx: QuoteContext, outputAmountDecimal: Big): Big { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - return outputAmountDecimal.plus(ctx.subsidy!.subsidyAmountInOutputTokenDecimal); - } - - protected mergeSubsidyRaw(ctx: QuoteContext, outputAmountRaw: Big): Big { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - return outputAmountRaw.plus(ctx.subsidy!.subsidyAmountInOutputTokenRaw); - } - - private buildBridgeRequest(data: SquidRouterData, req: CreateQuoteRequest): EvmBridgeRequest { - return { - amountRaw: data.amountRaw, - fromNetwork: data.fromNetwork, - fromToken: data.fromToken, - originalInputAmountForRateCalc: data.inputAmountRaw, - rampType: req.rampType, - toNetwork: data.toNetwork, - toToken: data.toToken - }; - } - - private async calculateBridge(bridgeRequest: EvmBridgeRequest): Promise { - return calculateEvmBridgeAndNetworkFee(bridgeRequest); - } - private assignContext( - type: SquidRouterComputation["type"], - ctx: QuoteContext, - bridgeResult: EvmBridgeResult, - data: SquidRouterData - ): void { - const baseMeta = { - effectiveExchangeRate: bridgeResult.finalEffectiveExchangeRate, - fromNetwork: data.fromNetwork, - fromToken: data.fromToken, - inputAmountDecimal: data.inputAmountDecimal, - inputAmountRaw: data.inputAmountRaw, - networkFeeUSD: bridgeResult.networkFeeUSD, - outputAmountDecimal: bridgeResult.finalGrossOutputAmountDecimal, - outputAmountRaw: bridgeResult.finalGrossOutputAmountRaw, - toNetwork: data.toNetwork, - toToken: data.toToken - }; - - if (type === "moonbeam-to-evm") { - ctx.moonbeamToEvm = baseMeta; - } else if (type === "evm-to-evm") { - ctx.evmToEvm = baseMeta; - } else if (type === "evm-to-moonbeam") { - ctx.evmToMoonbeam = baseMeta; - } - } - - private addNote( - type: SquidRouterComputation["type"], - ctx: QuoteContext, - bridgeResult: EvmBridgeResult, - data: SquidRouterData - ): void { - const outputCurrency = ctx.request.outputCurrency; - const toNetwork = data.toNetwork; - const outputAmount = bridgeResult.finalGrossOutputAmountDecimal.toFixed(); - - ctx.addNote?.(`${type}: output=${outputAmount} ${outputCurrency} on ${toNetwork}`); - } -} diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts deleted file mode 100644 index cd5e2af8a..000000000 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - EvmToken, - getNetworkFromDestination, - getOnChainTokenDetails, - multiplyByPowerOfTen, - Networks, - OnChainToken, - RampDirection -} from "@vortexfi/shared"; -import Big from "big.js"; -import httpStatus from "http-status"; -import { APIError } from "../../../../errors/api-error"; -import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../utils"; -import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig } from "./index"; - -export class OnRampSquidRouterToBaseEngine extends BaseSquidRouterEngine { - readonly config: SquidRouterConfig = { - direction: RampDirection.BUY, - skipNote: "OnRampSquidRouterBrlToEvmEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - protected validate(ctx: QuoteContext): void { - if (ctx.request.to === "assethub") { - throw new Error( - "OnRampSquidRouterBrlToEvmEngine: Skipped because destination is assethub, this engine handles EVM destinations only" - ); - } - - if (!ctx.nablaSwapEvm) { - throw new Error( - "OnRampSquidRouterBrlToEvmEngine: Missing nablaSwapEvm.outputAmountDecimal in context - ensure initialize stage ran successfully" - ); - } - - if (!ctx.fees?.usd || !ctx.fees?.displayFiat) { - throw new Error("OnRampPendulumTransferEngine: Missing fees in context - ensure fee calculation ran successfully"); - } - } - - protected compute(ctx: QuoteContext): SquidRouterComputation { - const req = ctx.request; - - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const usdFees = ctx.fees!.usd!; - - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const nablaSwap = ctx.nablaSwapEvm!; - - if (isEurToEurcBaseDirect(ctx.request.inputCurrency, ctx.request.outputCurrency, ctx.request.to)) { - const eurcBaseTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.EURC); - if (!eurcBaseTokenDetails || eurcBaseTokenDetails.type !== "evm") { - throw new Error("OnRampSquidRouterToBaseEngine: EURC Base token details not found"); - } - - const inputAmountDecimal = this.mergeSubsidy(ctx, new Big(nablaSwap.outputAmountDecimal)); - const inputAmountRaw = this.mergeSubsidyRaw(ctx, new Big(nablaSwap.outputAmountRaw)).toFixed(0, 0); - - return { - data: { - amountRaw: inputAmountRaw, - fromNetwork: Networks.Base, - fromToken: eurcBaseTokenDetails.erc20AddressSourceChain, - inputAmountDecimal, - inputAmountRaw, - outputDecimals: eurcBaseTokenDetails.decimals, - skipRouteCalculation: true, - toNetwork: Networks.Base, - toToken: eurcBaseTokenDetails.erc20AddressSourceChain - }, - type: "evm-to-evm" - }; - } - - if (isBrlToBrlaBaseDirect(ctx.request.inputCurrency, ctx.request.outputCurrency, ctx.request.to)) { - const brlaBaseTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.BRLA); - if (!brlaBaseTokenDetails || brlaBaseTokenDetails.type !== "evm") { - throw new Error("OnRampSquidRouterToBaseEngine: BRLA Base token details not found"); - } - - const inputAmountDecimal = this.mergeSubsidy(ctx, new Big(nablaSwap.outputAmountDecimal)); - const inputAmountRaw = this.mergeSubsidyRaw(ctx, new Big(nablaSwap.outputAmountRaw)).toFixed(0, 0); - - return { - data: { - amountRaw: inputAmountRaw, - fromNetwork: Networks.Base, - fromToken: brlaBaseTokenDetails.erc20AddressSourceChain, - inputAmountDecimal, - inputAmountRaw, - outputDecimals: brlaBaseTokenDetails.decimals, - skipRouteCalculation: true, - toNetwork: Networks.Base, - toToken: brlaBaseTokenDetails.erc20AddressSourceChain - }, - type: "evm-to-evm" - }; - } - - const usdFeesDistributedDecimal = Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup); - const usdFeesDistributedRaw = multiplyByPowerOfTen(usdFeesDistributedDecimal, nablaSwap.outputDecimals); - - const inputAmountDecimal = this.mergeSubsidy(ctx, new Big(nablaSwap.outputAmountDecimal)).minus(usdFeesDistributedDecimal); - const inputAmountRaw = this.mergeSubsidyRaw(ctx, new Big(nablaSwap.outputAmountRaw)) - .minus(usdFeesDistributedRaw) - .toFixed(0, 0); - - const usdcBaseTokenDetails = getTokenDetailsForEvmDestination(EvmToken.USDC, Networks.Base); - - // Trivial case: nabla output (USDC on Base) is already the requested output. Skip the Squid - // route fetch but still emit bridge meta so downstream stages have a 1:1 passthrough record. - if (ctx.to === Networks.Base && ctx.request.outputCurrency === EvmToken.USDC) { - return { - data: { - amountRaw: inputAmountRaw, - fromNetwork: Networks.Base, - fromToken: usdcBaseTokenDetails.erc20AddressSourceChain, - inputAmountDecimal, - inputAmountRaw, - outputDecimals: usdcBaseTokenDetails.decimals, - skipRouteCalculation: true, - toNetwork: Networks.Base, - toToken: usdcBaseTokenDetails.erc20AddressSourceChain - }, - type: "evm-to-evm" - }; - } - - const toNetwork = getNetworkFromDestination(req.to); - if (!toNetwork) { - throw new APIError({ - message: `Invalid network for destination: ${req.to} `, - status: httpStatus.BAD_REQUEST - }); - } - - const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain; - - return { - data: { - amountRaw: inputAmountRaw, - fromNetwork: Networks.Base, - fromToken: usdcBaseTokenDetails.erc20AddressSourceChain, - inputAmountDecimal: inputAmountDecimal, - inputAmountRaw: inputAmountRaw, - outputDecimals: usdcBaseTokenDetails.decimals, - toNetwork, - toToken - }, - type: "evm-to-evm" - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-moonbeam-to-evm.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-moonbeam-to-evm.ts deleted file mode 100644 index 909b8209f..000000000 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-moonbeam-to-evm.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { AXL_USDC_MOONBEAM, getNetworkFromDestination, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; -import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig } from "./index"; - -export class OnRampSquidRouterBrlToEvmEngine extends BaseSquidRouterEngine { - readonly config: SquidRouterConfig = { - direction: RampDirection.BUY, - skipNote: "OnRampSquidRouterBrlToEvmEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - protected validate(ctx: QuoteContext): void { - if (ctx.request.to === "assethub") { - throw new Error( - "OnRampSquidRouterBrlToEvmEngine: Skipped because destination is assethub, this engine handles EVM destinations only" - ); - } - - if (!ctx.pendulumToMoonbeamXcm) { - throw new Error( - "OnRampSquidRouterBrlToEvmEngine: Missing pendulumToMoonbeamXcm in context - ensure pendulum-transfers stage ran successfully" - ); - } - } - - protected compute(ctx: QuoteContext): SquidRouterComputation { - const req = ctx.request; - const toNetwork = getNetworkFromDestination(req.to); - if (!toNetwork) { - throw new Error( - `OnRampSquidRouterBrlToEvmEngine: Invalid network for destination: ${req.to} - ensure destination is a valid EVM network` - ); - } - - const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, toNetwork); - const toTokenAddress = toToken.erc20AddressSourceChain; - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const pendulumToMoonbeamXcm = ctx.pendulumToMoonbeamXcm!; - - return { - data: { - amountRaw: pendulumToMoonbeamXcm.outputAmountRaw, - fromNetwork: Networks.Moonbeam, - fromToken: AXL_USDC_MOONBEAM, - inputAmountDecimal: pendulumToMoonbeamXcm.outputAmountDecimal, - inputAmountRaw: pendulumToMoonbeamXcm.outputAmountRaw, - outputDecimals: toToken.decimals, - toNetwork, - toToken: toTokenAddress - }, - type: "moonbeam-to-evm" - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts deleted file mode 100644 index 85cf03598..000000000 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - ALFREDPAY_ERC20_TOKEN, - ALFREDPAY_EVM_TOKEN, - getNetworkFromDestination, - Networks, - OnChainToken, - RampDirection -} from "@vortexfi/shared"; -import httpStatus from "http-status"; -import { APIError } from "../../../../errors/api-error"; -import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig, SquidRouterData } from "./index"; - -export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { - readonly config: SquidRouterConfig = { - direction: RampDirection.BUY, - skipNote: "OnRampSquidRouterUsdToEvmEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - protected validate(ctx: QuoteContext): void { - if (ctx.request.to === "assethub") { - throw new Error( - "OnRampSquidRouterUsdToEvmEngine: Skipped because destination is assethub, this engine handles EVM destinations only" - ); - } - - if (!ctx.alfredpayMint?.outputAmountDecimal) { - throw new Error( - "OnRampSquidRouterUsdToEvmEngine: Missing alfredpayMint.amountOut in context - ensure initialize stage ran successfully" - ); - } - - if (!ctx.subsidy) { - throw new Error("OnRampSquidRouterUsdToEvmEngine: Missing subsidy in context - ensure discount stage ran successfully"); - } - } - - protected compute(ctx: QuoteContext): SquidRouterComputation { - if (ctx.to === Networks.Polygon && ctx.request.outputCurrency === ALFREDPAY_EVM_TOKEN) { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const subsidy = ctx.subsidy!; - return { - data: { - amountRaw: subsidy.targetOutputAmountRaw, - fromNetwork: Networks.Polygon, - fromToken: ALFREDPAY_ERC20_TOKEN, - inputAmountDecimal: subsidy.targetOutputAmountDecimal, - inputAmountRaw: subsidy.targetOutputAmountRaw, - outputDecimals: 6, - skipRouteCalculation: true, - toNetwork: Networks.Polygon, - toToken: ALFREDPAY_EVM_TOKEN - } as unknown as SquidRouterData, - type: "evm-to-evm" - }; - } - - const req = ctx.request; - const toNetwork = getNetworkFromDestination(req.to); - if (!toNetwork) { - throw new APIError({ - message: `Invalid network for destination: ${req.to} `, - status: httpStatus.BAD_REQUEST - }); - } - - const toTokenDetails = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to); - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const subsidy = ctx.subsidy!; - - return { - data: { - amountRaw: subsidy.targetOutputAmountRaw, - fromNetwork: Networks.Polygon, - fromToken: ALFREDPAY_ERC20_TOKEN, - inputAmountDecimal: subsidy.targetOutputAmountDecimal, - inputAmountRaw: subsidy.targetOutputAmountRaw, - outputDecimals: toTokenDetails.decimals, - toNetwork, - toToken: toTokenDetails.erc20AddressSourceChain - }, - type: "evm-to-evm" - }; - } -} diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 0c1aa466c..d8bdb7161 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -3,6 +3,7 @@ import { CreateBestQuoteRequest, CreateQuoteRequest, DestinationType, + EvmToken, FiatToken, getNetworkFromDestination, isNetworkEVM, @@ -17,15 +18,14 @@ import pLimit from "p-limit"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import { APIError } from "../../errors/api-error"; +import { getTargetFiatCurrency, SUPPORTED_CHAINS, validateChainSupport } from "../phases/blocks/core/helpers"; +import { MykoboFeeUnavailableError } from "../phases/blocks/core/mykobo-fee"; +import { runBlockQuoteFlow } from "../phases/blocks/core/quote"; +import { buildBlockQuoteResponse } from "../phases/blocks/core/quote-response"; import { BaseRampService } from "../ramp/base.service"; import { createLowLiquidityQuoteError, isLowLiquidityQuoteError } from "./core/errors"; -import { getTargetFiatCurrency, SUPPORTED_CHAINS, validateChainSupport } from "./core/helpers"; import { resolveQuotePartner } from "./core/partner-resolution"; import { createQuoteContext } from "./core/quote-context"; -import { QuoteOrchestrator } from "./core/quote-orchestrator"; -import { buildQuoteResponse } from "./engines/finalize"; -import { MykoboFeeUnavailableError } from "./engines/mykobo-fee"; -import { RouteResolver } from "./routes/route-resolver"; type BestQuoteFailure = { error: unknown; @@ -34,7 +34,12 @@ type BestQuoteFailure = { export class QuoteService extends BaseRampService { public async createQuote( - request: CreateQuoteRequest & { apiKey?: string | null; partnerName?: string | null; userId?: string } + request: CreateQuoteRequest & { + apiCredentialId?: string; + apiKey?: string | null; + partnerName?: string | null; + userId?: string; + } ): Promise { return this.executeQuoteCalculation(request); } @@ -50,7 +55,7 @@ export class QuoteService extends BaseRampService { return null; } - return buildQuoteResponse(quote); + return buildBlockQuoteResponse(quote); } /** @@ -59,7 +64,12 @@ export class QuoteService extends BaseRampService { * @returns The best quote across all eligible networks */ public async createBestQuote( - request: CreateBestQuoteRequest & { apiKey?: string | null; partnerName?: string | null; userId?: string } + request: CreateBestQuoteRequest & { + apiCredentialId?: string; + apiKey?: string | null; + partnerName?: string | null; + userId?: string; + } ): Promise { const { rampType, from, to, networks } = request; @@ -157,11 +167,27 @@ export class QuoteService extends BaseRampService { * @returns The calculated quote */ private async executeQuoteCalculation( - request: CreateQuoteRequest & { apiKey?: string | null; partnerName?: string | null; userId?: string }, + request: CreateQuoteRequest & { + apiCredentialId?: string; + apiKey?: string | null; + partnerName?: string | null; + userId?: string; + }, skipPersistence = false ): Promise { validateChainSupport(request.rampType, request.from, request.to); + if ( + (request.rampType === RampDirection.BUY && + request.inputCurrency === FiatToken.BRL && + getNetworkFromDestination(request.to) === Networks.AssetHub) || + (request.rampType === RampDirection.SELL && + getNetworkFromDestination(request.from) === Networks.AssetHub && + request.outputCurrency === FiatToken.BRL) + ) { + throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.BAD_REQUEST }); + } + if (request.rampType === RampDirection.BUY && request.to === Networks.Ethereum) { throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); } @@ -204,12 +230,8 @@ export class QuoteService extends BaseRampService { ctx.skipPersistence = true; } - const orchestrator = new QuoteOrchestrator(); - const resolver = new RouteResolver(); - const strategy = resolver.resolve(ctx); - try { - await orchestrator.run(strategy, ctx); + await runBlockQuoteFlow(ctx); } catch (error) { logger.error(error instanceof Error ? error.message : String(error)); diff --git a/apps/api/src/api/services/quote/routes/route-definition.ts b/apps/api/src/api/services/quote/routes/route-definition.ts deleted file mode 100644 index c851e33e7..000000000 --- a/apps/api/src/api/services/quote/routes/route-definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { EnginesRegistry, IRouteStrategy, QuoteContext, StageKey } from "../core/types"; - -type StageListFactory = (ctx: QuoteContext) => StageKey[]; -type EngineRegistryFactory = (ctx: QuoteContext) => EnginesRegistry; - -interface RouteDefinition { - engines: EngineRegistryFactory; - name: string; - stages: readonly StageKey[] | StageListFactory; -} - -export function defineRouteStrategy(definition: RouteDefinition): IRouteStrategy { - return { - getEngines(ctx) { - return definition.engines(ctx); - }, - getStages(ctx) { - return typeof definition.stages === "function" ? definition.stages(ctx) : [...definition.stages]; - }, - name: definition.name - }; -} diff --git a/apps/api/src/api/services/quote/routes/route-resolver.test.ts b/apps/api/src/api/services/quote/routes/route-resolver.test.ts deleted file mode 100644 index d50934d74..000000000 --- a/apps/api/src/api/services/quote/routes/route-resolver.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import {describe, expect, it} from "bun:test"; -import {AssetHubToken, EPaymentMethod, FiatToken, Networks, RampDirection} from "@vortexfi/shared"; -import {APIError} from "../../../errors/api-error"; -import {createQuoteContext} from "../core/quote-context"; -import {RouteResolver} from "./route-resolver"; - -describe("RouteResolver", () => { - it("rejects AssetHub to CBU before creating an unexecutable Alfredpay quote", () => { - const ctx = createQuoteContext({ - partner: null, - request: { - from: Networks.AssetHub, - inputAmount: "100", - inputCurrency: AssetHubToken.USDC, - network: Networks.AssetHub, - outputCurrency: FiatToken.ARS, - rampType: RampDirection.SELL, - to: EPaymentMethod.CBU - }, - targetFeeFiatCurrency: FiatToken.ARS - }); - - expect(() => new RouteResolver().resolve(ctx)).toThrow(APIError); - }); - - it("rejects BRL onramp to non-USDC AssetHub before selecting the disabled Hydration route", () => { - const ctx = createQuoteContext({ - partner: null, - request: { - from: EPaymentMethod.PIX, - inputAmount: "100", - inputCurrency: FiatToken.BRL, - network: Networks.AssetHub, - outputCurrency: AssetHubToken.USDT, - rampType: RampDirection.BUY, - to: Networks.AssetHub - }, - targetFeeFiatCurrency: FiatToken.BRL - }); - - expect(() => new RouteResolver().resolve(ctx)).toThrow(APIError); - }); - - it("keeps BRL onramp to AssetHub USDC available", () => { - const ctx = createQuoteContext({ - partner: null, - request: { - from: EPaymentMethod.PIX, - inputAmount: "100", - inputCurrency: FiatToken.BRL, - network: Networks.AssetHub, - outputCurrency: AssetHubToken.USDC, - rampType: RampDirection.BUY, - to: Networks.AssetHub - }, - targetFeeFiatCurrency: FiatToken.BRL - }); - - expect(new RouteResolver().resolve(ctx).name).toBe("OnRampAveniaToAssetHub"); - }); -}); diff --git a/apps/api/src/api/services/quote/routes/route-resolver.ts b/apps/api/src/api/services/quote/routes/route-resolver.ts deleted file mode 100644 index 5048b4c0c..000000000 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * RouteResolver selects a route strategy based on direction and destination. - */ -import { - AssetHubToken, - EPaymentMethod, - FiatToken, - isAlfredpayToken, - Networks, - QuoteError, - RampDirection -} from "@vortexfi/shared"; -import httpStatus from "http-status"; -import { APIError } from "../../../errors/api-error"; -import type { QuoteContext } from "../core/types"; -import { IRouteStrategy } from "../core/types"; -import { offrampEvmToAlfredpayStrategy } from "./strategies/offramp-evm-to-alfredpay.strategy"; -import { offrampToPixStrategy } from "./strategies/offramp-to-pix.strategy"; -import { offrampToPixEvmStrategy } from "./strategies/offramp-to-pix-base.strategy"; -import { offrampToSepaEvmStrategy } from "./strategies/offramp-to-sepa-evm.strategy"; -import { onrampAlfredpayToEvmStrategy } from "./strategies/onramp-alfredpay-to-evm.strategy"; -import { onrampAveniaToAssethubStrategy } from "./strategies/onramp-avenia-to-assethub.strategy"; -import { onrampAveniaToEvmBaseStrategy } from "./strategies/onramp-avenia-to-evm.strategy-base"; -import { onrampMykoboToEvmStrategy } from "./strategies/onramp-mykobo-to-evm.strategy"; - -const ALFREDPAY_PAYMENT_METHODS: ReadonlySet = new Set([ - EPaymentMethod.ACH, - EPaymentMethod.CBU, - EPaymentMethod.SPEI, - EPaymentMethod.WIRE -]); - -export class RouteResolver { - resolve(ctx: QuoteContext): IRouteStrategy { - // Onramps - if (ctx.direction === RampDirection.BUY) { - if (ctx.to === Networks.AssetHub) { - if (isAlfredpayToken(ctx.request.inputCurrency as FiatToken)) { - throw new APIError({ message: QuoteError.AssetHubNotSupportedForAlfredPay, status: httpStatus.BAD_REQUEST }); - } - if (ctx.request.inputCurrency === FiatToken.EURC) { - throw new APIError({ - message: "EUR onramp to AssetHub is not supported; please choose an EVM destination chain", - status: httpStatus.BAD_REQUEST - }); - } - if (ctx.request.outputCurrency !== AssetHubToken.USDC) { - throw new APIError({ message: QuoteError.UnsupportedCurrency, status: httpStatus.BAD_REQUEST }); - } - return onrampAveniaToAssethubStrategy; - } else { - if (ctx.request.inputCurrency === FiatToken.EURC) { - return onrampMykoboToEvmStrategy; - } else if (isAlfredpayToken(ctx.request.inputCurrency as FiatToken)) { - return onrampAlfredpayToEvmStrategy; - } else { - return onrampAveniaToEvmBaseStrategy; - } - } - } - - // Offramps - - // Explicitly disallow Assethub USDT and DOT - if (ctx.from === Networks.AssetHub) { - if (ALFREDPAY_PAYMENT_METHODS.has(ctx.to)) { - throw new APIError({ message: QuoteError.AssetHubNotSupportedForAlfredPay, status: httpStatus.BAD_REQUEST }); - } - if (ctx.request.inputCurrency === AssetHubToken.USDT) { - throw new Error("Offramp from USDT on AssetHub is currently not supported"); - } else if (ctx.request.inputCurrency === AssetHubToken.DOT) { - throw new Error("Offramp from DOT on AssetHub is currently not supported"); - } - } - - switch (ctx.to) { - case "pix": - return ctx.from === Networks.AssetHub ? offrampToPixStrategy : offrampToPixEvmStrategy; - case "wire": - case "ach": - case "spei": - case "cbu": - return offrampEvmToAlfredpayStrategy; - case "sepa": - return offrampToSepaEvmStrategy; - default: - throw new APIError({ message: `Unsupported offramp payment method: ${ctx.to}`, status: httpStatus.BAD_REQUEST }); - } - } -} diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts deleted file mode 100644 index 6dccd0a8c..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { StageKey } from "../../core/types"; -import { OffRampAlfredpayDiscountEngine } from "../../engines/discount/offramp-alfredpay"; -import { OffRampEvmToAlfredpayFeeEngine } from "../../engines/fee/offramp-evm-to-alfredpay"; -import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; - -import { OffRampFromEvmInitializeAlfredpayEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; -import { OfframpTransactionAlfredpayEngine } from "../../engines/partners/offramp-alfredpay"; -import { defineRouteStrategy } from "../route-definition"; - -export const offrampEvmToAlfredpayStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OffRampFromEvmInitializeAlfredpayEngine(), - [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), - [StageKey.Discount]: new OffRampAlfredpayDiscountEngine(), - [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), - [StageKey.Finalize]: new OffRampFinalizeEngine() - }), - name: "OfframpEvmToAlfredpay", - stages: [StageKey.Initialize, StageKey.Discount, StageKey.PartnerOperation, StageKey.Fee, StageKey.Finalize] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts deleted file mode 100644 index b6815d509..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { EvmToken } from "@vortexfi/shared"; -import { StageKey } from "../../core/types"; -import { OffRampDiscountEngine } from "../../engines/discount/offramp"; -import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; -import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; -import { OffRampFromEvmInitializeAveniaEngine } from "../../engines/initialize/offramp-from-evm-avenia"; -import { OffRampMergeSubsidyEvmEngine } from "../../engines/merge-subsidy/offramp-evm"; -import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; -import { defineRouteStrategy } from "../route-definition"; - -export const offrampToPixEvmStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OffRampFromEvmInitializeAveniaEngine(), - [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.BRLA), - [StageKey.Fee]: new OffRampFeeAveniaEngine(), - [StageKey.Discount]: new OffRampDiscountEngine(), - [StageKey.MergeSubsidy]: new OffRampMergeSubsidyEvmEngine(), - [StageKey.Finalize]: new OffRampFinalizeEngine() - }), - name: "OfframpToPixEvm", - stages: [StageKey.Initialize, StageKey.NablaSwap, StageKey.Fee, StageKey.Discount, StageKey.MergeSubsidy, StageKey.Finalize] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts deleted file mode 100644 index 478e6fb0f..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix.strategy.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { StageKey } from "../../core/types"; -import { OffRampDiscountEngine } from "../../engines/discount/offramp"; -import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; -import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; -import { OffRampFromAssethubInitializeEngine } from "../../engines/initialize/offramp-from-assethub"; -import { OffRampFromEvmInitializeEngineMoonbeam } from "../../engines/initialize/offramp-from-evm"; -import { OffRampSwapEngine } from "../../engines/nabla-swap/offramp"; -import { OffRampToAveniaPendulumTransferEngine } from "../../engines/pendulum-transfers/offramp-avenia"; -import { defineRouteStrategy } from "../route-definition"; - -export const offrampToPixStrategy = defineRouteStrategy({ - engines: ctx => ({ - [StageKey.Initialize]: - ctx.request.from === "assethub" - ? new OffRampFromAssethubInitializeEngine() - : new OffRampFromEvmInitializeEngineMoonbeam(), - [StageKey.NablaSwap]: new OffRampSwapEngine(), - [StageKey.Fee]: new OffRampFeeAveniaEngine(), - [StageKey.Discount]: new OffRampDiscountEngine(), - [StageKey.PendulumTransfer]: new OffRampToAveniaPendulumTransferEngine(), - [StageKey.Finalize]: new OffRampFinalizeEngine() - }), - name: "OffRampPix", - stages: [ - StageKey.Initialize, - StageKey.NablaSwap, - StageKey.Fee, - StageKey.Discount, - StageKey.PendulumTransfer, - StageKey.Finalize - ] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm.strategy.ts deleted file mode 100644 index 97d2ddf6d..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm.strategy.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { EvmToken } from "@vortexfi/shared"; -import { StageKey } from "../../core/types"; -import { OffRampDiscountEngine } from "../../engines/discount/offramp"; -import { OffRampFeeMykoboEngine } from "../../engines/fee/offramp-mykobo"; -import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; -import { OffRampFromEvmInitializeMykoboEngine } from "../../engines/initialize/offramp-from-evm-mykobo"; -import { OffRampMergeSubsidyEvmEngine } from "../../engines/merge-subsidy/offramp-evm"; -import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; -import { defineRouteStrategy } from "../route-definition"; - -export const offrampToSepaEvmStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OffRampFromEvmInitializeMykoboEngine(), - [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.EURC), - [StageKey.Fee]: new OffRampFeeMykoboEngine(), - [StageKey.Discount]: new OffRampDiscountEngine(), - [StageKey.MergeSubsidy]: new OffRampMergeSubsidyEvmEngine(), - [StageKey.Finalize]: new OffRampFinalizeEngine() - }), - name: "OfframpToSepaEvm", - stages: [StageKey.Initialize, StageKey.NablaSwap, StageKey.Fee, StageKey.Discount, StageKey.MergeSubsidy, StageKey.Finalize] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts deleted file mode 100644 index 9d9753cb3..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { StageKey } from "../../core/types"; -import { OnRampAlfredpayDiscountEngine } from "../../engines/discount/onramp-alfredpay"; -import { OnRampAlfredpayToEvmFeeEngine } from "../../engines/fee/onramp-alfredpay-to-evm"; -import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; -import { OnRampInitializeAlfredpayEngine } from "../../engines/initialize/onramp-alfredpay"; -import { OnRampSquidRouterUsdToEvmEngine } from "../../engines/squidrouter/onramp-polygon-to-evm-alfredpay"; -import { defineRouteStrategy } from "../route-definition"; - -export const onrampAlfredpayToEvmStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OnRampInitializeAlfredpayEngine(), - [StageKey.Fee]: new OnRampAlfredpayToEvmFeeEngine(), - [StageKey.Discount]: new OnRampAlfredpayDiscountEngine(), - [StageKey.SquidRouter]: new OnRampSquidRouterUsdToEvmEngine(), - [StageKey.Finalize]: new OnRampFinalizeEngine() - }), - name: "OnrampAlfredpayToEvm", - stages: [StageKey.Initialize, StageKey.Fee, StageKey.Discount, StageKey.SquidRouter, StageKey.Finalize] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-assethub.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-assethub.strategy.ts deleted file mode 100644 index 3073da78d..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-assethub.strategy.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { StageKey } from "../../core/types"; -import { OnRampDiscountEngine } from "../../engines/discount/onramp"; -import { OnRampAveniaToAssethubFeeEngine } from "../../engines/fee/onramp-brl-to-assethub"; -import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; -import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; -import { OnRampSwapEngine } from "../../engines/nabla-swap/onramp"; -import { OnRampPendulumTransferEngine } from "../../engines/pendulum-transfers/onramp"; -import { defineRouteStrategy } from "../route-definition"; - -export const onrampAveniaToAssethubStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OnRampInitializeAveniaEngine(), - [StageKey.Fee]: new OnRampAveniaToAssethubFeeEngine(), - [StageKey.NablaSwap]: new OnRampSwapEngine(), - [StageKey.Discount]: new OnRampDiscountEngine(), - [StageKey.PendulumTransfer]: new OnRampPendulumTransferEngine(), - [StageKey.Finalize]: new OnRampFinalizeEngine() - }), - name: "OnRampAveniaToAssetHub", - stages: [ - StageKey.Initialize, - StageKey.Fee, - StageKey.NablaSwap, - StageKey.Discount, - StageKey.PendulumTransfer, - StageKey.Finalize - ] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts deleted file mode 100644 index 48e564890..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { EvmToken, Networks } from "@vortexfi/shared"; -import { StageKey } from "../../core/types"; -import { OnRampDiscountEngine } from "../../engines/discount/onramp"; -import { OnRampAveniaToEvmFeeEngine } from "../../engines/fee/onramp-brl-to-evm"; -import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; -import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; -import { OnRampSwapEngineEvm } from "../../engines/nabla-swap/onramp-evm"; -import { OnRampSquidRouterToBaseEngine } from "../../engines/squidrouter/onramp-base-to-evm"; -import { defineRouteStrategy } from "../route-definition"; - -export const onrampAveniaToEvmBaseStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OnRampInitializeAveniaEngine(), - [StageKey.Fee]: new OnRampAveniaToEvmFeeEngine(Networks.Base, EvmToken.USDC), - [StageKey.NablaSwap]: new OnRampSwapEngineEvm(), - [StageKey.Discount]: new OnRampDiscountEngine(), - [StageKey.SquidRouter]: new OnRampSquidRouterToBaseEngine(), - [StageKey.Finalize]: new OnRampFinalizeEngine() - }), - name: "OnRampAveniaToEvmBase", - stages: [StageKey.Initialize, StageKey.Fee, StageKey.NablaSwap, StageKey.Discount, StageKey.SquidRouter, StageKey.Finalize] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy.ts deleted file mode 100644 index b7693e2cf..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { EvmToken, Networks } from "@vortexfi/shared"; -import { StageKey } from "../../core/types"; -import { OnRampDiscountEngine } from "../../engines/discount/onramp"; -import { OnRampAveniaToEvmFeeEngine } from "../../engines/fee/onramp-brl-to-evm"; -import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; -import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; -import { OnRampSwapEngine } from "../../engines/nabla-swap/onramp"; -import { OnRampPendulumTransferEngine } from "../../engines/pendulum-transfers/onramp"; -import { OnRampSquidRouterBrlToEvmEngine } from "../../engines/squidrouter/onramp-moonbeam-to-evm"; -import { defineRouteStrategy } from "../route-definition"; - -export const onrampAveniaToEvmStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OnRampInitializeAveniaEngine(), - [StageKey.Fee]: new OnRampAveniaToEvmFeeEngine(Networks.Moonbeam, EvmToken.AXLUSDC), - [StageKey.NablaSwap]: new OnRampSwapEngine(), - [StageKey.Discount]: new OnRampDiscountEngine(), - [StageKey.PendulumTransfer]: new OnRampPendulumTransferEngine(), - [StageKey.SquidRouter]: new OnRampSquidRouterBrlToEvmEngine(), - [StageKey.Finalize]: new OnRampFinalizeEngine() - }), - name: "OnRampAveniaToEvm", - stages: [ - StageKey.Initialize, - StageKey.Fee, - StageKey.NablaSwap, - StageKey.Discount, - StageKey.PendulumTransfer, - StageKey.SquidRouter, - StageKey.Finalize - ] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-mykobo-to-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-mykobo-to-evm.strategy.ts deleted file mode 100644 index 232def9f7..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-mykobo-to-evm.strategy.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { EvmToken, Networks } from "@vortexfi/shared"; -import { StageKey } from "../../core/types"; -import { OnRampDiscountEngine } from "../../engines/discount/onramp"; -import { OnRampMykoboToEvmFeeEngine } from "../../engines/fee/onramp-mykobo-to-evm"; -import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; -import { OnRampInitializeMykoboEngine } from "../../engines/initialize/onramp-mykobo"; -import { OnRampSwapEngineMykoboEvm } from "../../engines/nabla-swap/onramp-mykobo-evm"; -import { OnRampSquidRouterToBaseEngine } from "../../engines/squidrouter/onramp-base-to-evm"; -import { defineRouteStrategy } from "../route-definition"; - -export const onrampMykoboToEvmStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OnRampInitializeMykoboEngine(), - [StageKey.Fee]: new OnRampMykoboToEvmFeeEngine(Networks.Base, EvmToken.EURC), - [StageKey.NablaSwap]: new OnRampSwapEngineMykoboEvm(), - [StageKey.Discount]: new OnRampDiscountEngine(), - [StageKey.SquidRouter]: new OnRampSquidRouterToBaseEngine(), - [StageKey.Finalize]: new OnRampFinalizeEngine() - }), - name: "OnRampMykoboToEvm", - stages: [StageKey.Initialize, StageKey.Fee, StageKey.NablaSwap, StageKey.Discount, StageKey.SquidRouter, StageKey.Finalize] -}); 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 2c17c0f46..f5e786bb9 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,7 @@ -import {afterAll, beforeEach, describe, expect, it, mock} from "bun:test"; -import {EphemeralAccountType} from "@vortexfi/shared"; +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { EphemeralAccountType, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; import * as sharedNamespace from "@vortexfi/shared"; -import {APIError} from "../../errors/api-error"; +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. @@ -18,6 +18,8 @@ let substrateNonce = 0; let substrateFree = "0"; let checkedSubstrateNetworks: string[] = []; let evmNonce = 0; +let evmBalance = 0n; +let checkedEvmNetworks: string[] = []; let evmGetClientShouldThrow = false; mock.module("@vortexfi/shared", () => { @@ -28,7 +30,6 @@ mock.module("@vortexfi/shared", () => { getInstance: () => ({ getApi: async (network: string) => { checkedSubstrateNetworks.push(network); - return { api: { query: { @@ -46,9 +47,11 @@ mock.module("@vortexfi/shared", () => { }, EvmClientManager: { getInstance: () => ({ - getClient: (_network: string) => { + getClient: (network: string) => { if (evmGetClientShouldThrow) throw new Error("RPC down"); + checkedEvmNetworks.push(network); return { + getBalance: async (_args: { address: string }) => evmBalance, getTransactionCount: async (_args: { address: string }) => evmNonce }; } @@ -58,7 +61,158 @@ mock.module("@vortexfi/shared", () => { }); // Import AFTER mocks are registered so the module picks up the mocked deps. -const { validateEphemeralAccountsFresh } = await import("./ephemeral-freshness"); +const { validateEphemeralAccountsFresh, quoteToSigningNetworks } = await import("./ephemeral-freshness"); + +// Minimal quotes per corridor. Only the fields quoteToSigningNetworks reads are set. +const BRL_OFFRAMP_EVM = { + from: Networks.Base, + inputCurrency: "USDC", + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: "pix" +}; +const BRL_OFFRAMP_ASSETHUB = { ...BRL_OFFRAMP_EVM, from: Networks.AssetHub }; +const ALFREDPAY_OFFRAMP = { + from: Networks.Polygon, + inputCurrency: "USDT", + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: "spei" +}; +const AVENIA_ONRAMP_BASE = { + from: "pix", + inputCurrency: FiatToken.BRL, + outputCurrency: "USDC", + rampType: RampDirection.BUY, + to: Networks.Base +}; +const AVENIA_ONRAMP_TO_POLYGON = { ...AVENIA_ONRAMP_BASE, to: Networks.Polygon }; +const AVENIA_ONRAMP_ASSETHUB_USDC = { ...AVENIA_ONRAMP_BASE, outputCurrency: "USDC", to: Networks.AssetHub }; +const AVENIA_ONRAMP_ASSETHUB_NON_USDC = { ...AVENIA_ONRAMP_BASE, outputCurrency: "DOT", to: Networks.AssetHub }; +const ALFREDPAY_ONRAMP = { + from: "spei", + inputCurrency: FiatToken.MXN, + outputCurrency: "USDT", + rampType: RampDirection.BUY, + to: Networks.Polygon +}; +const ALFREDPAY_ONRAMP_TO_BASE = { ...ALFREDPAY_ONRAMP, to: Networks.Base }; +const EUR_OFFRAMP = { + from: Networks.Base, + inputCurrency: "USDC", + outputCurrency: FiatToken.EURC, + rampType: RampDirection.SELL, + to: "sepa" +}; +const EUR_ONRAMP_BASE = { + from: "sepa", + inputCurrency: FiatToken.EURC, + outputCurrency: "USDC", + rampType: RampDirection.BUY, + to: Networks.Base +}; +const EUR_ONRAMP_TO_ARBITRUM = { ...EUR_ONRAMP_BASE, to: Networks.Arbitrum }; + +describe("quoteToSigningNetworks", () => { + it("BRL off-ramp from an EVM chain signs on Base only", () => { + expect(quoteToSigningNetworks(BRL_OFFRAMP_EVM)).toEqual({ evm: [Networks.Base], substrate: [] }); + }); + + it("BRL off-ramp from AssetHub signs the Substrate ephemeral on Pendulum only", () => { + expect(quoteToSigningNetworks(BRL_OFFRAMP_ASSETHUB)).toEqual({ evm: [], substrate: ["pendulum"] }); + }); + + it("Alfredpay off-ramp signs on Polygon only", () => { + expect(quoteToSigningNetworks(ALFREDPAY_OFFRAMP)).toEqual({ evm: [Networks.Polygon], substrate: [] }); + }); + + it("Avenia on-ramp to Base signs on Base only (destination deduped against the hub)", () => { + expect(quoteToSigningNetworks(AVENIA_ONRAMP_BASE)).toEqual({ evm: [Networks.Base], substrate: [] }); + }); + + it("Avenia on-ramp to a different EVM chain signs on Base plus the destination", () => { + expect(quoteToSigningNetworks(AVENIA_ONRAMP_TO_POLYGON)).toEqual({ + evm: [Networks.Base, Networks.Polygon], + substrate: [] + }); + }); + + it("Avenia on-ramp to AssetHub (USDC) signs Moonbeam + Pendulum, without Hydration", () => { + expect(quoteToSigningNetworks(AVENIA_ONRAMP_ASSETHUB_USDC)).toEqual({ + evm: [Networks.Moonbeam], + substrate: ["pendulum"] + }); + }); + + it("Avenia on-ramp to AssetHub (non-USDC) additionally signs Hydration", () => { + expect(quoteToSigningNetworks(AVENIA_ONRAMP_ASSETHUB_NON_USDC)).toEqual({ + evm: [Networks.Moonbeam], + substrate: ["pendulum", "hydration"] + }); + }); + + it("Alfredpay on-ramp signs on Polygon only (destination deduped)", () => { + expect(quoteToSigningNetworks(ALFREDPAY_ONRAMP)).toEqual({ evm: [Networks.Polygon], substrate: [] }); + }); + + it("Alfredpay on-ramp to a different EVM chain signs on Polygon plus the destination", () => { + expect(quoteToSigningNetworks(ALFREDPAY_ONRAMP_TO_BASE)).toEqual({ + evm: [Networks.Polygon, Networks.Base], + substrate: [] + }); + }); + + it("EUR off-ramp signs on Base only", () => { + expect(quoteToSigningNetworks(EUR_OFFRAMP)).toEqual({ evm: [Networks.Base], substrate: [] }); + }); + + it("EUR on-ramp to Base signs on Base only (destination deduped)", () => { + expect(quoteToSigningNetworks(EUR_ONRAMP_BASE)).toEqual({ evm: [Networks.Base], substrate: [] }); + }); + + it("EUR on-ramp to a different EVM chain signs on Base plus the destination", () => { + expect(quoteToSigningNetworks(EUR_ONRAMP_TO_ARBITRUM)).toEqual({ + evm: [Networks.Base, Networks.Arbitrum], + substrate: [] + }); + }); + + it("covers every branch of the mapping", () => { + // Guard against a corridor being added to quoteToSigningNetworks without a test: + // each case above must exercise a distinct branch, and together they must produce + // every network the mapping can emit. + const emitted = new Set( + [ + BRL_OFFRAMP_EVM, + BRL_OFFRAMP_ASSETHUB, + ALFREDPAY_OFFRAMP, + EUR_OFFRAMP, + AVENIA_ONRAMP_BASE, + AVENIA_ONRAMP_TO_POLYGON, + AVENIA_ONRAMP_ASSETHUB_USDC, + AVENIA_ONRAMP_ASSETHUB_NON_USDC, + ALFREDPAY_ONRAMP, + ALFREDPAY_ONRAMP_TO_BASE, + EUR_ONRAMP_BASE, + EUR_ONRAMP_TO_ARBITRUM + ].flatMap(quote => { + const { evm, substrate } = quoteToSigningNetworks(quote); + return [...evm, ...substrate]; + }) + ); + expect([...emitted].sort() as string[]).toEqual( + ([Networks.Arbitrum, Networks.Base, Networks.Moonbeam, Networks.Polygon, "hydration", "pendulum"] as string[]).sort() + ); + }); + + it("does not depend on chains outside the route (no all-chain fan-out)", () => { + // The whole point of SPEC-015: a BRL-on-Base ramp must not touch Arbitrum/Avalanche/etc. + const { evm } = quoteToSigningNetworks(BRL_OFFRAMP_EVM); + expect(evm).not.toContain(Networks.Arbitrum); + expect(evm).not.toContain(Networks.Ethereum); + expect(evm.length).toBe(1); + }); +}); describe("validateEphemeralAccountsFresh", () => { beforeEach(() => { @@ -66,68 +220,81 @@ describe("validateEphemeralAccountsFresh", () => { substrateFree = "0"; checkedSubstrateNetworks = []; evmNonce = 0; + evmBalance = 0n; + checkedEvmNetworks = []; evmGetClientShouldThrow = false; }); - it("passes when all submitted ephemerals are fresh on every supported network", async () => { + it("passes when the submitted ephemeral is fresh on the route's chains", async () => { await expect( - validateEphemeralAccountsFresh({ - [EphemeralAccountType.EVM]: EVM_ADDR, - [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR - }) + validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR }, BRL_OFFRAMP_EVM) ).resolves.toBeUndefined(); + expect(checkedEvmNetworks).toEqual([Networks.Base]); }); - it("does not check Hydration while Hydration-backed routes are disabled", async () => { - await validateEphemeralAccountsFresh({ [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }); + it("only checks the chains the route actually signs on", async () => { + await validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR }, ALFREDPAY_OFFRAMP); + expect(checkedEvmNetworks).toEqual([Networks.Polygon]); + }); + + it("checks Hydration for a non-USDC AssetHub on-ramp", async () => { + await validateEphemeralAccountsFresh({ [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, AVENIA_ONRAMP_ASSETHUB_NON_USDC); + expect(checkedSubstrateNetworks).toEqual(["pendulum", "hydration"]); + }); - expect(checkedSubstrateNetworks).toEqual(["pendulum", "assethub"]); + it("does not check an ephemeral on chains the route never uses", async () => { + // A Substrate ephemeral submitted for a Base off-ramp is unused → not checked. + await validateEphemeralAccountsFresh( + { [EphemeralAccountType.EVM]: EVM_ADDR, [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, + BRL_OFFRAMP_EVM + ); + expect(checkedEvmNetworks).toEqual([Networks.Base]); + expect(checkedSubstrateNetworks).toEqual([]); }); it("passes when no ephemerals are submitted", async () => { - await expect(validateEphemeralAccountsFresh({})).resolves.toBeUndefined(); + await expect(validateEphemeralAccountsFresh({}, BRL_OFFRAMP_EVM)).resolves.toBeUndefined(); }); it("rejects non-fresh Substrate (non-zero nonce)", async () => { substrateNonce = 1; - try { - await validateEphemeralAccountsFresh({ [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }); - throw new Error("expected rejection"); - } catch (err) { - expect(err).toBeInstanceOf(APIError); - expect((err as APIError).status).toBe(400); - expect((err as APIError).message).toContain("not fresh"); - } + const err = await validateEphemeralAccountsFresh( + { [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, + BRL_OFFRAMP_ASSETHUB + ).catch(e => e); + expect(err).toBeInstanceOf(APIError); + expect((err as APIError).status).toBe(400); + expect((err as APIError).message).toContain("not fresh"); }); it("rejects non-fresh Substrate (non-zero free balance)", async () => { substrateFree = "1000"; - try { - await validateEphemeralAccountsFresh({ [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }); - throw new Error("expected rejection"); - } catch (err) { - expect((err as APIError).status).toBe(400); - } + const err = await validateEphemeralAccountsFresh( + { [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, + BRL_OFFRAMP_ASSETHUB + ).catch(e => e); + expect((err as APIError).status).toBe(400); }); it("rejects non-fresh EVM (non-zero nonce)", async () => { evmNonce = 5; - try { - await validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR }); - throw new Error("expected rejection"); - } catch (err) { - expect((err as APIError).status).toBe(400); - expect((err as APIError).message).toContain("not fresh"); - } + const err = await validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR }, BRL_OFFRAMP_EVM).catch(e => e); + expect((err as APIError).status).toBe(400); + expect((err as APIError).message).toContain("not fresh"); + }); + + it("rejects a nonce-0 EVM account that already holds a native balance (SPEC-015)", async () => { + evmNonce = 0; + evmBalance = 1_000_000_000n; + const err = await validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR }, BRL_OFFRAMP_EVM).catch(e => e); + expect(err).toBeInstanceOf(APIError); + expect((err as APIError).status).toBe(400); + expect((err as APIError).message).toContain("balance=1000000000"); }); it("fails closed with SERVICE_UNAVAILABLE on RPC error", async () => { evmGetClientShouldThrow = true; - try { - await validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR }); - throw new Error("expected rejection"); - } catch (err) { - expect((err as APIError).status).toBe(503); - } + const err = await validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR }, BRL_OFFRAMP_EVM).catch(e => e); + expect((err as APIError).status).toBe(503); }); }); diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.ts index e0aeae465..e29d191d6 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.ts @@ -1,49 +1,133 @@ import { ApiManager, + type DestinationType, EphemeralAccountType, EvmClientManager, EvmNetworks, + FiatToken, + getNetworkFromDestination, + getOnChainTokenDetails, + isAlfredpayToken, + isEvmTokenDetails, + isNetworkEVM, Networks, + OnChainToken, + RampDirection, SubstrateApiNetwork } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../errors/api-error"; -const SUPPORTED_SUBSTRATE_NETWORKS: SubstrateApiNetwork[] = ["pendulum", "assethub"]; - -const SUPPORTED_EVM_NETWORKS: EvmNetworks[] = [ - Networks.Arbitrum, - Networks.Avalanche, - Networks.Base, - Networks.BSC, - Networks.Ethereum, - Networks.Moonbeam, - Networks.Polygon, - Networks.PolygonAmoy, - Networks.BaseSepolia -]; - -// SECURITY: fail-closed. Any RPC error rejects the registration since we cannot prove freshness without on-chain data. -// Hydration is intentionally excluded while Hydration-backed routes are disabled; otherwise unrelated registrations -// would open the Hydration RPC even when their route never signs on Hydration. +/** + * The quote fields that determine which chains a ramp's ephemerals will sign on. + * A subset of QuoteTicket so callers can pass a quote row directly. + */ +export interface FreshnessQuote { + rampType: RampDirection; + inputCurrency: string; + outputCurrency: string; + from: string; + to: string; +} + +interface SigningNetworks { + evm: EvmNetworks[]; + substrate: SubstrateApiNetwork[]; +} + +/** + * The chains a ramp's ephemerals will actually sign transactions on, derived from the + * quote. Freshness is validated only against these chains so an outage on an RPC the + * route never touches cannot block an unrelated registration (SPEC-015). Under-reporting + * a chain here is a security hole — an unfresh chain would skip validation — so the set + * mirrors the route topology in `transactions/{onramp,offramp}/` exactly; keep it in sync + * when a route's chains change. `ephemeral-freshness.test.ts` pins the expected set for + * every branch below, including the cross-chain destination and Hydration cases. + * + * Both ephemerals are a single address reused across every chain of their type, so the + * EVM/Substrate split here maps directly onto the two provided ephemeral addresses. + */ +export function quoteToSigningNetworks(quote: FreshnessQuote): SigningNetworks { + const evm = new Set(); + const substrate = new Set(); + + const addIfEvm = (network: Networks | undefined): void => { + if (network && isNetworkEVM(network)) { + evm.add(network); + } + }; + + if (quote.rampType === RampDirection.SELL) { + if (quote.outputCurrency === FiatToken.BRL) { + // Fork on the input token: an EVM stable off-ramps on Base; an AssetHub asset + // off-ramps via the Substrate ephemeral on Pendulum (offramp/index.ts). + const fromNetwork = getNetworkFromDestination(quote.from as DestinationType); + const inputTokenDetails = fromNetwork + ? getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken) + : undefined; + if (inputTokenDetails && isEvmTokenDetails(inputTokenDetails)) { + evm.add(Networks.Base); + } else { + substrate.add("pendulum"); + } + } else if (quote.outputCurrency === FiatToken.EURC) { + evm.add(Networks.Base); // evm-to-mykobo + } else if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { + evm.add(Networks.Polygon); // evm-to-alfredpay + } + return { evm: [...evm], substrate: [...substrate] }; + } + + // On-ramps: a fixed hub chain plus the variable destination network (quote.to). + const toNetwork = getNetworkFromDestination(quote.to as DestinationType); + + if (quote.inputCurrency === FiatToken.EURC) { + // Mykobo (currently kill-switched, but mapped for completeness): Base + destination. + evm.add(Networks.Base); + addIfEvm(toNetwork); + } else if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { + evm.add(Networks.Polygon); // mint chain + addIfEvm(toNetwork); + } else if (toNetwork === Networks.AssetHub) { + // Avenia BRL -> AssetHub: the EVM ephemeral signs Moonbeam XCM (H160), the Substrate + // ephemeral signs Pendulum, plus Hydration when the output is not USDC. + evm.add(Networks.Moonbeam); + substrate.add("pendulum"); + if (quote.outputCurrency !== "USDC") { + substrate.add("hydration"); + } + } else { + // Avenia BRL -> EVM (Base): Base + destination. + evm.add(Networks.Base); + addIfEvm(toNetwork); + } + + return { evm: [...evm], substrate: [...substrate] }; +} + +// SECURITY: fail-closed. Any RPC error rejects the registration since we cannot prove +// freshness without on-chain data. The chain set is scoped to the quote's route so an +// unrelated chain's RPC outage cannot block registrations that never touch it. export async function validateEphemeralAccountsFresh( ephemerals: { [key in EphemeralAccountType]?: string; - } + }, + quote: FreshnessQuote ): Promise { + const { evm: evmNetworks, substrate: substrateNetworks } = quoteToSigningNetworks(quote); const checks: Promise[] = []; const substrateAddress = ephemerals[EphemeralAccountType.Substrate]; if (substrateAddress) { - for (const network of SUPPORTED_SUBSTRATE_NETWORKS) { + for (const network of substrateNetworks) { checks.push(assertSubstrateAccountFresh(substrateAddress, network)); } } const evmAddress = ephemerals[EphemeralAccountType.EVM]; if (evmAddress) { - for (const network of SUPPORTED_EVM_NETWORKS) { + for (const network of evmNetworks) { checks.push(assertEvmAccountFresh(evmAddress, network)); } } @@ -79,9 +163,15 @@ async function assertSubstrateAccountFresh(address: string, network: SubstrateAp async function assertEvmAccountFresh(address: string, network: EvmNetworks): Promise { let nonce: number; + let balance: bigint; try { const client = EvmClientManager.getInstance().getClient(network); - nonce = await client.getTransactionCount({ address: address as `0x${string}` }); + // Both must be zero to prove freshness: a nonce-0 account can still hold a native + // balance (funded but never used), which the nonce-only check missed (SPEC-015). + [nonce, balance] = await Promise.all([ + client.getTransactionCount({ address: address as `0x${string}` }), + client.getBalance({ address: address as `0x${string}` }) + ]); } catch (error) { throw new APIError({ message: `Could not verify freshness of EVM ephemeral ${address} on ${network}: ${(error as Error).message}`, @@ -89,9 +179,9 @@ async function assertEvmAccountFresh(address: string, network: EvmNetworks): Pro }); } - if (nonce !== 0) { + if (nonce !== 0 || balance !== 0n) { throw new APIError({ - message: `EVM ephemeral ${address} is not fresh on ${network} (nonce=${nonce}). A new, unused ephemeral account must be provided.`, + message: `EVM ephemeral ${address} is not fresh on ${network} (nonce=${nonce}, balance=${balance.toString()}). A new, unused ephemeral account must be provided.`, status: httpStatus.BAD_REQUEST }); } diff --git a/apps/api/src/api/services/ramp/helpers.test.ts b/apps/api/src/api/services/ramp/helpers.test.ts index 823e5b195..35e62b917 100644 --- a/apps/api/src/api/services/ramp/helpers.test.ts +++ b/apps/api/src/api/services/ramp/helpers.test.ts @@ -77,6 +77,21 @@ describe("getFinalTransactionHashForRampV2", () => { }); }); + it("uses the Pendulum to Moonbeam hash for AssetHub BRL offramps", () => { + const result = getFinalTransactionHashForRampV2( + createRampState({ + state: { pendulumToMoonbeamXcmHash: "0xmoonbeam" }, + type: RampDirection.SELL + }), + createQuote(Networks.AssetHub) + ); + + expect(result).toEqual({ + transactionExplorerLink: "https://pendulum.subscan.io/block/0xmoonbeam", + transactionHash: "0xmoonbeam" + }); + }); + it("uses Polygon explorer links for Alfredpay offramps", () => { const result = getFinalTransactionHashForRampV2( createRampState({ diff --git a/apps/api/src/api/services/ramp/helpers.ts b/apps/api/src/api/services/ramp/helpers.ts index 595564a7d..693fd5172 100644 --- a/apps/api/src/api/services/ramp/helpers.ts +++ b/apps/api/src/api/services/ramp/helpers.ts @@ -8,6 +8,7 @@ import { fetchWithTimeout } from "../../helpers/fetchWithTimeout"; enum TransactionHashKey { HydrationToAssethubXcmHash = "hydrationToAssethubXcmHash", PendulumToAssethubXcmHash = "pendulumToAssethubXcmHash", + PendulumToMoonbeamXcmHash = "pendulumToMoonbeamXcmHash", SquidRouterSwapHash = "squidRouterSwapHash", DestinationTransferTxHash = "destinationTransferTxHash", BrlaPayoutTxHash = "brlaPayoutTxHash", @@ -103,6 +104,8 @@ const EXPLORER_LINK_BUILDERS: Record = [TransactionHashKey.PendulumToAssethubXcmHash]: hash => `https://pendulum.subscan.io/block/${hash}`, + [TransactionHashKey.PendulumToMoonbeamXcmHash]: hash => `https://pendulum.subscan.io/block/${hash}`, + [TransactionHashKey.SquidRouterSwapHash]: hash => `https://axelarscan.io/gmp/${hash}`, [TransactionHashKey.DestinationTransferTxHash]: (hash, _rampState, quote) => buildEvmExplorerLink(hash, quote.network), @@ -128,6 +131,7 @@ const BUY_TRANSACTION_HASH_V2_PRIORITY: readonly TransactionHashKey[] = [ const SELL_TRANSACTION_HASH_V2_PRIORITY: readonly TransactionHashKey[] = [ TransactionHashKey.BrlaPayoutTxHash, + TransactionHashKey.PendulumToMoonbeamXcmHash, TransactionHashKey.MykoboPayoutTxHash, TransactionHashKey.AlfredpayOfframpTransferTxHash ] as const; diff --git a/apps/api/src/api/services/ramp/ramp-transaction-preparation.test.ts b/apps/api/src/api/services/ramp/ramp-transaction-preparation.test.ts deleted file mode 100644 index 491d5cfe0..000000000 --- a/apps/api/src/api/services/ramp/ramp-transaction-preparation.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { FiatToken, Networks, RampDirection } from "@vortexfi/shared"; -import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } from "./ramp-transaction-preparation"; - -describe("selectRampTransactionPreparationKind", () => { - it("selects the BRL offramp preparer for sell quotes that output BRL", () => { - expect( - selectRampTransactionPreparationKind({ - inputCurrency: FiatToken.BRL, - outputCurrency: FiatToken.BRL, - rampType: RampDirection.SELL - }) - ).toBe(RampTransactionPreparationKind.OfframpBrl); - }); - - it("selects the non-BRL offramp preparer for EUR sell quotes (Mykobo offramp handled downstream)", () => { - expect( - selectRampTransactionPreparationKind({ - inputCurrency: FiatToken.EURC, - outputCurrency: FiatToken.EURC, - rampType: RampDirection.SELL - }) - ).toBe(RampTransactionPreparationKind.OfframpNonBrl); - }); - - it("routes EURC onramps to Mykobo on every supported destination", () => { - expect( - selectRampTransactionPreparationKind({ - inputCurrency: FiatToken.EURC, - outputCurrency: FiatToken.EURC, - rampType: RampDirection.BUY, - to: Networks.Base - }) - ).toBe(RampTransactionPreparationKind.OnrampMykobo); - - expect( - selectRampTransactionPreparationKind({ - inputCurrency: FiatToken.EURC, - outputCurrency: FiatToken.EURC, - rampType: RampDirection.BUY - }) - ).toBe(RampTransactionPreparationKind.OnrampMykobo); - }); - - it("selects non-EURC onramp preparers from the fiat input token", () => { - expect( - selectRampTransactionPreparationKind({ - inputCurrency: FiatToken.USD, - outputCurrency: FiatToken.USD, - rampType: RampDirection.BUY - }) - ).toBe(RampTransactionPreparationKind.OnrampAlfredpay); - - expect( - selectRampTransactionPreparationKind({ - inputCurrency: FiatToken.BRL, - outputCurrency: FiatToken.BRL, - rampType: RampDirection.BUY - }) - ).toBe(RampTransactionPreparationKind.OnrampAvenia); - }); -}); diff --git a/apps/api/src/api/services/ramp/ramp-transaction-preparation.ts b/apps/api/src/api/services/ramp/ramp-transaction-preparation.ts deleted file mode 100644 index 3c763efed..000000000 --- a/apps/api/src/api/services/ramp/ramp-transaction-preparation.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { FiatToken, isAlfredpayToken, RampDirection, RegisterRampRequest } from "@vortexfi/shared"; - -export enum RampTransactionPreparationKind { - OfframpBrl = "offramp-brl", - OfframpNonBrl = "offramp-non-brl", - OnrampAlfredpay = "onramp-alfredpay", - OnrampAvenia = "onramp-avenia", - OnrampMykobo = "onramp-mykobo" -} - -export interface RampTransactionPreparationQuote { - inputCurrency: string; - outputCurrency: string; - rampType: RampDirection; - to?: string; -} - -export function selectRampTransactionPreparationKind( - quote: RampTransactionPreparationQuote, - _additionalData?: RegisterRampRequest["additionalData"] -): RampTransactionPreparationKind { - if (quote.rampType === RampDirection.SELL) { - if (quote.outputCurrency === FiatToken.BRL) { - return RampTransactionPreparationKind.OfframpBrl; - } - - return RampTransactionPreparationKind.OfframpNonBrl; - } - - if (quote.inputCurrency === FiatToken.EURC) { - return RampTransactionPreparationKind.OnrampMykobo; - } - - if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return RampTransactionPreparationKind.OnrampAlfredpay; - } - - return RampTransactionPreparationKind.OnrampAvenia; -} diff --git a/apps/api/src/api/services/ramp/ramp.service.generic-preparation.test.ts b/apps/api/src/api/services/ramp/ramp.service.generic-preparation.test.ts new file mode 100644 index 000000000..1e68b82d4 --- /dev/null +++ b/apps/api/src/api/services/ramp/ramp.service.generic-preparation.test.ts @@ -0,0 +1,133 @@ +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { EphemeralAccountType, RampDirection } from "@vortexfi/shared"; +import type { Transaction } from "sequelize"; +import * as catalogNamespace from "../phases/blocks/flows/catalog"; + +const catalogReal = { ...catalogNamespace }; +const register = mock(async (ctx: { metadata: unknown }) => ({ + metadata: { ...(ctx.metadata as object), refreshed: true }, + registrationFacts: { provider: { aveniaTicketId: "ticket-1", taxId: "derived-tax-id" } }, + responseArtifacts: { provider: { depositQrCode: "provider-code" } } +})); +const prepareTxs = mock(async () => ({ + stateMeta: { blockState: { provider: { taxId: "derived-tax-id" } }, phaseFlow: ["initial", "complete"] }, + unsignedTxs: [] +})); +const start = mock(async (ctx: { metadata: unknown; state: unknown }) => ({ + metadata: { ...(ctx.metadata as object), started: true }, + responseArtifacts: { provider: { achPaymentData: { paymentType: "ACH", reference: "payment-1" } } }, + state: { ...(ctx.state as object), alfredpayTransactionId: "transaction-1" } +})); + +mock.module("../phases/blocks/flows/catalog", () => ({ + ...catalogReal, + resolvePersistedBlockFlow: () => ({ prepareTxs, register, start }) +})); + +const { RampService } = await import("./ramp.service"); + +afterAll(() => { + mock.module("../phases/blocks/flows/catalog", () => ({ ...catalogReal })); +}); + +describe("RampService generic flow preparation", () => { + it("dispatches through the persisted flow and maps refreshed metadata, facts, and artifacts", async () => { + const transaction = {} as Transaction; + const update = mock(async () => undefined); + const metadata = { + blocks: {}, + globals: { + fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } }, + partner: null, + request: { inputCurrency: "BRL", rampType: RampDirection.BUY } + } + }; + const quote = { + get: () => ({ inputAmount: "100" }), + inputCurrency: "BRL", + metadata, + outputCurrency: "BRLA", + rampType: RampDirection.BUY, + to: "base", + update + }; + + const service = new RampService() as unknown as { + prepareRampTransactions: ( + quote: never, + signingAccounts: Array<{ address: string; type: EphemeralAccountType }>, + additionalData: Record, + transaction: Transaction, + userId: string + ) => Promise>; + }; + const result = await service.prepareRampTransactions( + quote as never, + [{ address: "0x1111111111111111111111111111111111111111", type: EphemeralAccountType.EVM }], + { destinationAddress: "0x2222222222222222222222222222222222222222", taxId: "client-tax-id" }, + transaction, + "user-1" + ); + + expect(register).toHaveBeenCalledWith(expect.objectContaining({ + authenticatedUser: { id: "user-1" }, + input: expect.objectContaining({ taxId: "client-tax-id" }), + metadata, + transaction + })); + expect(prepareTxs).toHaveBeenCalledWith(expect.objectContaining({ + destinationAddress: "0x2222222222222222222222222222222222222222", + registrationFacts: { provider: { aveniaTicketId: "ticket-1", taxId: "derived-tax-id" } } + })); + expect(update).toHaveBeenCalledWith( + { metadata: expect.objectContaining({ refreshed: true }) }, + { transaction } + ); + expect(result).toEqual(expect.objectContaining({ + aveniaTicketId: "ticket-1", + depositQrCode: "provider-code", + stateMeta: expect.objectContaining({ aveniaTicketId: "ticket-1", taxId: "derived-tax-id" }), + unsignedTxs: [] + })); + }); + + it("starts the persisted flow and transactionally maps metadata, state, and artifacts", async () => { + const transaction = {} as Transaction; + const quoteUpdate = mock(async () => undefined); + const rampUpdate = mock(async () => undefined); + const metadata = { + blocks: {}, + globals: { + fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } }, + partner: null, + request: { inputCurrency: "MXN", rampType: RampDirection.BUY } + } + }; + const quote = { + get: () => ({ id: "quote-1", inputAmount: "100", inputCurrency: "MXN" }), + metadata, + update: quoteUpdate + }; + const rampState = { + state: { destinationAddress: "0x1111111111111111111111111111111111111111" }, + update: rampUpdate, + userId: "user-1" + }; + const service = new RampService() as unknown as { + startPersistedFlow: (ramp: never, quote: never, transaction: Transaction) => Promise>; + }; + + const result = await service.startPersistedFlow(rampState as never, quote as never, transaction); + + expect(start).toHaveBeenCalledWith(expect.objectContaining({ metadata, state: rampState.state, userId: "user-1" })); + expect(quoteUpdate).toHaveBeenCalledWith( + { metadata: expect.objectContaining({ started: true }) }, + { transaction } + ); + expect(rampUpdate).toHaveBeenCalledWith( + { state: expect.objectContaining({ alfredpayTransactionId: "transaction-1" }) }, + { transaction } + ); + expect(result).toEqual({ achPaymentData: { paymentType: "ACH", reference: "payment-1" } }); + }); +}); 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 87d266bbd..8348e830d 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 @@ -19,21 +19,31 @@ QuoteTicket.findByPk = mock(async () => ({ inputAmount: "25003", inputCurrency: FiatToken.BRL, metadata: { - fees: { - displayFiat: { - anchor: "0.75", - currency: "BRL", - network: "0", - partnerMarkup: "0", - total: "0.75", - vortex: "0" + blocks: {}, + globals: { + fees: { + displayFiat: { + anchor: "0.75", + currency: "BRL", + network: "0", + partnerMarkup: "0", + total: "0.75", + vortex: "0" + }, + usd: { + anchor: "0.15", + network: "0", + partnerMarkup: "0", + total: "0.15", + vortex: "0" + } }, - usd: { - anchor: "0.15", - network: "0", - partnerMarkup: "0", - total: "0.15", - vortex: "0" + partner: null, + request: {}, + subsidyDisplay: { + currency: FiatToken.BRL, + fiat: "12.34", + usd: "2.47" } } }, @@ -144,6 +154,18 @@ function makeExtrinsicOptions() { } describe("RampService.getRampStatus", () => { + it("returns discount display fields from block quote metadata", async () => { + const service = new TestRampService(makeRampState(false)); + + const status = await service.getRampStatus("ramp-1"); + + expect(status).toMatchObject({ + discountCurrency: FiatToken.BRL, + discountFiat: "12.34", + discountUsd: "2.47" + }); + }); + it("returns onHoldForComplianceCheck when ramp state is marked as on hold", async () => { const service = new TestRampService(makeRampState(true)); 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 index fe544805d..627d9a2c7 100644 --- 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 @@ -1,15 +1,17 @@ import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import { FiatToken } from "@vortexfi/shared"; import httpStatus from "http-status"; import type { Transaction } from "sequelize"; import sequelize from "../../../config/database"; import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; +import PartnerManagedProfile from "../../../models/partnerManagedProfile.model"; import User from "../../../models/user.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 +// docs/adr-0001-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 and the User lookup are enough to drive them. class TestRampService extends RampService { @@ -23,6 +25,7 @@ function stubQuote(overrides: { userId: string | null }): void { expiresAt: new Date(Date.now() + 10 * 60 * 1000), flowVariant: config.flowVariant, id: "quote-1", + inputCurrency: FiatToken.EURC, status: "pending", userId: overrides.userId })) as unknown as typeof QuoteTicket.findByPk; @@ -43,19 +46,23 @@ async function expectRegisterError(userId: string | undefined, expectedStatus: n describe("RampService.registerRamp user gating", () => { const originalFindByPk = QuoteTicket.findByPk; const originalUserFindByPk = User.findByPk; + const originalManagedProfileFindOne = PartnerManagedProfile.findOne; const originalQuery = sequelize.query; const queryMock = mock(async () => []); User.findByPk = mock(async () => ({ id: "user-a" })) as unknown as typeof User.findByPk; + PartnerManagedProfile.findOne = mock(async () => null) as unknown as typeof PartnerManagedProfile.findOne; sequelize.query = queryMock as unknown as typeof sequelize.query; afterEach(() => { QuoteTicket.findByPk = originalFindByPk; + PartnerManagedProfile.findOne = mock(async () => null) as unknown as typeof PartnerManagedProfile.findOne; queryMock.mockClear(); }); afterAll(() => { User.findByPk = originalUserFindByPk; + PartnerManagedProfile.findOne = originalManagedProfileFindOne; sequelize.query = originalQuery; }); @@ -66,10 +73,10 @@ describe("RampService.registerRamp user gating", () => { 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. + // The EUR kill switch runs after the user guards and before flow preparation, so this + // proves the anonymous quote was claimable without requiring unrelated flow metadata. expect(error).toBeInstanceOf(APIError); - expect((error as APIError).status).not.toBe(httpStatus.FORBIDDEN); + expect((error as APIError).status).toBe(httpStatus.SERVICE_UNAVAILABLE); expect((error as APIError).message).not.toContain("Invalid quote"); } }); @@ -86,4 +93,28 @@ describe("RampService.registerRamp user gating", () => { // different 400 (missing destinationAddress), which must not satisfy this test. expect(error.message).toContain("requires an API key linked to a user"); }); + + it("rejects technical managed profiles before ramp preparation", async () => { + stubQuote({ userId: null }); + PartnerManagedProfile.findOne = mock(async () => ({ id: "managed-1" })) as unknown as typeof PartnerManagedProfile.findOne; + + const error = await expectRegisterError("user-a", httpStatus.FORBIDDEN); + expect(error.type).toBe("TECHNICAL_PROFILE_NOT_RAMP_ELIGIBLE"); + }); + + it("rejects unsupported recipient-directed payout context instead of silently treating it as self-offramp data", async () => { + const service = new TestRampService(); + + await expect( + service.registerRamp({ + additionalData: { senderRecipientId: "relationship-1" }, + quoteId: "quote-1", + signingAccounts: [], + userId: "user-a" + } as never) + ).rejects.toMatchObject({ + message: expect.stringContaining("Recipient-directed payout is not supported"), + status: httpStatus.BAD_REQUEST + }); + }); }); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 109d7915a..8075987bb 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1,30 +1,15 @@ +import { isDeepStrictEqual } from "node:util"; import { decodeAddress, encodeAddress } from "@polkadot/util-crypto"; import { AccountMeta, - ALFREDPAY_ONCHAIN_CURRENCY, - AlfredpayApiService, - AlfredpayChain, - AlfredpayFiatCurrency, AlfredpayFiatPaymentInstructions, - AlfredpayPaymentMethodType, - AveniaPaymentMethod, - BrlaApiService, - BrlaCurrency, - CreateAlfredpayOfframpQuoteRequest, - CreateAlfredpayOnrampRequest, EphemeralAccountType, FiatToken, GetRampHistoryResponse, GetRampStatusResponse, - generateReferenceLabel, IbanPaymentData, isAlfredpayToken, - Limit, - MykoboApiService, - MykoboCurrency, - MykoboTransactionType, Networks, - normalizeTaxId, QuoteError, RampDirection, RampErrorLog, @@ -37,8 +22,7 @@ import { TransactionStatus, UnsignedTx, UpdateRampRequest, - UpdateRampResponse, - validateMaskedNumber + UpdateRampResponse } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; @@ -47,35 +31,44 @@ import { isAddress } from "viem"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; +import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../../constants/constants"; +import PartnerManagedProfile from "../../../models/partnerManagedProfile.model"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState, { RampStateAttributes } from "../../../models/rampState.model"; import User from "../../../models/user.model"; import { APIError } from "../../errors/api-error"; -import { getTargetFiatCurrency } from "../../services/quote/core/helpers"; import { ActivePartner, handleQuoteConsumptionForDiscountState, resolveActivePartnerById -} from "../../services/quote/engines/discount/helpers"; -import { findAveniaCustomerByTaxId } from "../avenia/avenia-customer.service"; -import { resolveAveniaAccountForRamp } from "../avenia-account"; -import { resolveMykoboCustomerForUser } from "../mykobo/mykobo-customer.service"; +} from "../../services/phases/blocks/core/discount"; +import { getTargetFiatCurrency } from "../../services/phases/blocks/core/helpers"; +import { accountCapabilities } from "../phases/blocks/core/accounts"; +import { getFlowMetadata } from "../phases/blocks/core/metadata"; +import { resolvePersistedBlockFlow } from "../phases/blocks/flows/catalog"; 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"; -import { prepareMykoboToEvmOnrampTransactions } from "../transactions/onramp/routes/mykobo-to-evm"; import { validatePresignedTxs } from "../transactions/validation"; import webhookDeliveryService from "../webhook/webhook-delivery.service"; import { BaseRampService } from "./base.service"; import { validateEphemeralAccountsFresh } from "./ephemeral-freshness"; import { getFinalTransactionHashForRampV2 } from "./helpers"; -import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } from "./ramp-transaction-preparation"; -const RAMP_START_EXPIRATION_TIME_SECONDS = 900; // 15 minutes +function mergeCompatibilityRecords(label: string, records: readonly unknown[]): Record { + const merged: Record = {}; + for (const record of records) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new Error(`${label} contains a non-object compatibility record`); + } + for (const [key, value] of Object.entries(record)) { + if (Object.hasOwn(merged, key) && !isDeepStrictEqual(merged[key], value)) { + throw new Error(`${label} contains conflicting values for compatibility field ${key}`); + } + merged[key] = value; + } + } + return merged; +} // Classifies unsigned txs by signer: ephemeral-signed (backend pre-signs) vs user-wallet-signed. function partitionUnsignedTxs( @@ -173,11 +166,38 @@ export class RampService extends BaseRampService { }); } } + + private static assertStartDeadlineNotExceeded(ramp: Pick): void { + const ageSeconds = (Date.now() - ramp.createdAt.getTime()) / 1000; + if (ageSeconds > RAMP_START_EXPIRATION_TIME_SECONDS) { + throw new APIError({ + message: "Maximum time window to start process exceeded. Ramp invalidated.", + status: httpStatus.BAD_REQUEST + }); + } + } + /** * Register a new ramping process. This will create a new ramp state and create transactions that need to be signed * on the client side. */ public async registerRamp(request: RegisterRampRequest, _route = "/v1/ramp/register"): Promise { + const recipientContextKeys = [ + "recipientId", + "recipientRelationshipId", + "recipientPayoutReferenceId", + "senderRecipientId" + ] as const; + const unsupportedRecipientKey = recipientContextKeys.find(key => + Object.prototype.hasOwnProperty.call(request.additionalData ?? {}, key) + ); + if (unsupportedRecipientKey) { + throw new APIError({ + message: "Recipient-directed payout is not supported by ramp registration; recipient eligibility is advisory only.", + status: httpStatus.BAD_REQUEST + }); + } + return this.withTransaction(async transaction => { const { signingAccounts, quoteId, additionalData } = request; @@ -235,9 +255,23 @@ export class RampService extends BaseRampService { }); } + const technicalManagedProfile = await PartnerManagedProfile.findOne({ + attributes: ["id"], + transaction, + where: { profileId: effectiveUserId, subjectType: "technical" } + }); + if (technicalManagedProfile) { + throw new APIError({ + isPublic: true, + message: "Technical managed profiles are not eligible to register ramps.", + status: httpStatus.FORBIDDEN, + type: "TECHNICAL_PROFILE_NOT_RAMP_ELIGIBLE" + }); + } + // 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). + // RUN_LIVE_TESTS-gated tests only — see docs/operations-testing.md). if (quote.inputCurrency === FiatToken.EURC || quote.outputCurrency === FiatToken.EURC) { throw new APIError({ message: "EUR ramps are currently disabled", @@ -284,13 +318,12 @@ export class RampService extends BaseRampService { }); } - await validateEphemeralAccountsFresh(ephemerals); + await validateEphemeralAccountsFresh(ephemerals, quote); const { unsignedTxs, stateMeta, depositQrCode, ibanPaymentData, aveniaTicketId } = await this.prepareRampTransactions( quote, normalizedSigningAccounts, additionalData, - signingAccounts, transaction, effectiveUserId ); @@ -405,6 +438,8 @@ export class RampService extends BaseRampService { }); } + RampService.assertStartDeadlineNotExceeded(rampState); + // Validate presigned transactions, if some were supplied const ephemerals: { [key in EphemeralAccountType]: string } = { EVM: rampState.state.evmEphemeralAddress, @@ -448,14 +483,7 @@ export class RampService extends BaseRampService { const presignChecksPass = await this.tryReleaseDepositQr(rampState, quote, transaction); const ephemeralPresignChecksPass = presignChecksPass || (await this.ephemeralPresignChecksPass(rampState)); - let achPaymentData: AlfredpayFiatPaymentInstructions | undefined = undefined; - if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { - achPaymentData = await this.processAlfredpayOnrampStart(rampState, quote, transaction); - } - - if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { - await this.processAlfredpayOfframpStart(rampState, quote, transaction); - } + const { achPaymentData } = await this.startPersistedFlow(rampState, quote, transaction); // Create response const response: UpdateRampResponse = { @@ -520,17 +548,7 @@ export class RampService extends BaseRampService { } this.validateRampStateData(rampState, quote); - - const rampStateCreationTime = new Date(rampState.createdAt); - const currentTime = new Date(); - const timeDifferenceSeconds = (currentTime.getTime() - rampStateCreationTime.getTime()) / 1000; - - if (timeDifferenceSeconds > RAMP_START_EXPIRATION_TIME_SECONDS) { - throw new APIError({ - message: "Maximum time window to start process exceeded. Ramp invalidated.", - status: httpStatus.BAD_REQUEST - }); - } + RampService.assertStartDeadlineNotExceeded(rampState); // Check if presigned transactions are available (should be set by updateRamp) if (!rampState.presignedTxs || rampState.presignedTxs.length === 0) { @@ -547,6 +565,8 @@ export class RampService extends BaseRampService { }; await validatePresignedTxs(rampState.type, rampState.presignedTxs, ephemerals, rampState.unsignedTxs); + await this.startPersistedFlow(rampState, quote, transaction); + logger.log("Triggering TRANSACTION_CREATED webhook for ramp state:", rampState.id); webhookDeliveryService .triggerTransactionCreated( @@ -616,8 +636,9 @@ export class RampService extends BaseRampService { }); } - const usdFees = quote.metadata.fees?.usd; - const fiatFees = quote.metadata.fees?.displayFiat; + const { fees, subsidyDisplay } = getFlowMetadata(quote.metadata).globals; + const usdFees = fees.usd; + const fiatFees = fees.displayFiat; if (!usdFees || !fiatFees) { throw new APIError({ message: "Quote fee structure is incomplete", @@ -691,11 +712,11 @@ export class RampService extends BaseRampService { quoteId: rampState.quoteId, sessionId: rampState.state.sessionId, status: this.mapPhaseToStatus(rampState.currentPhase), - ...(quote.metadata.subsidyDisplay + ...(subsidyDisplay ? { - discountCurrency: quote.metadata.subsidyDisplay.currency, - discountFiat: quote.metadata.subsidyDisplay.fiat, - discountUsd: quote.metadata.subsidyDisplay.usd + discountCurrency: subsidyDisplay.currency, + discountFiat: subsidyDisplay.fiat, + discountUsd: subsidyDisplay.usd } : {}), to: rampState.to, @@ -867,469 +888,86 @@ export class RampService extends BaseRampService { }); } - /** - * Sum the BRL-equivalent volume of all in-progress ramps for a given taxId and direction. - */ - private async getPendingBrlVolume(taxId: string, direction: RampDirection): Promise { - const normalizedTaxId = normalizeTaxId(taxId); - - const pendingRamps = await RampState.findAll({ - include: [{ as: "quote", model: QuoteTicket }], - where: { - currentPhase: { [Op.notIn]: ["complete", "failed", "timedOut", "initial"] }, - "state.taxId": normalizedTaxId, - type: direction - } - }); - - let totalPendingBrl = new Big(0); - for (const ramp of pendingRamps) { - const quote = (ramp as RampState & { quote: QuoteTicket }).quote; - if (!quote) continue; - - const brlAmount = direction === RampDirection.BUY ? quote.inputAmount : quote.outputAmount; - totalPendingBrl = totalPendingBrl.plus(brlAmount); - } - - return totalPendingBrl; - } - - /** - * Validate the ramp amount against both per-currency (BRL) and global (*) limits, - * accounting for pending ramp volume that hasn't settled on Avenia yet. - */ - private async validateAveniaLimits( - amountBrl: string, - limits: Limit[], - direction: RampDirection, - taxId: string - ): Promise { - const pendingBrl = await this.getPendingBrlVolume(taxId, direction); - const effectiveAmountBrl = new Big(amountBrl).plus(pendingBrl); - - const brlLimits = limits.find(limit => limit.currency === BrlaCurrency.BRL); - if (!brlLimits) { - throw new APIError({ - message: "BRL limits not found.", - status: httpStatus.BAD_REQUEST - }); - } - - const brlRemaining = - direction === RampDirection.BUY - ? Number(brlLimits.maxFiatIn) - Number(brlLimits.usedLimit.usedFiatIn) - : Number(brlLimits.maxFiatOut) - Number(brlLimits.usedLimit.usedFiatOut); - - if (effectiveAmountBrl.gt(brlRemaining)) { - throw new APIError({ - message: "Amount exceeds BRL limit.", - status: httpStatus.BAD_REQUEST - }); - } - - const globalLimits = limits.find(limit => limit.currency === "*"); - if (globalLimits) { - const priceFeedService = PriceFeedService.getInstance(); - const effectiveAmountUsd = await priceFeedService.convertCurrency( - effectiveAmountBrl.toFixed(2), - FiatToken.BRL, - FiatToken.USD, - 2 - ); - - const globalRemaining = - direction === RampDirection.BUY - ? Number(globalLimits.maxFiatIn) - Number(globalLimits.usedLimit.usedFiatIn) - : Number(globalLimits.maxFiatOut) - Number(globalLimits.usedLimit.usedFiatOut); - - if (Number(effectiveAmountUsd) > globalRemaining) { - throw new APIError({ - message: "Amount exceeds global limit.", - status: httpStatus.BAD_REQUEST - }); - } - } - } - - /** - * BRLA. Get subaccount and validate pix and tax id. - */ - public async validateBrlaOfframpRequest( - taxId: string, - pixKey: string, - receiverTaxId: string, - amount: string - ): Promise<{ wallets: { evm: string }; brCode: string }> { - const brlaApiService = BrlaApiService.getInstance(); - - const aveniaCustomer = await findAveniaCustomerByTaxId(taxId); - if (!aveniaCustomer) { - throw new APIError({ - message: "Subaccount not found", - status: httpStatus.BAD_REQUEST - }); - } - const aveniaSubAccountId = aveniaCustomer.providerSubaccountId ?? ""; - const subAccountData = await brlaApiService.subaccountInfo(aveniaSubAccountId); - const subaccountLimits = await brlaApiService.getSubaccountUsedLimit(aveniaSubAccountId); - if (!subaccountLimits) { - throw new APIError({ - message: "Failed to fetch subaccount limits", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - - // To make it harder to extract information, both the pixKey and the receiverTaxId are required to be correct. - // The user-facing error stays generic, but server-side logs differentiate failure modes for diagnosis. - let pixKeyData; - try { - pixKeyData = await brlaApiService.validatePixKey(pixKey); - } catch (error) { - logger.warn( - `validateBrlaOfframpRequest: pix-info lookup failed for pixKey=${pixKey}: ${ - error instanceof Error ? error.message : String(error) - }` - ); - throw new APIError({ - message: "Invalid pixKey or receiverTaxId.", - status: httpStatus.BAD_REQUEST - }); - } - - let masksMatch: boolean; - try { - // Do NOT pass the masked taxId through normalizeTaxId: that helper strips all - // non-digits, which would also strip the `*` mask characters and break the - // length-aligned comparison done by validateMaskedNumber. - masksMatch = validateMaskedNumber(pixKeyData.taxId, normalizeTaxId(receiverTaxId)); - } catch (error) { - logger.warn( - `validateBrlaOfframpRequest: pix key owner taxId is not comparable to receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId( - receiverTaxId - )}: ${error instanceof Error ? error.message : String(error)}` - ); - throw new APIError({ - message: "Invalid pixKey or receiverTaxId.", - status: httpStatus.BAD_REQUEST - }); - } - - if (!masksMatch) { - logger.warn( - `validateBrlaOfframpRequest: pix key owner taxId does not match receiverTaxId. masked=${pixKeyData.taxId}, provided=${normalizeTaxId(receiverTaxId)}` - ); - throw new APIError({ - message: "Invalid pixKey or receiverTaxId.", - status: httpStatus.BAD_REQUEST - }); - } - - await this.validateAveniaLimits(amount, subaccountLimits.limitInfo.limits, RampDirection.SELL, taxId); - - const evmAddress = subAccountData?.wallets.find(w => w.chain === "EVM")?.walletAddress; - - if (!evmAddress) { - throw new APIError({ - message: "EVM wallet not found in subaccount.", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - - return { brCode: subAccountData.brCode, wallets: { evm: evmAddress } }; - } - - /** - * BRLA. Validate the onramp request. Returns appropiate pay in code if valid. - */ - public async validateBrlaOnrampRequest( - taxId: string, - quote: QuoteTicket, - amount: string - ): Promise<{ brCode: string; aveniaTicketId: string }> { - const brlaApiService = BrlaApiService.getInstance(); - - const aveniaCustomer = await findAveniaCustomerByTaxId(taxId); - if (!aveniaCustomer) { - throw new APIError({ - message: "Subaccount not found.", - status: httpStatus.BAD_REQUEST - }); - } - const aveniaSubAccountId = aveniaCustomer.providerSubaccountId ?? ""; - - const accountLimits = await brlaApiService.getSubaccountUsedLimit(aveniaSubAccountId); - if (!accountLimits) { - throw new APIError({ - message: "Failed to fetch subaccount limits.", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - - await this.validateAveniaLimits(amount, accountLimits.limitInfo.limits, RampDirection.BUY, taxId); - - const aveniaQuote = await brlaApiService.createPayInQuote({ - inputAmount: String(amount), - inputCurrency: BrlaCurrency.BRL, - inputPaymentMethod: AveniaPaymentMethod.PIX, - inputThirdParty: false, - outputCurrency: BrlaCurrency.BRLA, - outputPaymentMethod: AveniaPaymentMethod.INTERNAL, - outputThirdParty: false, - subAccountId: aveniaSubAccountId - }); - - const aveniaTicket = await brlaApiService.createPixInputTicket( - { - quoteToken: aveniaQuote.quoteToken, - ticketBlockchainOutput: { - // This means we are paying out to the subAccount itself. - beneficiaryWalletId: "00000000-0000-0000-0000-000000000000" - }, - ticketBrlPixInput: { - additionalData: generateReferenceLabel(quote) - } - }, - aveniaSubAccountId - ); - - return { aveniaTicketId: aveniaTicket.id, brCode: aveniaTicket.brCode }; - } - - private async prepareOfframpBrlTransactions( - quote: QuoteTicket, - normalizedSigningAccounts: AccountMeta[], - additionalData: RegisterRampRequest["additionalData"], - userId: string - ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; depositQrCode?: string }> { - 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( - derivedTaxId, - additionalData.pixDestination, - derivedReceiverTaxId, - quote.outputAmount - ); - - const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({ - brlaEvmAddress: subaccount.wallets.evm, - pixDestination: additionalData.pixDestination, - quote, - receiverTaxId: derivedReceiverTaxId, - signingAccounts: normalizedSigningAccounts, - taxId: derivedTaxId, - userAddress: additionalData.walletAddress, - userId - }); - - return { depositQrCode: subaccount.brCode, stateMeta, unsignedTxs }; - } - - private async prepareOfframpNonBrlTransactions( + private async prepareRampTransactions( quote: QuoteTicket, normalizedSigningAccounts: AccountMeta[], additionalData: RegisterRampRequest["additionalData"], transaction: Transaction, 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, - userId, - transaction - ); - } - - const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({ - destinationAddress: additionalData?.destinationAddress, - email: additionalData?.email, - fiatAccountId: additionalData?.fiatAccountId as string | undefined, - ipAddress: additionalData?.ipAddress, - quote, - signingAccounts: normalizedSigningAccounts, - userAddress: additionalData?.walletAddress, - userId - }); - - return { stateMeta, unsignedTxs }; - } - - private async prepareAveniaOnrampTransactions( - quote: QuoteTicket, - normalizedSigningAccounts: AccountMeta[], - additionalData: RegisterRampRequest["additionalData"], - signingAccounts: AccountMeta[], - userId: string - ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; depositQrCode: string; aveniaTicketId: string }> { - if (!additionalData || !additionalData.destinationAddress) { - throw new APIError({ - message: "Parameter destinationAddress is required for onramp", - status: httpStatus.BAD_REQUEST - }); - } - - const hasEvmEphemeral = signingAccounts.some(ephemeral => ephemeral.type === EphemeralAccountType.EVM); - if (!hasEvmEphemeral) { - throw new APIError({ - message: "Base ephemeral not found", - status: httpStatus.BAD_REQUEST - }); - } - - 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: derivedTaxId - }; - - const { unsignedTxs, stateMeta } = await prepareOnrampTransactions(params); - - return { aveniaTicketId, depositQrCode: brCode, stateMeta: stateMeta as Partial, unsignedTxs }; - } - - private async prepareAlfredpayOnrampTransactions( - quote: QuoteTicket, - normalizedSigningAccounts: AccountMeta[], - additionalData: RegisterRampRequest["additionalData"], - userId: string - ): Promise<{ - unsignedTxs: UnsignedTx[]; - stateMeta: Partial; - }> { - if (!additionalData || !additionalData.destinationAddress) { - throw new APIError({ - message: "Parameter destinationAddress is required for Alfredpay onramp", - status: httpStatus.BAD_REQUEST - }); - } - - await resolveAlfredpayCustomerId(quote.inputCurrency, userId); - - const { unsignedTxs, stateMeta } = await prepareOnrampTransactions({ - destinationAddress: additionalData.destinationAddress, - quote, - signingAccounts: normalizedSigningAccounts, - userId - }); - - return { stateMeta: stateMeta as Partial, unsignedTxs }; - } - - private async prepareMykoboOnrampTransactions( - quote: QuoteTicket, - normalizedSigningAccounts: AccountMeta[], - additionalData: RegisterRampRequest["additionalData"], - userId: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; + depositQrCode?: string; + aveniaTicketId?: string; ibanPaymentData?: IbanPaymentData; }> { - if (!additionalData?.destinationAddress || !additionalData?.ipAddress) { + if ( + (quote.inputCurrency === FiatToken.EURC || quote.outputCurrency === FiatToken.EURC) && + (!additionalData?.destinationAddress || !additionalData.ipAddress) + ) { throw new APIError({ - message: "Parameters destinationAddress and ipAddress are required for Mykobo EUR onramp", + message: `Parameters destinationAddress and ipAddress are required for Mykobo EUR ${quote.rampType === RampDirection.BUY ? "onramp" : "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 } = await resolveMykoboCustomerForUser(userId, additionalData.email); - - const evmEphemeralEntry = normalizedSigningAccounts.find(account => account.type === "EVM"); - if (!evmEphemeralEntry) { + if (quote.rampType === RampDirection.BUY && !additionalData?.destinationAddress) { + const provider = isAlfredpayToken(quote.inputCurrency as FiatToken) ? "Alfredpay " : ""; throw new APIError({ - message: "EVM ephemeral account is required for Mykobo EUR onramp", + message: `Parameter destinationAddress is required for ${provider}onramp`, status: httpStatus.BAD_REQUEST }); } - - const mykobo = MykoboApiService.getInstance(); - const intent = await mykobo.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: evmEphemeralEntry.address - }); - - const instructions = intent.instructions; - if (!instructions || !("iban" in instructions)) { - throw new APIError({ - message: "Mykobo deposit intent did not return IBAN instructions", - status: httpStatus.BAD_GATEWAY - }); + if (quote.rampType === RampDirection.BUY && quote.inputCurrency === FiatToken.BRL) { + if (!normalizedSigningAccounts.some(account => account.type === EphemeralAccountType.EVM)) { + throw new APIError({ message: "Base ephemeral not found", status: httpStatus.BAD_REQUEST }); + } + if ( + quote.to === Networks.AssetHub && + !normalizedSigningAccounts.some(account => account.type === EphemeralAccountType.Substrate) + ) { + throw new APIError({ message: "Pendulum ephemeral not found", status: httpStatus.BAD_REQUEST }); + } } - 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 metadata = getFlowMetadata(quote.metadata); + const flow = resolvePersistedBlockFlow(metadata); + const quoteFields = quote.get({ plain: true }); + const registered = await flow.register({ + authenticatedUser: { id: userId }, + input: additionalData ?? {}, + ipAddress: additionalData?.ipAddress, + metadata, + quote: quoteFields, + signingAccounts: normalizedSigningAccounts, + transaction }); - - const ibanPaymentData: IbanPaymentData = { - bic: "", - iban: instructions.iban, - receiverName: instructions.bank_account_name, - reference: intent.transaction.reference + await quote.update({ metadata: registered.metadata as unknown as QuoteTicket["metadata"] }, { transaction }); + const prepared = await flow.prepareTxs({ + accounts: accountCapabilities(normalizedSigningAccounts), + destinationAddress: additionalData?.destinationAddress, + metadata: registered.metadata, + quote: quoteFields, + registrationFacts: registered.registrationFacts, + userId + }); + const compatibilityState = mergeCompatibilityRecords("Prepared ramp state", [ + ...Object.values(registered.registrationFacts), + ...Object.values(prepared.stateMeta.blockState ?? {}) + ]) as Partial; + const responseArtifacts = mergeCompatibilityRecords( + "Ramp registration response", + Object.values(registered.responseArtifacts) + ) as { + aveniaTicketId?: string; + depositQrCode?: string; + ibanPaymentData?: IbanPaymentData; + }; + return { + ...responseArtifacts, + aveniaTicketId: responseArtifacts.aveniaTicketId ?? compatibilityState.aveniaTicketId, + stateMeta: { ...prepared.stateMeta, ...compatibilityState }, + unsignedTxs: prepared.unsignedTxs }; - - return { ibanPaymentData, stateMeta: stateMeta as Partial, unsignedTxs }; - } - - private async prepareRampTransactions( - quote: QuoteTicket, - normalizedSigningAccounts: AccountMeta[], - additionalData: RegisterRampRequest["additionalData"], - signingAccounts: AccountMeta[], - transaction: Transaction, - userId: string - ): Promise<{ - unsignedTxs: UnsignedTx[]; - stateMeta: Partial; - depositQrCode?: string; - aveniaTicketId?: string; - ibanPaymentData?: IbanPaymentData; - }> { - switch (selectRampTransactionPreparationKind(quote, additionalData)) { - case RampTransactionPreparationKind.OfframpBrl: - return this.prepareOfframpBrlTransactions(quote, normalizedSigningAccounts, additionalData, userId); - - case RampTransactionPreparationKind.OfframpNonBrl: - return this.prepareOfframpNonBrlTransactions(quote, normalizedSigningAccounts, additionalData, transaction, userId); - - case RampTransactionPreparationKind.OnrampMykobo: - return this.prepareMykoboOnrampTransactions(quote, normalizedSigningAccounts, additionalData, userId); - - case RampTransactionPreparationKind.OnrampAlfredpay: - return this.prepareAlfredpayOnrampTransactions(quote, normalizedSigningAccounts, additionalData, userId); - - case RampTransactionPreparationKind.OnrampAvenia: - return this.prepareAveniaOnrampTransactions(quote, normalizedSigningAccounts, additionalData, signingAccounts, userId); - } } private async ephemeralPresignChecksPass(rampState: RampState): Promise { @@ -1430,251 +1068,26 @@ export class RampService extends BaseRampService { } } - private async processAlfredpayOnrampStart( + private async startPersistedFlow( rampState: RampState, quote: QuoteTicket, transaction: Transaction - ): Promise { - if (rampState.state.alfredpayTransactionId) { - return; - } - - const alfredpayService = AlfredpayApiService.getInstance(); - const originalAlfredpayMint = quote.metadata.alfredpayMint; - const originalQuoteId = originalAlfredpayMint?.quoteId; - - if (!originalQuoteId || !originalAlfredpayMint) { - throw new APIError({ - message: "Missing Alfredpay quote ID in metadata", - status: httpStatus.BAD_REQUEST - }); - } - - if (!rampState.userId) { - throw new APIError({ - message: "Missing user ID in ramp state", - status: httpStatus.BAD_REQUEST - }); - } - - if (!rampState.state.destinationAddress) { - throw new APIError({ - message: "Destination address not found in ramp state", - status: httpStatus.BAD_REQUEST - }); - } - - if (!rampState.state.alfredpayUserId) { - throw new APIError({ - message: "Missing Alfredpay user ID in ramp state", - status: httpStatus.BAD_REQUEST - }); - } - - // Alfredpay quotes expire ~30s after creation, which is often shorter than the time the - // user needs to sign ephemeral txs in the UI. Try refreshing the Alfredpay quote - const fromCurrency = quote.inputCurrency as unknown as AlfredpayFiatCurrency; - const effectiveQuoteId = await this.refreshAlfredpayOnrampQuoteIfMatching( - quote, - originalAlfredpayMint, - fromCurrency, - rampState.userId, - transaction - ); - - const orderRequest: CreateAlfredpayOnrampRequest = { - amount: quote.inputAmount, - chain: AlfredpayChain.MATIC, - customerId: rampState.state.alfredpayUserId, - depositAddress: rampState.state.evmEphemeralAddress, - fromCurrency, - paymentMethodType: AlfredpayPaymentMethodType.BANK, - quoteId: effectiveQuoteId, - toCurrency: ALFREDPAY_ONCHAIN_CURRENCY - }; - - const order = await alfredpayService.createOnramp(orderRequest); - - await rampState.update( - { - state: { - ...rampState.state, - alfredpayTransactionId: order.transaction.transactionId, - fiatPaymentInstructions: order.fiatPaymentInstructions - } - }, - { transaction } - ); - - return order.fiatPaymentInstructions; - } - - private async refreshAlfredpayOnrampQuoteIfMatching( - 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, - fromAmount: new Big(quote.inputAmount).toString(), - fromCurrency, - metadata: { - businessId: "vortex", - customerId - }, - paymentMethodType: AlfredpayPaymentMethodType.BANK, - toCurrency: ALFREDPAY_ONCHAIN_CURRENCY - }); - - // outputAmountDecimal arrives as a serialized Big after JSONB roundtrip; normalize via Big(). - const originalToAmount = new Big(originalAlfredpayMint.outputAmountDecimal as unknown as string); - const freshToAmount = new Big(freshQuote.toAmount); - - const originalFee = new Big(originalAlfredpayMint.fee as unknown as string); - const freshFee = AlfredpayApiService.sumFeesByCurrency(freshQuote.fees, fromCurrency); - - if (!freshToAmount.eq(originalToAmount) || !freshFee.eq(originalFee)) { - logger.warn( - `[refreshAlfredpayOnrampQuote] Quote ${quote.id}: refreshed Alfredpay quote drifted. ` + - `toAmount original=${originalToAmount.toString()} fresh=${freshToAmount.toString()}, ` + - `fee original=${originalFee.toString()} fresh=${freshFee.toString()}. ` + - `Falling back to original quoteId ${originalQuoteId}.` - ); - return originalQuoteId; - } - - await quote.update( - { - metadata: { - ...quote.metadata, - alfredpayMint: { - ...originalAlfredpayMint, - expirationDate: new Date(freshQuote.expiration), - quoteId: freshQuote.quoteId - } - } - }, - { transaction } - ); - - logger.info( - `[refreshAlfredpayOnrampQuote] Quote ${quote.id}: swapped Alfredpay quote ${originalQuoteId} -> ${freshQuote.quoteId}.` - ); - return freshQuote.quoteId; - } catch (error) { - logger.warn( - `[refreshAlfredpayOnrampQuote] Quote ${quote.id}: refresh failed (${ - error instanceof Error ? error.message : String(error) - }). Falling back to original quoteId ${originalQuoteId}.` - ); - return originalQuoteId; - } - } - - private async refreshAlfredpayOfframpQuoteIfMatching( - 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 }, - paymentMethodType: AlfredpayPaymentMethodType.BANK, - toCurrency - } satisfies CreateAlfredpayOfframpQuoteRequest); - - const originalToAmount = new Big(originalAlfredpayOfframp.outputAmountDecimal as unknown as string); - const freshToAmount = new Big(freshQuote.toAmount); - - const originalFee = new Big(originalAlfredpayOfframp.fee as unknown as string); - const freshFee = AlfredpayApiService.sumFeesByCurrency(freshQuote.fees, toCurrency); - - if (!freshToAmount.eq(originalToAmount) || !freshFee.eq(originalFee)) { - throw new APIError({ - message: - `[refreshAlfredpayOfframpQuote] Quote ${quote.id}: refreshed Alfredpay offramp quote drifted. ` + - `toAmount original=${originalToAmount.toString()} fresh=${freshToAmount.toString()}, ` + - `fee original=${originalFee.toString()} fresh=${freshFee.toString()}. ` + - "Cannot proceed with offramp order.", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - - await quote.update( - { - metadata: { - ...quote.metadata, - alfredpayOfframp: { - ...originalAlfredpayOfframp, - expirationDate: new Date(freshQuote.expiration), - quoteId: freshQuote.quoteId - } - } - }, - { transaction } - ); - - logger.info( - `[refreshAlfredpayOfframpQuote] Quote ${quote.id}: swapped Alfredpay offramp quote ${originalQuoteId} -> ${freshQuote.quoteId}.` - ); - return freshQuote.quoteId; - } - - private async processAlfredpayOfframpStart( - rampState: RampState, - quote: QuoteTicket, - transaction: Transaction - ): Promise { - if (rampState.state.alfredpayTransactionId) { - return; - } - - const alfredpayQuoteId = quote.metadata.alfredpayOfframp?.quoteId; - - if (!alfredpayQuoteId) { - throw new APIError({ - message: "Missing Alfredpay quote ID in metadata", - status: httpStatus.BAD_REQUEST - }); - } - - if (!rampState.state.alfredpayUserId) { - throw new APIError({ - message: "Missing Alfredpay user ID in ramp state", - status: httpStatus.BAD_REQUEST - }); - } - - if (!rampState.state.fiatAccountId) { - throw new APIError({ - message: "Missing fiatAccountId in ramp state", - status: httpStatus.BAD_REQUEST - }); + ): Promise<{ achPaymentData?: AlfredpayFiatPaymentInstructions }> { + const metadata = getFlowMetadata(quote.metadata); + const started = await resolvePersistedBlockFlow(metadata).start({ + metadata, + quote: quote.get({ plain: true }), + rampId: rampState.id, + state: rampState.state, + userId: rampState.userId ?? undefined + }); + if (started.metadata !== metadata) { + await quote.update({ metadata: started.metadata as unknown as QuoteTicket["metadata"] }, { transaction }); } - - if (!rampState.state.walletAddress) { - throw new APIError({ - message: "Wallet address not found in ramp state", - status: httpStatus.BAD_REQUEST - }); + if (started.state !== rampState.state) { + await rampState.update({ state: started.state }, { transaction }); } + return Object.assign({}, ...Object.values(started.responseArtifacts)); } } diff --git a/apps/api/src/api/services/rampInfo.service.test.ts b/apps/api/src/api/services/rampInfo.service.test.ts new file mode 100644 index 000000000..6009d2555 --- /dev/null +++ b/apps/api/src/api/services/rampInfo.service.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import CustomerEntity from "../../models/customerEntity.model"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; +import { getRampInfo } from "./rampInfo.service"; + +const originalEntityFindAll = CustomerEntity.findAll; +const originalCustomerFindAll = ProviderCustomer.findAll; + +afterEach(() => { + CustomerEntity.findAll = originalEntityFindAll; + ProviderCustomer.findAll = originalCustomerFindAll; +}); + +describe("getRampInfo", () => { + it("returns only sanitized corridor eligibility for the credential profile", async () => { + CustomerEntity.findAll = mock(async options => { + expect(options?.where).toEqual({ profileId: "profile-1" }); + return [{ id: "entity-1" }]; + }) as never; + ProviderCustomer.findAll = mock(async () => [ + { country: null, provider: "avenia", status: VerificationStatus.Approved }, + { country: "MX", provider: "alfredpay", status: VerificationStatus.InReview }, + { country: "US", provider: "alfredpay", status: VerificationStatus.Rejected } + ]) as never; + + const result = await getRampInfo("profile-1"); + + expect(result).toEqual({ + corridors: { + AR: { canBuy: false, canSell: false, kycStatus: "not_started" }, + BR: { canBuy: true, canSell: true, kycStatus: "approved" }, + CO: { canBuy: false, canSell: false, kycStatus: "not_started" }, + MX: { canBuy: false, canSell: false, kycStatus: "pending" }, + US: { canBuy: false, canSell: false, kycStatus: "rejected" } + } + }); + expect(JSON.stringify(result)).not.toMatch(/profile|customer|provider|limit|reason/i); + }); + + it("does not query provider records when the profile has no customer entity", async () => { + CustomerEntity.findAll = mock(async () => []) as never; + ProviderCustomer.findAll = mock(async () => []) as never; + + const result = await getRampInfo("profile-1"); + + expect(ProviderCustomer.findAll).not.toHaveBeenCalled(); + expect(Object.values(result.corridors).every(corridor => corridor.kycStatus === "not_started")).toBe(true); + }); +}); diff --git a/apps/api/src/api/services/rampInfo.service.ts b/apps/api/src/api/services/rampInfo.service.ts new file mode 100644 index 000000000..304d62ff1 --- /dev/null +++ b/apps/api/src/api/services/rampInfo.service.ts @@ -0,0 +1,43 @@ +import { GetRampInfoResponse } from "@vortexfi/shared"; +import CustomerEntity from "../../models/customerEntity.model"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; + +const CORRIDORS = ["AR", "BR", "CO", "MX", "US"] as const; +type RampInfoStatus = GetRampInfoResponse["corridors"][string]["kycStatus"]; + +function corridorFor(customer: ProviderCustomer): (typeof CORRIDORS)[number] | null { + if (customer.provider === "avenia") return "BR"; + if (customer.provider !== "alfredpay") return null; + + const country = customer.country?.toUpperCase(); + return CORRIDORS.find(corridor => corridor === country) ?? null; +} + +function collapseStatus(statuses: VerificationStatus[]): RampInfoStatus { + if (statuses.includes(VerificationStatus.Approved)) return "approved"; + if (statuses.some(status => status !== VerificationStatus.Rejected)) return "pending"; + if (statuses.length > 0) return "rejected"; + return "not_started"; +} + +export async function getRampInfo(profileId: string): Promise { + const entities = await CustomerEntity.findAll({ attributes: ["id"], where: { profileId } }); + const customers = entities.length + ? await ProviderCustomer.findAll({ + attributes: ["country", "provider", "status"], + where: { customerEntityId: entities.map(entity => entity.id) } + }) + : []; + + return { + corridors: Object.fromEntries( + CORRIDORS.map(corridor => { + const status = collapseStatus( + customers.filter(customer => corridorFor(customer) === corridor).map(customer => customer.status) + ); + const approved = status === "approved"; + return [corridor, { canBuy: approved, canSell: approved, kycStatus: status }]; + }) + ) + }; +} diff --git a/apps/api/src/api/services/siwe.service.ts b/apps/api/src/api/services/siwe.service.ts index d2748c1bb..862fd2271 100644 --- a/apps/api/src/api/services/siwe.service.ts +++ b/apps/api/src/api/services/siwe.service.ts @@ -141,7 +141,7 @@ export const validateSignatureAndGetMemo = async ( userChallengeSignature: string | null ): Promise => { if (!userChallengeSignature || !nonce) { - return null; // Default memo value when single stellar account is used + return null; // No memo can be derived without a signed nonce } try { diff --git a/apps/api/src/api/services/transactions/base/cleanup.ts b/apps/api/src/api/services/transactions/base/cleanup.ts deleted file mode 100644 index 0c836f752..000000000 --- a/apps/api/src/api/services/transactions/base/cleanup.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { EvmClientManager, EvmNetworks, EvmTransactionData } from "@vortexfi/shared"; -import { encodeFunctionData } from "viem/utils"; -import erc20ABI from "../../../../contracts/ERC20"; - -export async function prepareBaseCleanupApproval( - tokenAddress: `0x${string}`, - fundingAddress: string, - network: EvmNetworks -): Promise { - const maxUint256 = (2n ** 256n - 1n).toString(); - - const approveCallData = encodeFunctionData({ - abi: erc20ABI, - args: [fundingAddress, maxUint256], - functionName: "approve" - }); - - const evmClientManager = EvmClientManager.getInstance(); - const publicClient = evmClientManager.getClient(network); - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - - return { - data: approveCallData as `0x${string}`, - gas: "100000", - maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxPriorityFeePerGas), - to: tokenAddress, - value: "0" - }; -} diff --git a/apps/api/src/api/services/transactions/index.ts b/apps/api/src/api/services/transactions/index.ts deleted file mode 100644 index 0f7bd9dba..000000000 --- a/apps/api/src/api/services/transactions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function encodeEvmTransactionData(data: unknown) { - // We don't need to stringify this and can just return the plain JSON - return data; -} diff --git a/apps/api/src/api/services/transactions/moonbeam/balance.ts b/apps/api/src/api/services/transactions/moonbeam/balance.ts index d50757ece..acaf57d5c 100644 --- a/apps/api/src/api/services/transactions/moonbeam/balance.ts +++ b/apps/api/src/api/services/transactions/moonbeam/balance.ts @@ -1,7 +1,7 @@ import { ApiManager, EvmClientManager, multiplyByPowerOfTen, Networks } from "@vortexfi/shared"; import logger from "../../../../config/logger"; import { MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../constants/constants"; -import { getEvmFundingAccount } from "../../phases/evm-funding"; +import { getEvmFundingAccount } from "../../phases/blocks/core/evm-funding"; export const fundMoonbeamEphemeralAccount = async (ephemeralAddress: string) => { try { diff --git a/apps/api/src/api/services/transactions/moonbeam/cleanup.ts b/apps/api/src/api/services/transactions/moonbeam/cleanup.ts index a2c62f20e..f14c6c2d2 100644 --- a/apps/api/src/api/services/transactions/moonbeam/cleanup.ts +++ b/apps/api/src/api/services/transactions/moonbeam/cleanup.ts @@ -1,7 +1,7 @@ import { SubmittableExtrinsic } from "@polkadot/api/types"; import { ISubmittableResult } from "@polkadot/types/types"; import { ApiManager, Networks } from "@vortexfi/shared"; -import { getEvmFundingAccount } from "../../phases/evm-funding"; +import { getEvmFundingAccount } from "../../phases/blocks/core/evm-funding"; export async function prepareMoonbeamCleanupTransaction(): Promise> { const apiManager = ApiManager.getInstance(); diff --git a/apps/api/src/api/services/transactions/offramp/common/transactions.ts b/apps/api/src/api/services/transactions/offramp/common/transactions.ts deleted file mode 100644 index 03328188d..000000000 --- a/apps/api/src/api/services/transactions/offramp/common/transactions.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { - AccountMeta, - AMM_MINIMUM_OUTPUT_HARD_MARGIN, - AMM_MINIMUM_OUTPUT_SOFT_MARGIN, - addAdditionalTransactionsToMeta, - createAssethubToPendulumXCM, - createNablaTransactionsForOfframp, - createOfframpSquidrouterTransactions, - createPaseoToPendulumXCM, - createPendulumToMoonbeamTransfer, - EvmTransactionData, - encodeSubmittableExtrinsic, - Networks, - PendulumTokenDetails, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import { encodeFunctionData } from "viem"; -import { config } from "../../../../../config/vars"; -import erc20ABI from "../../../../../contracts/ERC20"; -import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { encodeEvmTransactionData } from "../../index"; - -/** - * Creates transactions for EVM source networks using Squidrouter or mock transactions in sandbox - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param stateMeta State metadata to update - * @returns Updated state metadata - */ -export async function createEvmSourceTransactions( - params: { - userAddress: string; - pendulumEphemeralAddress: string; - fromNetwork: Networks; - inputAmountRaw: string; - fromToken: `0x${string}`; - toToken: `0x${string}`; - }, - unsignedTxs: UnsignedTx[] -): Promise> { - const { userAddress, pendulumEphemeralAddress, fromNetwork, inputAmountRaw, fromToken, toToken } = params; - - const squidResult = await createOfframpSquidrouterTransactions({ - fromAddress: userAddress, - fromNetwork, - fromToken, - pendulumAddressDestination: pendulumEphemeralAddress, - rawAmount: inputAmountRaw, - toToken - }); - - let { approveData, swapData } = squidResult; - const { squidRouterReceiverId, squidRouterReceiverHash, squidRouterQuoteId } = squidResult; - - // Override approveData and swapData in sandbox mode - if (config.sandboxEnabled) { - const sandboxTransactions = createSandboxEvmTransactions(inputAmountRaw); - approveData = sandboxTransactions.approveData; - swapData = sandboxTransactions.swapData; - } - - unsignedTxs.push({ - meta: {}, - network: config.sandboxEnabled ? Networks.PolygonAmoy : fromNetwork, - nonce: 0, - phase: "squidRouterApprove", - signer: userAddress, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: config.sandboxEnabled ? Networks.PolygonAmoy : fromNetwork, - nonce: 0, - phase: "squidRouterSwap", - signer: userAddress, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - - return { - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId - }; -} - -/** - * Creates transactions for AssetHub source networks - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param fromNetwork Source network - */ -export async function createAssetHubSourceTransactions( - params: { - userAddress: string; - pendulumEphemeralAddress: string; - inputAmountRaw: string; - }, - unsignedTxs: UnsignedTx[], - fromNetwork: Networks -): Promise { - const { userAddress, pendulumEphemeralAddress, inputAmountRaw } = params; - - // Create Assethub to Pendulum transaction - const assethubToPendulumTransaction = config.sandboxEnabled - ? await createPaseoToPendulumXCM(pendulumEphemeralAddress, "usdc", inputAmountRaw) - : await createAssethubToPendulumXCM(pendulumEphemeralAddress, "usdc", inputAmountRaw); - const originNetwork = config.sandboxEnabled ? Networks.Paseo : fromNetwork; - - unsignedTxs.push({ - meta: {}, - network: originNetwork, - nonce: 0, - phase: "assethubToPendulum", - signer: userAddress, - txData: encodeSubmittableExtrinsic(assethubToPendulumTransaction) - }); -} - -/** - * Creates Nabla swap transactions for Pendulum - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param nextNonce Next available nonce - * @returns Updated nonce and state metadata - */ -export async function createNablaSwapTransactions( - params: { - quote: QuoteTicketAttributes; - account: AccountMeta; - inputTokenPendulumDetails: PendulumTokenDetails; - outputTokenPendulumDetails: PendulumTokenDetails; - }, - unsignedTxs: UnsignedTx[], - nextNonce: number -): Promise<{ nextNonce: number; stateMeta: Partial }> { - const { quote, account, inputTokenPendulumDetails, outputTokenPendulumDetails } = params; - - if (!quote.metadata.nablaSwap?.inputAmountForSwapRaw) { - throw new Error("Missing nablaSwap input amount in quote metadata"); - } - - const inputAmountForNablaSwapRaw = quote.metadata.nablaSwap.inputAmountForSwapRaw; - const outputAmountRaw = Big(quote.metadata.nablaSwap.outputAmountRaw); - - const nablaSoftMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); - const nablaHardMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); - - const { approve, swap } = await createNablaTransactionsForOfframp( - inputAmountForNablaSwapRaw, - account, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - nablaHardMinimumOutputRaw - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: nextNonce, - phase: "nablaApprove", - signer: account.address, - txData: approve.transaction - }); - nextNonce++; - - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: nextNonce, - phase: "nablaSwap", - signer: account.address, - txData: swap.transaction - }); - nextNonce++; - - return { - nextNonce, - stateMeta: { - nabla: { - approveExtrinsicOptions: approve.extrinsicOptions, - swapExtrinsicOptions: swap.extrinsicOptions - }, - nablaSoftMinimumOutputRaw - } - }; -} - -/** - * Creates BRL-specific transactions for Pendulum to Moonbeam transfer - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param pendulumCleanupTx Cleanup transaction template - * @param nextNonce Next available nonce - * @returns Updated nonce and state metadata - */ -export async function createBRLTransactions( - params: { - brlaEvmAddress: string; - outputAmountRaw: string; - outputTokenPendulumDetails: PendulumTokenDetails; - account: AccountMeta; - taxId: string; - pixDestination: string; - receiverTaxId: string; - }, - unsignedTxs: UnsignedTx[], - pendulumCleanupTx: Omit, - nextNonce: number -): Promise<{ nextNonce: number; stateMeta: Partial }> { - const { brlaEvmAddress, outputAmountRaw, outputTokenPendulumDetails, account, taxId, pixDestination, receiverTaxId } = params; - - const pendulumToMoonbeamTransaction = await createPendulumToMoonbeamTransfer( - brlaEvmAddress, - outputAmountRaw, - outputTokenPendulumDetails.currencyId - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: nextNonce, - phase: "pendulumToMoonbeamXcm", - signer: account.address, - txData: encodeSubmittableExtrinsic(pendulumToMoonbeamTransaction) - }); - nextNonce++; - - // Add the cleanup transaction with the next nonce - unsignedTxs.push({ - ...pendulumCleanupTx, - nonce: nextNonce - }); - nextNonce++; - - return { - nextNonce, - stateMeta: { - brlaEvmAddress, - pixDestination, - receiverTaxId, - taxId - } - }; -} - -/** - * Creates mock approve and swap transactions for sandbox mode - * @param inputAmountRaw The raw input amount to approve - * @returns Mock approve and swap transaction data - */ -function createSandboxEvmTransactions(inputAmountRaw: string): { - approveData: EvmTransactionData; - swapData: EvmTransactionData; -} { - const USDC_POLYGON_AMOY = "0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582" as `0x${string}`; - const MOCK_SQUIDROUTER_RECEIVER = "0x1234567890123456789012345678901234567890" as `0x${string}`; - const approveTransactionData = encodeFunctionData({ - abi: erc20ABI, - args: [MOCK_SQUIDROUTER_RECEIVER, inputAmountRaw], - functionName: "approve" - }); - - const approveData: EvmTransactionData = { - data: approveTransactionData as `0x${string}`, - gas: "150000", - maxFeePerGas: "1000000000", - maxPriorityFeePerGas: "1000000000", - to: USDC_POLYGON_AMOY, - value: "0" - }; - - // Swap transaction: simply a native transfer to mock squidrouter swap. - const transferValue = "100000000000000"; - - const swapData: EvmTransactionData = { - data: "0x" as `0x${string}`, - gas: "21000", - maxFeePerGas: "1000000000", - maxPriorityFeePerGas: "1000000000", - to: MOCK_SQUIDROUTER_RECEIVER, - value: transferValue - }; - - return { approveData, swapData }; -} diff --git a/apps/api/src/api/services/transactions/offramp/common/types.ts b/apps/api/src/api/services/transactions/offramp/common/types.ts deleted file mode 100644 index 03405fb8c..000000000 --- a/apps/api/src/api/services/transactions/offramp/common/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AccountMeta, UnsignedTx } from "@vortexfi/shared"; -import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; - -export interface OfframpTransactionParams { - quote: QuoteTicketAttributes; - signingAccounts: AccountMeta[]; - userAddress?: string; - pixDestination?: string; - taxId?: string; - receiverTaxId?: string; - brlaEvmAddress?: string; - userId: string; - fiatAccountId?: string; - email?: string; - destinationAddress?: string; - ipAddress?: string; -} - -export interface OfframpTransactionsWithMeta { - unsignedTxs: UnsignedTx[]; - stateMeta: Partial>; -} diff --git a/apps/api/src/api/services/transactions/offramp/common/validation.ts b/apps/api/src/api/services/transactions/offramp/common/validation.ts deleted file mode 100644 index c6a11b7dd..000000000 --- a/apps/api/src/api/services/transactions/offramp/common/validation.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - AccountMeta, - FiatTokenDetails, - getAnyFiatTokenDetails, - getNetworkFromDestination, - getOnChainTokenDetails, - isFiatToken, - isOnChainToken, - normalizeTaxId -} from "@vortexfi/shared"; -import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; - -/** - * Validates offramp quote and returns required data - * @param quote The quote ticket - * @param signingAccounts The signing accounts - * @param options Set `requireSubstrateEphemeral: false` for EVM-only offramp routes (e.g. Mykobo on Base) - * @returns Validation result with required data - */ -export function validateOfframpQuote( - quote: QuoteTicketAttributes, - signingAccounts: AccountMeta[], - options: { requireSubstrateEphemeral?: boolean } = {} -) { - const { requireSubstrateEphemeral = true } = options; - const fromNetwork = getNetworkFromDestination(quote.from); - if (!fromNetwork) { - throw new Error(`Invalid network for destination ${quote.from}`); - } - - if (!isOnChainToken(quote.inputCurrency)) { - throw new Error(`Input currency must be on-chain token for offramp, got ${quote.inputCurrency}`); - } - - const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency); - if (!inputTokenDetails) { - throw new Error(`Input currency must be on-chain token for offramp, got ${quote.inputCurrency}`); - } - - if (!isFiatToken(quote.outputCurrency)) { - throw new Error(`Output currency must be fiat token for offramp, got ${quote.outputCurrency}`); - } - - const outputTokenDetails = getAnyFiatTokenDetails(quote.outputCurrency); - - const substrateEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "Substrate"); - if (requireSubstrateEphemeral && !substrateEphemeralEntry) { - throw new Error("Pendulum ephemeral not found"); - } - - return { - fromNetwork, - inputTokenDetails, - outputTokenDetails, - substrateEphemeralEntry - }; -} - -/** - * Validates BRL offramp requirements. - * @param quote The quote ticket - * @param params Offramp parameters - * @returns Validated parameters - */ -export function validateBRLOfframp( - quote: QuoteTicketAttributes, - params: { - brlaEvmAddress?: string; - pixDestination?: string; - taxId?: string; - receiverTaxId?: string; - } -): { - brlaEvmAddress: string; - pixDestination: string; - taxId: string; - receiverTaxId: string; -} { - const { brlaEvmAddress, pixDestination, taxId, receiverTaxId } = params; - - if (!brlaEvmAddress || !pixDestination || !taxId || !receiverTaxId) { - throw new Error("brlaEvmAddress, pixDestination, receiverTaxId and taxId must be derived for offramp to BRL"); - } - - return { - brlaEvmAddress, - pixDestination, - receiverTaxId: normalizeTaxId(receiverTaxId), - taxId: normalizeTaxId(taxId) - }; -} - -/** - * Validates BRL offramp metadata derived from the quote (substrate-input corridor). - * Used by the legacy AssetHub→BRL route which transfers BRLA via XCM through Moonbeam. - */ -export function validateBRLOfframpMetadata(quote: QuoteTicketAttributes): { - offrampAmountBeforeAnchorFeesRaw: string; -} { - if (!quote.metadata.pendulumToMoonbeamXcm?.outputAmountRaw) { - throw new Error("Quote metadata is missing pendulumToMoonbeamXcm.outputAmountRaw required for BRL offramp"); - } - - return { - offrampAmountBeforeAnchorFeesRaw: quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw - }; -} diff --git a/apps/api/src/api/services/transactions/offramp/index.ts b/apps/api/src/api/services/transactions/offramp/index.ts deleted file mode 100644 index 1c84dd087..000000000 --- a/apps/api/src/api/services/transactions/offramp/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - FiatToken, - getNetworkFromDestination, - getOnChainTokenDetails, - isAlfredpayToken, - isEvmTokenDetails, - OnChainToken -} from "@vortexfi/shared"; -import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "./common/types"; -import { prepareAssethubToBRLOfframpTransactions } from "./routes/assethub-to-brl"; -import { prepareEvmToAlfredpayOfframpTransactions } from "./routes/evm-to-alfredpay"; -import { prepareEvmToBRLOfframpBaseTransactions } from "./routes/evm-to-brl-base"; -import { prepareEvmToMykoboOfframpTransactions } from "./routes/evm-to-mykobo"; - -export async function prepareOfframpTransactions(params: OfframpTransactionParams): Promise { - const { quote } = params; - - const fromNetwork = getNetworkFromDestination(quote.from); - if (!fromNetwork) { - throw new Error(`Invalid network for destination ${quote.from}`); - } - - // Route to appropriate handler based on input source and output destination - if (quote.outputCurrency === FiatToken.BRL) { - const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); - if (inputTokenDetails && isEvmTokenDetails(inputTokenDetails)) { - return prepareEvmToBRLOfframpBaseTransactions(params); - } else { - return prepareAssethubToBRLOfframpTransactions(params); - } - } else if (quote.outputCurrency === FiatToken.EURC) { - // Mykobo EUR offramp on Base (EVM-only path) - const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); - if (!inputTokenDetails || !isEvmTokenDetails(inputTokenDetails)) { - throw new Error("Mykobo EUR offramp requires an EVM source chain"); - } - return prepareEvmToMykoboOfframpTransactions(params); - } else if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { - // Alfredpay offramp (USD, MXN, COP, ARS) - return prepareEvmToAlfredpayOfframpTransactions(params); - } - - throw new Error(`Unsupported offramp output currency: ${quote.outputCurrency}`); -} diff --git a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts b/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts deleted file mode 100644 index 8cda2faf5..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { encodeSubmittableExtrinsic, getPendulumDetails, MoonbeamTokenDetails, Networks, UnsignedTx } from "@vortexfi/shared"; -import Big from "big.js"; -import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { addFeeDistributionTransaction } from "../../common/feeDistribution"; -import { preparePendulumCleanupTransaction } from "../../pendulum/cleanup"; -import { createAssetHubSourceTransactions, createBRLTransactions, createNablaSwapTransactions } from "../common/transactions"; -import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -import { validateBRLOfframp, validateBRLOfframpMetadata, validateOfframpQuote } from "../common/validation"; - -/** - * Prepares all transactions for an AssetHub to BRL offramp. - * This route handles: AssetHub → Pendulum (swap) → Moonbeam (BRL) - */ -export async function prepareAssethubToBRLOfframpTransactions({ - quote, - signingAccounts, - userAddress, - pixDestination, - taxId, - receiverTaxId, - brlaEvmAddress -}: OfframpTransactionParams): Promise { - const unsignedTxs: UnsignedTx[] = []; - let stateMeta: Partial = {}; - - // Validate inputs and extract required data - const { fromNetwork, inputTokenDetails, outputTokenDetails, substrateEphemeralEntry } = validateOfframpQuote( - quote, - signingAccounts - ); - if (!substrateEphemeralEntry) { - throw new Error("Pendulum ephemeral not found"); - } - - const { - brlaEvmAddress: validatedBrlaEvmAddress, - pixDestination: validatedPixDestination, - taxId: validatedTaxId, - receiverTaxId: validatedReceiverTaxId - } = validateBRLOfframp(quote, { brlaEvmAddress, pixDestination, receiverTaxId, taxId }); - const { offrampAmountBeforeAnchorFeesRaw } = validateBRLOfframpMetadata(quote); - - const inputAmountRaw = multiplyByPowerOfTen(new Big(quote.inputAmount), inputTokenDetails.decimals).toFixed(0, 0); - - // Initialize state metadata - stateMeta = { - substrateEphemeralAddress: substrateEphemeralEntry.address - }; - - if (!userAddress) { - throw new Error("User address must be provided for offramping."); - } - - // Create AssetHub source transactions - await createAssetHubSourceTransactions( - { - inputAmountRaw, - pendulumEphemeralAddress: substrateEphemeralEntry.address, - userAddress - }, - unsignedTxs, - fromNetwork - ); - - // Process Pendulum account - const substrateAccount = signingAccounts.find(account => account.type === "Substrate"); - if (!substrateAccount) { - throw new Error("Substrate account not found"); - } - - const inputTokenPendulumDetails = getPendulumDetails(quote.inputCurrency, fromNetwork); - const outputTokenPendulumDetails = getPendulumDetails(quote.outputCurrency); - - let pendulumNonce = 0; - - // Add fee distribution transaction - pendulumNonce = await addFeeDistributionTransaction(quote, substrateAccount, unsignedTxs, pendulumNonce); - - // Create Nabla swap transactions - const nablaResult = await createNablaSwapTransactions( - { - account: substrateAccount, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - quote - }, - unsignedTxs, - pendulumNonce - ); - - pendulumNonce = nablaResult.nextNonce; - stateMeta = { - ...stateMeta, - ...nablaResult.stateMeta - }; - - // Prepare cleanup transaction - const pendulumCleanupTransaction = await preparePendulumCleanupTransaction( - inputTokenPendulumDetails.currencyId, - outputTokenPendulumDetails.currencyId - ); - - const pendulumCleanupTx: Omit = { - meta: {}, - network: Networks.Pendulum, - phase: "pendulumCleanup", - signer: substrateAccount.address, - txData: encodeSubmittableExtrinsic(pendulumCleanupTransaction) - }; - - // Create BRL transactions - const brlResult = await createBRLTransactions( - { - account: substrateAccount, - brlaEvmAddress: validatedBrlaEvmAddress, - outputAmountRaw: offrampAmountBeforeAnchorFeesRaw, - outputTokenPendulumDetails: (outputTokenDetails as unknown as MoonbeamTokenDetails).pendulumRepresentative, - pixDestination: validatedPixDestination, - receiverTaxId: validatedReceiverTaxId, - taxId: validatedTaxId - }, - unsignedTxs, - pendulumCleanupTx, - pendulumNonce - ); - - stateMeta = { - ...stateMeta, - ...brlResult.stateMeta - }; - - return { stateMeta, unsignedTxs }; -} 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 deleted file mode 100644 index a4ae797d0..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts +++ /dev/null @@ -1,540 +0,0 @@ -import { - ALFREDPAY_ERC20_TOKEN, - ALFREDPAY_ONCHAIN_CURRENCY, - AlfredpayApiService, - AlfredpayChain, - AlfredpayFiatCurrency, - createOfframpSquidrouterTransactionsToEvm, - EvmClientManager, - EvmNetworks, - EvmToken, - EvmTokenDetails, - EvmTransactionData, - evmTokenConfig, - getNetworkFromDestination, - getNetworkId, - getOnChainTokenDetails, - isEvmToken, - isNetworkEVM, - Networks, - SignedTypedData, - TypedDataDomain, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import httpStatus from "http-status"; -import { - ContractFunctionExecutionError, - encodeAbiParameters, - encodeFunctionData, - keccak256, - PublicClient, - pad, - parseAbiParameters, - toHex -} from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import { config } from "../../../../../config/vars"; -import erc20ABI from "../../../../../contracts/ERC20"; -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"; -import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -import { buildUserSquidTransactions } from "./user-squid-transactions"; - -// TokenRelayer deployments. Address may differ per chain -export const RELAYER_ADDRESSES: Partial> = { - [Networks.Arbitrum]: "0xC9ECD03c89349B3EAe4613c7091c6c3029413785", - [Networks.Base]: "0xDbece5cE27984FC64688bcC57f75b96a28e8c68c", - [Networks.Polygon]: "0xC9ECD03c89349B3EAe4613c7091c6c3029413785", - [Networks.Avalanche]: "0x11871C77Aa0170ae13864E4E82cFa471720e045e", - [Networks.Ethereum]: "0x522A51f9c5B1683F0F15910075487c4D162A8b83", - [Networks.BSC]: "0x2d657ac14088fED401b58FEd377988ed3F875220" -}; - -export function getRelayerAddress(network: EvmNetworks): `0x${string}` { - const address = RELAYER_ADDRESSES[network]; - if (!address) { - throw new Error(`No TokenRelayer deployed on ${network}`); - } - return address; -} - -/** - * Resolves the EIP-712 domain for a token's permit signature. - * Some tokens (like USDT in polygon) use salt-based domain separation instead of chainId. - */ -async function resolvePermitDomain( - publicClient: PublicClient, - tokenAddress: `0x${string}`, - chainId: number, - tokenName: string -): Promise { - let version = "1"; - try { - version = (await publicClient.readContract({ - abi: [{ inputs: [], name: "version", outputs: [{ type: "string" }], type: "function" }], - address: tokenAddress, - functionName: "version" - })) as string; - } catch { - // If version() fails, we stick with "1" - } - - const standardHash = keccak256( - encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, uint256, address"), [ - keccak256(toHex("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")), - keccak256(toHex(tokenName)), - keccak256(toHex(version)), - BigInt(chainId), - tokenAddress - ]) - ); - - let onChainSeparator: `0x${string}` | undefined; - try { - onChainSeparator = (await publicClient.readContract({ - abi: [{ inputs: [], name: "DOMAIN_SEPARATOR", outputs: [{ type: "bytes32" }], type: "function" }], - address: tokenAddress, - functionName: "DOMAIN_SEPARATOR" - })) as `0x${string}`; - } catch { - // If we can't read it, fall back to using standard domain separator eventually - } - - if (onChainSeparator !== undefined) { - if (onChainSeparator !== standardHash) { - // On-chain separator exists but doesn't match standard - compute salt hash for comparison - const salt = pad(toHex(chainId), { size: 32 }); - const saltHash = keccak256( - encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, address, bytes32"), [ - keccak256(toHex("EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)")), - keccak256(toHex(tokenName)), - keccak256(toHex(version)), - tokenAddress, - salt - ]) - ); - - if (onChainSeparator === saltHash) { - return { name: tokenName, salt, verifyingContract: tokenAddress, version }; - } - - // Neither matches - this is an error - throw new Error( - `Token ${tokenName} has unexpected DOMAIN_SEPARATOR. Expected standard: ${standardHash} or salt: ${saltHash}, got: ${onChainSeparator}` - ); - } - // use standard domain - return { chainId, name: tokenName, verifyingContract: tokenAddress, version }; - } - - // No on-chain separator available - default to standard - return { chainId, name: tokenName, verifyingContract: tokenAddress, version }; -} - -const erc20Abi = [ - { - inputs: [{ name: "account", type: "address" }], - name: "balanceOf", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - }, - { - inputs: [{ name: "owner", type: "address" }], - name: "nonces", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - }, - { inputs: [], name: "name", outputs: [{ name: "", type: "string" }], stateMutability: "view", type: "function" } -]; - -/** - * Prepares all transactions for an EVM to Alfredpay (USD) offramp. - * This route handles: EVM → Polygon (USDC) → Alfredpay (Fiat) - */ -export async function prepareEvmToAlfredpayOfframpTransactions({ - fiatAccountId, - quote, - signingAccounts, - userAddress, - userId -}: OfframpTransactionParams): Promise { - const unsignedTxs: UnsignedTx[] = []; - let stateMeta: Partial = {}; - - const evmClientManager = EvmClientManager.getInstance(); - - const fromNetwork = getNetworkFromDestination(quote.from); - if (!fromNetwork) { - throw new Error(`Invalid network for destination ${quote.from}`); - } - - const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); - if (!evmEphemeralEntry) { - throw new Error("EVM ephemeral account not found"); - } - - const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency); - if (!inputTokenDetails || !isEvmToken(quote.inputCurrency)) { - throw new Error(`Input token details not found for ${quote.inputCurrency} on network ${fromNetwork}`); - } - - if (!userAddress) { - throw new Error("User address must be provided for offramping."); - } - - if (!quote.metadata.alfredpayOfframp?.inputAmountRaw) { - throw new Error("Missing alfredpayOfframp.inputAmountRaw in quote metadata"); - } - - if (!isNetworkEVM(fromNetwork)) { - throw new Error(`Unsupported source network ${fromNetwork} for EVM to Alfredpay type offramp`); - } - - 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 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 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: alfredPayId, - fiatAccountId, - fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - originAddress: evmEphemeralEntry.address, - quoteId: alfredpayQuoteId, - toCurrency: quote.outputCurrency as unknown as AlfredpayFiatCurrency - }); - - const inputAmountRaw = new Big(quote.inputAmount).mul(new Big(10).pow(inputTokenDetails.decimals)).toFixed(0, 0); - const inputTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain; - - const isDirectPolygonTransfer = - fromNetwork === Networks.Polygon && inputTokenAddress.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase(); - - const publicClient = evmClientManager.getClient(fromNetwork); - const chainId = getNetworkId(fromNetwork); - if (chainId === undefined) { - throw new Error(`Unsupported EVM network for Alfredpay offramp: ${fromNetwork}`); - } - - // Probe EIP-2612 support: tokens that don't implement nonces() (e.g. USDT on Base) revert here. - // Only treat contract-call failures as "no permit"; rethrow network/transport errors. - let userNonce: bigint | null = null; - try { - userNonce = (await publicClient.readContract({ - abi: erc20Abi, - address: inputTokenAddress, - args: [userAddress], - functionName: "nonces" - })) as bigint; - } catch (error) { - if (error instanceof ContractFunctionExecutionError) { - userNonce = null; - } else { - throw error; - } - } - const supportsPermit = userNonce !== null; - - if (supportsPermit && userNonce !== null) { - const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); - - const tokenName = (await publicClient.readContract({ - abi: erc20Abi, - address: inputTokenAddress, - functionName: "name" - })) as string; - - const resolvedDomain = await resolvePermitDomain(publicClient, inputTokenAddress, chainId, tokenName); - - if (isDirectPolygonTransfer) { - // Source is already Polygon USDT — user permits the executor to transferFrom directly. - // The executor has gas; the ephemeral is not yet funded at the squidRouterPermitExecute phase. - const executorAccount = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); - const permitTypedData: SignedTypedData = { - domain: resolvedDomain, - message: { - deadline: permitDeadline.toString(), - nonce: userNonce.toString(), - owner: userAddress, - spender: executorAccount.address, - value: inputAmountRaw.toString() - }, - primaryType: "Permit", - types: { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - } - }; - - unsignedTxs.push({ - meta: {}, - network: fromNetwork, - nonce: 0, - phase: "squidRouterPermitExecute", - signer: userAddress, - txData: [permitTypedData] - }); - - stateMeta = { - ...stateMeta, - alfredpayTransactionId: offrampOrder.transactionId, - alfredpayUserId: alfredPayId, - evmEphemeralAddress: evmEphemeralEntry.address, - fiatAccountId, - isDirectTransfer: true, - walletAddress: userAddress - }; - } else { - const bridgeResult = await createOfframpSquidrouterTransactionsToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: userAddress, - fromNetwork, - fromToken: inputTokenAddress, - rawAmount: inputAmountRaw, - toNetwork: Networks.Polygon, - toToken: ALFREDPAY_ERC20_TOKEN - }); - - const relayerAddress = RELAYER_ADDRESSES[fromNetwork]; - - if (!relayerAddress) { - throw new Error(`Alfredpay offramp permit flow is not supported on ${fromNetwork}: no relayer deployment configured`); - } - - const permitTypedData: SignedTypedData = { - domain: resolvedDomain, - message: { - deadline: permitDeadline.toString(), - nonce: userNonce.toString(), - owner: userAddress, - spender: relayerAddress, - value: inputAmountRaw.toString() - }, - primaryType: "Permit", - types: { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - } - }; - - const payloadNonce = BigInt(Math.floor(Date.now() / 1000)); - const payloadDeadline = BigInt(Math.floor(Date.now() / 1000) + 3600); - - const payloadTypedData: SignedTypedData = { - domain: { - chainId, - name: "TokenRelayer", - verifyingContract: relayerAddress, - version: "1" - }, - message: { - data: bridgeResult.swapData.data, - deadline: payloadDeadline.toString(), - destination: bridgeResult.swapData.to, - ethValue: bridgeResult.swapData.value, - nonce: payloadNonce.toString(), - owner: userAddress, - token: inputTokenAddress, - value: inputAmountRaw.toString() - }, - primaryType: "Payload", - types: { - Payload: [ - { name: "destination", type: "address" }, - { name: "owner", type: "address" }, - { name: "token", type: "address" }, - { name: "value", type: "uint256" }, - { name: "data", type: "bytes" }, - { name: "ethValue", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - } - }; - - unsignedTxs.push({ - meta: {}, - network: fromNetwork, - nonce: 0, - phase: "squidRouterPermitExecute", - signer: userAddress, - txData: [permitTypedData, payloadTypedData] - }); - - stateMeta = { - ...stateMeta, - alfredpayTransactionId: offrampOrder.transactionId, - alfredpayUserId: alfredPayId, - evmEphemeralAddress: evmEphemeralEntry.address, - fiatAccountId, - squidRouterPermitExecutionValue: bridgeResult.swapData.value, - walletAddress: userAddress - }; - } - } else if (isDirectPolygonTransfer) { - // No permit available, but user already holds USDT on Polygon: user signs a single - // transfer(ephemeral, amount) in their wallet. Funds land directly on the ephemeral. - const transferData = encodeFunctionData({ - abi: erc20ABI, - args: [evmEphemeralEntry.address as `0x${string}`, BigInt(inputAmountRaw)], - functionName: "transfer" - }); - - unsignedTxs.push({ - meta: {}, - network: fromNetwork, - nonce: 0, - phase: "squidRouterNoPermitTransfer", - signer: userAddress, - txData: { - data: transferData, - gas: "0", - to: inputTokenAddress, - value: "0" - } - }); - - stateMeta = { - ...stateMeta, - alfredpayTransactionId: offrampOrder.transactionId, - alfredpayUserId: alfredPayId, - evmEphemeralAddress: evmEphemeralEntry.address, - fiatAccountId, - isDirectTransfer: true, - isNoPermitFallback: true, - walletAddress: userAddress - }; - } else { - // Cross-chain fallback: user submits the standard squidRouter approve + swap pair from - // their own wallet, bypassing the relayer (which would require permit). Squid lands the - // bridged USDC on the EVM ephemeral on Polygon, identical to the permit-based flow. - const bridgeResult = await createOfframpSquidrouterTransactionsToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: userAddress, - fromNetwork, - fromToken: inputTokenAddress, - rawAmount: inputAmountRaw, - toNetwork: Networks.Polygon, - toToken: ALFREDPAY_ERC20_TOKEN - }); - - unsignedTxs.push( - ...buildUserSquidTransactions({ - approveData: bridgeResult.approveData, - approvePhase: "squidRouterNoPermitApprove", - isNative: inputTokenDetails.isNative, - network: fromNetwork, - signer: userAddress, - swapData: bridgeResult.swapData, - swapPhase: "squidRouterNoPermitSwap" - }) - ); - - stateMeta = { - ...stateMeta, - alfredpayTransactionId: offrampOrder.transactionId, - alfredpayUserId: alfredPayId, - evmEphemeralAddress: evmEphemeralEntry.address, - fiatAccountId, - isNoPermitFallback: true, - squidRouterPermitExecutionValue: bridgeResult.swapData.value, - walletAddress: userAddress - }; - } - - const finalTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: quote.metadata.alfredpayOfframp.inputAmountRaw, - destinationNetwork: Networks.Polygon as EvmNetworks, - toAddress: offrampOrder.depositAddress as `0x${string}`, - toToken: ALFREDPAY_ERC20_TOKEN - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, - nonce: 0, - phase: "alfredpayOfframpTransfer", - signer: evmEphemeralEntry.address, - txData: finalTransferTxData - }); - - const fallbackTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: quote.metadata.alfredpayOfframp.inputAmountRaw, - destinationNetwork: Networks.Polygon as EvmNetworks, - toAddress: userAddress, - toToken: ALFREDPAY_ERC20_TOKEN - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, - nonce: 0, // Also use nonce 0 to ensure transaction is available immediately - phase: "alfredpayOfframpTransferFallback", - signer: evmEphemeralEntry.address, - txData: fallbackTransferTxData - }); - - // Squidrouter delivers axlUSDC (not USDT/ALFREDPAY_ERC20_TOKEN) to the Polygon ephemeral if its - // destination swap exceeds slippage. This approval lets the funding account sweep that residual - // via post-process. Runs at nonce 1 (after whichever of the two nonce-0 transfers executes). - const polygonAxlUsdcAddress = evmTokenConfig[Networks.Polygon][EvmToken.AXLUSDC]?.erc20AddressSourceChain; - if (!polygonAxlUsdcAddress) { - throw new Error("Invalid AXLUSDC configuration for Polygon in evmTokenConfig"); - } - const polygonFundingAccount = getEvmFundingAccount(Networks.Polygon); - const axlUsdcCleanupApproval = await preparePolygonCleanupApproval( - polygonAxlUsdcAddress as `0x${string}`, - polygonFundingAccount.address, - Networks.Polygon - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, - nonce: 1, - phase: "polygonCleanupAxlUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(axlUsdcCleanupApproval) as EvmTransactionData - }); - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts deleted file mode 100644 index 762a767d0..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { - createOfframpSquidrouterTransactionsToEvm, - EvmToken, - EvmTransactionData, - evmTokenConfig, - isEvmTokenDetails, - multiplyByPowerOfTen, - Networks, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import { encodeFunctionData } from "viem"; -import erc20ABI from "../../../../../contracts/ERC20"; -import { getEvmFundingAccount } from "../../../phases/evm-funding"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { encodeEvmTransactionData } from "../.."; -import { prepareBaseCleanupApproval } from "../../base/cleanup"; -import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; -import { addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; -import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -import { validateBRLOfframp, validateOfframpQuote } from "../common/validation"; -import { buildUserSquidTransactions } from "./user-squid-transactions"; - -/** - * Prepares all transactions for an EVM to BRL offramp. - * This route handles: EVM → Base (swap) → Avenia Offramp. - */ -export async function prepareEvmToBRLOfframpBaseTransactions({ - quote, - signingAccounts, - userAddress, - pixDestination, - taxId, - receiverTaxId, - brlaEvmAddress -}: OfframpTransactionParams): Promise { - const unsignedTxs: UnsignedTx[] = []; - let stateMeta: Partial = {}; - - // Validate inputs and extract required data - const { fromNetwork, inputTokenDetails } = validateOfframpQuote(quote, signingAccounts); - - const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); - if (!evmEphemeralEntry) { - throw new Error("EVM account not found. An EVM ephemeral account is required for EVM to BRL offramp."); - } - - const { - brlaEvmAddress: validatedBrlaEvmAddress, - pixDestination: validatedPixDestination, - taxId: validatedTaxId, - receiverTaxId: validatedReceiverTaxId - } = validateBRLOfframp(quote, { brlaEvmAddress, pixDestination, receiverTaxId, taxId }); - - const inputAmountRaw = multiplyByPowerOfTen(new Big(quote.inputAmount), inputTokenDetails.decimals).toFixed(0, 0); - - if (!userAddress) { - throw new Error("User address must be provided for offramping."); - } - - if (!isEvmTokenDetails(inputTokenDetails)) { - throw new Error("EVM to BRL route requires EVM input token"); - } - - const baseUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; - if (!baseUsdcAddress) { - throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); - } - - const baseBrlaAddress = evmTokenConfig[Networks.Base][EvmToken.BRLA]?.erc20AddressSourceChain; - if (!baseBrlaAddress) { - throw new Error("Invalid BRLA configuration for Base in evmTokenConfig"); - } - - // Special case: if user already holds USDC on Base, skip squidrouter and have the user sign a - // direct ERC20 transfer to the ephemeral. Without this leg the ephemeral never receives USDC and - // downstream phases (distributeFees, nablaSwap) would revert from insufficient balance. - if (fromNetwork === Networks.Base && inputTokenDetails.erc20AddressSourceChain === baseUsdcAddress) { - const transferData = encodeFunctionData({ - abi: erc20ABI, - args: [evmEphemeralEntry.address as `0x${string}`, BigInt(inputAmountRaw)], - functionName: "transfer" - }); - - unsignedTxs.push({ - meta: {}, - network: fromNetwork, - nonce: 0, - phase: "squidRouterNoPermitTransfer", - signer: userAddress, - txData: { - data: transferData, - gas: "0", - to: inputTokenDetails.erc20AddressSourceChain, - value: "0" - } - }); - } else { - // TODO Maybe, move to contract-base squid swap. - // Otherwise use the same approach as previously - const { approveData, swapData } = await createOfframpSquidrouterTransactionsToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: userAddress, - fromNetwork, - fromToken: inputTokenDetails.erc20AddressSourceChain, - rawAmount: inputAmountRaw, - toNetwork: Networks.Base, - toToken: baseUsdcAddress - }); - - unsignedTxs.push( - ...buildUserSquidTransactions({ - approveData: encodeEvmTransactionData(approveData) as EvmTransactionData, - approvePhase: "squidRouterApprove", - isNative: inputTokenDetails.isNative, - network: fromNetwork, - signer: userAddress, - swapData: encodeEvmTransactionData(swapData) as EvmTransactionData, - swapPhase: "squidRouterSwap" - }) - ); - } - - let baseNonce = 0; - - const baseUSDCTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; - if (!baseUSDCTokenAddress) { - throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); - } - const baseBRLATokenAddress = evmTokenConfig[Networks.Base][EvmToken.BRLA]?.erc20AddressSourceChain; - if (!baseBRLATokenAddress) { - throw new Error("Invalid BRLA configuration for Base in evmTokenConfig"); - } - - // Fee distribution transaction on EVM MUST be built before the Nabla swap on offramps: - // fees are paid in USDC, which on offramps is available before the USDC -> BRLA swap. - // Nonce ordering on Base: distributeFees=0, nablaApprove=1, nablaSwap=2, brlaPayoutOnBase=3. - baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - - // Add Base Nabla swap transactions (USDC to BRLA on Base) - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( - { - account: evmEphemeralEntry, - inputTokenAddress: baseUSDCTokenAddress, - outputTokenAddress: baseBRLATokenAddress, - quote - }, - unsignedTxs, - baseNonce - ); - stateMeta = { ...stateMeta, ...nablaStateMeta }; - baseNonce = nonceAfterNabla; - - const brlaTransferAmountRaw = quote.metadata.nablaSwapEvm?.outputAmountRaw; - if (!brlaTransferAmountRaw) { - throw new Error("Missing outputAmountRaw in nablaSwapEvm metadata"); - } - - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: brlaTransferAmountRaw, - destinationNetwork: Networks.Base, - isNativeToken: false, - toAddress: validatedBrlaEvmAddress, - toToken: baseBrlaAddress as `0x${string}` - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce, - phase: "brlaPayoutOnBase", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalDestinationTransfer) as EvmTransactionData - }); - baseNonce++; - - const baseFundingAccount = getEvmFundingAccount(Networks.Base); - - const usdcCleanupApproval = await prepareBaseCleanupApproval( - baseUSDCTokenAddress as `0x${string}`, - baseFundingAccount.address, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData - }); - - const brlaCleanupApproval = await prepareBaseCleanupApproval( - baseBRLATokenAddress as `0x${string}`, - baseFundingAccount.address, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupBrla", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(brlaCleanupApproval) as EvmTransactionData - }); - - // Squidrouter delivers axlUSDC (not USDC) to the Base ephemeral if its destination swap - // exceeds slippage. This approval lets the funding account sweep that residual via post-process. - const baseAxlUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.AXLUSDC]?.erc20AddressSourceChain; - if (!baseAxlUsdcAddress) { - throw new Error("Invalid AXLUSDC configuration for Base in evmTokenConfig"); - } - const axlUsdcCleanupApproval = await prepareBaseCleanupApproval( - baseAxlUsdcAddress as `0x${string}`, - baseFundingAccount.address, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupAxlUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(axlUsdcCleanupApproval) as EvmTransactionData - }); - - stateMeta = { - ...stateMeta, - brlaEvmAddress: validatedBrlaEvmAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - pixDestination: validatedPixDestination, - receiverTaxId: validatedReceiverTaxId, - taxId: validatedTaxId - }; - - return { stateMeta, unsignedTxs }; -} 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 deleted file mode 100644 index b5a1fb590..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { - createOfframpSquidrouterTransactionsToEvm, - EvmToken, - EvmTransactionData, - evmTokenConfig, - isEvmTokenDetails, - isWithdrawInstructions, - MykoboApiService, - MykoboCurrency, - MykoboTransactionType, - multiplyByPowerOfTen, - Networks, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import httpStatus from "http-status"; -import { encodeFunctionData } from "viem"; -import erc20ABI from "../../../../../contracts/ERC20"; -import { APIError } from "../../../../errors/api-error"; -import { resolveMykoboCustomerForUser } from "../../../mykobo/mykobo-customer.service"; -import { getEvmFundingAccount } from "../../../phases/evm-funding"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { encodeEvmTransactionData } from "../.."; -import { prepareBaseCleanupApproval } from "../../base/cleanup"; -import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; -import { addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; -import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -import { validateOfframpQuote } from "../common/validation"; -import { buildUserSquidTransactions } from "./user-squid-transactions"; - -export async function prepareEvmToMykoboOfframpTransactions({ - quote, - signingAccounts, - userAddress, - email, - destinationAddress, - ipAddress, - userId -}: OfframpTransactionParams): Promise { - const unsignedTxs: UnsignedTx[] = []; - let stateMeta: Partial = {}; - - const { fromNetwork, inputTokenDetails } = validateOfframpQuote(quote, signingAccounts, { requireSubstrateEphemeral: false }); - - const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); - if (!evmEphemeralEntry) { - throw new Error("EVM ephemeral account not found for EVM to Mykobo offramp"); - } - - // 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({ - isPublic: true, - message: "ipAddress must be provided for Mykobo (EUR) offramp", - status: httpStatus.BAD_REQUEST - }); - } - - if (!destinationAddress) { - throw new APIError({ - isPublic: true, - message: "destinationAddress (user receiving wallet) must be provided for Mykobo offramp", - status: httpStatus.BAD_REQUEST - }); - } - - if (!userAddress) { - throw new Error("User address must be provided for offramping."); - } - - if (!isEvmTokenDetails(inputTokenDetails)) { - throw new Error("EVM to Mykobo route requires EVM input token"); - } - - const baseUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; - if (!baseUsdcAddress) { - throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); - } - - const baseEurcAddress = evmTokenConfig[Networks.Base][EvmToken.EURC]?.erc20AddressSourceChain; - if (!baseEurcAddress) { - throw new Error("Invalid EURC configuration for Base in evmTokenConfig"); - } - - const baseAxlUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.AXLUSDC]?.erc20AddressSourceChain; - if (!baseAxlUsdcAddress) { - throw new Error("Invalid AXLUSDC configuration for Base in evmTokenConfig"); - } - - const inputAmountRaw = multiplyByPowerOfTen(new Big(quote.inputAmount), inputTokenDetails.decimals).toFixed(0, 0); - const inputTokenAddress = inputTokenDetails.erc20AddressSourceChain; - const isDirectBaseTransfer = - fromNetwork === Networks.Base && inputTokenAddress.toLowerCase() === baseUsdcAddress.toLowerCase(); - - // Resolve the Mykobo intent before building any user-signed transactions so that an API failure aborts early. - const mykoboIntentValue = quote.metadata.nablaSwapEvm?.outputAmountDecimal; - if (!mykoboIntentValue) { - throw new Error("Missing nablaSwapEvm.outputAmountDecimal in quote metadata for Mykobo intent value"); - } - - // Mykobo silently truncates the intent value to 2 decimals. We floor here so the on-chain - // EURC transfer below matches the amount Mykobo actually credits to the withdraw intent. - const mykoboFlooredValue = new Big(mykoboIntentValue).toFixed(2, 0); - const eurcDecimals = evmTokenConfig[Networks.Base][EvmToken.EURC]?.decimals; - if (eurcDecimals === undefined) { - throw new Error("Invalid EURC decimals configuration for Base in evmTokenConfig"); - } - const eurcTransferAmountRaw = multiplyByPowerOfTen(new Big(mykoboFlooredValue), eurcDecimals).toFixed(0, 0); - - const mykobo = MykoboApiService.getInstance(); - const intent = await mykobo.createTransactionIntent({ - currency: MykoboCurrency.EURC, - email_address: mykoboEmail, - ip_address: ipAddress, - transaction_type: MykoboTransactionType.WITHDRAW, - value: mykoboFlooredValue, - wallet_address: evmEphemeralEntry.address - }); - - if (!isWithdrawInstructions(intent.instructions)) { - throw new Error("Mykobo intent did not return withdraw instructions; cannot derive receivables address"); - } - const mykoboReceivablesAddress = intent.instructions.address; - const mykoboTransactionId = intent.transaction.id; - const mykoboTransactionReference = intent.transaction.reference; - - if (isDirectBaseTransfer) { - // User already holds USDC on Base — they sign a single ERC-20 transfer to the ephemeral. - // Mirrors the isDirectPolygonTransfer branch in evm-to-alfredpay.ts. - const transferData = encodeFunctionData({ - abi: erc20ABI, - args: [evmEphemeralEntry.address as `0x${string}`, BigInt(inputAmountRaw)], - functionName: "transfer" - }); - - unsignedTxs.push({ - meta: {}, - network: fromNetwork, - nonce: 0, - phase: "squidRouterNoPermitTransfer", - signer: userAddress, - txData: { - data: transferData, - gas: "0", - to: inputTokenAddress, - value: "0" - } - }); - } else { - const { approveData, swapData } = await createOfframpSquidrouterTransactionsToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: userAddress, - fromNetwork, - fromToken: inputTokenAddress, - rawAmount: inputAmountRaw, - toNetwork: Networks.Base, - toToken: baseUsdcAddress - }); - - unsignedTxs.push( - ...buildUserSquidTransactions({ - approveData: encodeEvmTransactionData(approveData) as EvmTransactionData, - approvePhase: "squidRouterApprove", - isNative: inputTokenDetails.isNative, - network: fromNetwork, - signer: userAddress, - swapData: encodeEvmTransactionData(swapData) as EvmTransactionData, - swapPhase: "squidRouterSwap" - }) - ); - } - - let baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, 0); - - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( - { - account: evmEphemeralEntry, - inputTokenAddress: baseUsdcAddress, - outputTokenAddress: baseEurcAddress, - quote - }, - unsignedTxs, - baseNonce - ); - stateMeta = nablaStateMeta; - baseNonce = nonceAfterNabla; - - const payoutTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: eurcTransferAmountRaw, - destinationNetwork: Networks.Base, - isNativeToken: false, - toAddress: mykoboReceivablesAddress as `0x${string}`, - toToken: baseEurcAddress as `0x${string}` - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce, - phase: "mykoboPayoutOnBase", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(payoutTransfer) as EvmTransactionData - }); - baseNonce++; - - const baseFundingAccount = getEvmFundingAccount(Networks.Base); - - const cleanupTokens = [ - { address: baseUsdcAddress, phase: "baseCleanupUsdc" as const }, - { address: baseEurcAddress, phase: "baseCleanupEurc" as const }, - { address: baseAxlUsdcAddress, phase: "baseCleanupAxlUsdc" as const } - ]; - - for (const { address, phase } of cleanupTokens) { - const approval = await prepareBaseCleanupApproval(address as `0x${string}`, baseFundingAccount.address, Networks.Base); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase, - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approval) as EvmTransactionData - }); - } - - stateMeta = { - ...stateMeta, - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - mykoboEmail, - mykoboReceivablesAddress, - mykoboTransactionId, - mykoboTransactionReference, - walletAddress: userAddress - }; - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/offramp/routes/user-squid-transactions.test.ts b/apps/api/src/api/services/transactions/offramp/routes/user-squid-transactions.test.ts deleted file mode 100644 index ce079dd8b..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/user-squid-transactions.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Networks } from "@vortexfi/shared"; -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { buildUserSquidTransactions } from "./user-squid-transactions"; - -const txData = { data: "0x", gas: "1", to: "0x1111111111111111111111111111111111111111", value: "0" } as const; - -describe("buildUserSquidTransactions", () => { - it("builds approve then swap for ERC-20 input", () => { - const transactions = buildUserSquidTransactions({ - approveData: txData, - approvePhase: "squidRouterNoPermitApprove", - isNative: false, - network: Networks.Polygon, - signer: "0x2222222222222222222222222222222222222222", - swapData: txData, - swapPhase: "squidRouterNoPermitSwap" - }); - - assert.deepEqual( - transactions.map(transaction => [transaction.phase, transaction.nonce]), - [ - ["squidRouterNoPermitApprove", 0], - ["squidRouterNoPermitSwap", 1] - ] - ); - }); - - it("builds only nonce-zero swap for native input", () => { - const transactions = buildUserSquidTransactions({ - approveData: txData, - approvePhase: "squidRouterNoPermitApprove", - isNative: true, - network: Networks.Polygon, - signer: "0x2222222222222222222222222222222222222222", - swapData: { ...txData, value: "1000000000000000000" }, - swapPhase: "squidRouterNoPermitSwap" - }); - - assert.deepEqual(transactions.map(transaction => [transaction.phase, transaction.nonce]), [ - ["squidRouterNoPermitSwap", 0] - ]); - assert.equal((transactions[0]?.txData as { value: string }).value, "1000000000000000000"); - }); -}); diff --git a/apps/api/src/api/services/transactions/offramp/routes/user-squid-transactions.ts b/apps/api/src/api/services/transactions/offramp/routes/user-squid-transactions.ts deleted file mode 100644 index ffff79c49..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/user-squid-transactions.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { EvmTransactionData, Networks, UnsignedTx } from "@vortexfi/shared"; - -interface UserSquidTransactionsInput { - approveData: EvmTransactionData; - approvePhase: UnsignedTx["phase"]; - isNative: boolean; - network: Networks; - signer: string; - swapData: EvmTransactionData; - swapPhase: UnsignedTx["phase"]; -} - -export function buildUserSquidTransactions(input: UserSquidTransactionsInput): UnsignedTx[] { - const transactions: UnsignedTx[] = []; - if (!input.isNative) { - transactions.push({ - meta: {}, - network: input.network, - nonce: 0, - phase: input.approvePhase, - signer: input.signer, - txData: input.approveData - }); - } - transactions.push({ - meta: {}, - network: input.network, - nonce: input.isNative ? 0 : 1, - phase: input.swapPhase, - signer: input.signer, - txData: input.swapData - }); - return transactions; -} 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 deleted file mode 100644 index df5ccf7d0..000000000 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -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" -} as const; - -const nablaHardMinimumOutputRawCalls: string[] = []; - -const createNablaTransactionsForOnrampOnEVM = mock( - async ( - _inputAmountForNablaSwapRaw: string, - _account: AccountMeta, - _inputTokenAddress: `0x${string}`, - _outputTokenAddress: `0x${string}`, - nablaHardMinimumOutputRaw: string, - _deadlineMinutes: number, - _router: string - ) => { - nablaHardMinimumOutputRawCalls.push(nablaHardMinimumOutputRaw); - - return { - approve: { - data: "0xapprove", - gas: "100000", - to: "0xinput", - value: "0" - }, - swap: { - data: "0xswap", - gas: "200000", - to: "0xrouter", - value: "0" - } - }; - } -); - -mock.module("@vortexfi/shared", () => ({ - ...sharedReal, - AMM_MINIMUM_OUTPUT_HARD_MARGIN: 0.02, - AMM_MINIMUM_OUTPUT_SOFT_MARGIN: 0.01, - createMoonbeamToPendulumXCM: mock(async () => "0xmoonbeam"), - createNablaTransactionsForOnramp: mock(async () => ({ approve: "0xapprove", swap: "0xswap" })), - createNablaTransactionsForOnrampOnEVM, - encodeSubmittableExtrinsic: (tx: unknown) => tx, - EvmClientManager: { - getInstance: () => ({ - getClient: () => ({ - estimateFeesPerGas: mock(async () => ({ maxFeePerGas: 1n, maxPriorityFeePerGas: 1n })) - }) - }) - }, - getNablaBasePool: () => ({ router: "0xrouter" }), - getNetworkId: () => 1, - Networks -})); - -mock.module("../../../../../config/vars", () => ({ - ...varsReal, - config: { - ...varsReal.config, - swap: { - deadlineMinutes: 20 - } - } -})); - -mock.module("../../moonbeam/cleanup", () => ({ - prepareMoonbeamCleanupTransaction: mock(async () => "0xcleanup") -})); - -mock.module("../../pendulum/cleanup", () => ({ - preparePendulumCleanupTransaction: mock(async () => "0xcleanup") -})); - -const {addNablaSwapTransactionsOnBase} = await import("./transactions"); - -function createQuote(ammOutputAmountRaw?: string): QuoteTicketAttributes { - return { - metadata: { - nablaSwapEvm: { - ammOutputAmountRaw, - inputAmountForSwapRaw: "100000000", - outputAmountRaw: "110000000" - } - } - } as unknown as QuoteTicketAttributes; -} - -describe("addNablaSwapTransactionsOnBase", () => { - const account = { - address: "0x1111111111111111111111111111111111111111", - type: "EVM" - } as unknown as AccountMeta; - - beforeEach(() => { - createNablaTransactionsForOnrampOnEVM.mockClear(); - nablaHardMinimumOutputRawCalls.length = 0; - }); - - it("uses AMM-only output for Nabla minimums when subsidy was merged into the quote", async () => { - const unsignedTxs: UnsignedTx[] = []; - - const result = await addNablaSwapTransactionsOnBase( - { - account, - inputTokenAddress: "0x2222222222222222222222222222222222222222", - outputTokenAddress: "0x3333333333333333333333333333333333333333", - quote: createQuote("100000000") - }, - unsignedTxs, - 7 - ); - - expect(nablaHardMinimumOutputRawCalls).toEqual(["98000000"]); - expect(result.stateMeta.nablaSoftMinimumOutputRaw).toBe("99000000"); - expect(unsignedTxs.map(tx => tx.phase)).toEqual(["nablaApprove", "nablaSwap"]); - expect(result.nextNonce).toBe(9); - }); - - it("falls back to outputAmountRaw for quotes without an AMM-only output snapshot", async () => { - const result = await addNablaSwapTransactionsOnBase( - { - account, - inputTokenAddress: "0x2222222222222222222222222222222222222222", - outputTokenAddress: "0x3333333333333333333333333333333333333333", - quote: createQuote() - }, - [], - 0 - ); - - expect(nablaHardMinimumOutputRawCalls).toEqual(["107800000"]); - expect(result.stateMeta.nablaSoftMinimumOutputRaw).toBe("108900000"); - }); -}); diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts deleted file mode 100644 index 0cf243b35..000000000 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { - AccountMeta, - AMM_MINIMUM_OUTPUT_HARD_MARGIN, - AMM_MINIMUM_OUTPUT_SOFT_MARGIN, - createMoonbeamToPendulumXCM, - createNablaTransactionsForOnramp, - createNablaTransactionsForOnrampOnEVM, - EvmClientManager, - EvmNetworks, - EvmTransactionData, - encodeSubmittableExtrinsic, - getNablaBasePool, - getNetworkId, - Networks, - PendulumTokenDetails, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import { encodeFunctionData } from "viem/utils"; -import { config } from "../../../../../config/vars"; -import erc20ABI from "../../../../../contracts/ERC20"; -import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { prepareMoonbeamCleanupTransaction } from "../../moonbeam/cleanup"; -import { preparePendulumCleanupTransaction } from "../../pendulum/cleanup"; - -/** - * Creates Moonbeam to Pendulum XCM transactions - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param nextNonce Next available nonce - * @returns Updated nonce - */ -export async function addMoonbeamTransactions( - params: { - pendulumEphemeralAddress: string; - inputAmountRaw: string; - fromToken: `0x${string}`; - account: AccountMeta; - toNetworkId: number; - }, - unsignedTxs: UnsignedTx[], - nextNonce: number -): Promise { - const { pendulumEphemeralAddress, inputAmountRaw, fromToken, account, toNetworkId } = params; - - // Create and add Moonbeam to Pendulum XCM transaction - const moonbeamToPendulumXCMTransaction = await createMoonbeamToPendulumXCM( - pendulumEphemeralAddress, - inputAmountRaw, - fromToken - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Moonbeam, - nonce: nextNonce, - phase: "moonbeamToPendulumXcm", - signer: account.address, - txData: encodeSubmittableExtrinsic(moonbeamToPendulumXCMTransaction) - }); - // For some reason, the Moonbeam to Pendulum XCM transaction causes a nonce increment of 2. - nextNonce = nextNonce + 2; - - // Create and add Moonbeam cleanup transaction - const moonbeamCleanupTransaction = await prepareMoonbeamCleanupTransaction(); - - // For assethub, we skip the 2 squidRouter transactions, so nonce is 2 lower. - // TODO is the moonbeamCleanup nonce too high? - const moonbeamCleanupNonce = - toNetworkId === getNetworkId(Networks.AssetHub) - ? nextNonce // no nonce increase we skip squidRouter transactions - : nextNonce + 2; // +2 because we need to account for squidRouter approve and swap - - unsignedTxs.push({ - meta: {}, - network: Networks.Moonbeam, - nonce: moonbeamCleanupNonce, - phase: "moonbeamCleanup", - signer: account.address, - txData: encodeSubmittableExtrinsic(moonbeamCleanupTransaction) - }); - - return nextNonce; -} - -/** - * Creates Nabla swap transactions for Pendulum - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param nextNonce Next available nonce - * @returns Updated nonce and state metadata - */ -export async function addNablaSwapTransactions( - params: { - quote: QuoteTicketAttributes; - account: AccountMeta; - inputTokenPendulumDetails: PendulumTokenDetails; - outputTokenPendulumDetails: PendulumTokenDetails; - }, - unsignedTxs: UnsignedTx[], - nextNonce: number -): Promise<{ nextNonce: number; stateMeta: Partial }> { - const { quote, account, inputTokenPendulumDetails, outputTokenPendulumDetails } = params; - - if (!quote.metadata.nablaSwap?.inputAmountForSwapRaw) { - throw new Error("Missing nablaSwap input amount in quote metadata"); - } - - // The input amount for the swap was already calculated in the quote. - const inputAmountForNablaSwapRaw = quote.metadata.nablaSwap.inputAmountForSwapRaw; - const outputAmountRaw = Big(quote.metadata.nablaSwap.outputAmountRaw); - - const nablaSoftMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN).toFixed(0, 0); - const nablaHardMinimumOutputRaw = outputAmountRaw.mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN).toFixed(0, 0); - - const { approve, swap } = await createNablaTransactionsForOnramp( - inputAmountForNablaSwapRaw, - account, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - nablaHardMinimumOutputRaw - ); - - // Add Nabla approve transaction - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: nextNonce, - phase: "nablaApprove", - signer: account.address, - txData: approve.transaction - }); - nextNonce++; - - // Add Nabla swap transaction - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: nextNonce, - phase: "nablaSwap", - signer: account.address, - txData: swap.transaction - }); - nextNonce++; - - return { - nextNonce, - stateMeta: { - nabla: { - approveExtrinsicOptions: approve.extrinsicOptions, - swapExtrinsicOptions: swap.extrinsicOptions - }, - nablaSoftMinimumOutputRaw - } - }; -} - -/** - * Creates Pendulum cleanup transaction - * @param params Transaction parameters - * @returns Cleanup transaction template - */ -export async function addPendulumCleanupTx(params: { - inputTokenPendulumDetails: PendulumTokenDetails; - outputTokenPendulumDetails: PendulumTokenDetails; - account: AccountMeta; -}): Promise> { - const { inputTokenPendulumDetails, outputTokenPendulumDetails, account } = params; - - const pendulumCleanupTransaction = await preparePendulumCleanupTransaction( - inputTokenPendulumDetails.currencyId, - outputTokenPendulumDetails.currencyId - ); - - return { - meta: {}, - network: Networks.Pendulum, - phase: "pendulumCleanup", - signer: account.address, - txData: encodeSubmittableExtrinsic(pendulumCleanupTransaction) - }; -} - -/** - * Creates transactions to handle the ephemeral account on the destination chain - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param nextNonce Next available nonce - * @returns Updated nonce - */ -export async function addOnrampDestinationChainTransactions(params: { - toAddress: string; - toToken: `0x${string}`; - amountRaw: string; - destinationNetwork: EvmNetworks; - isNativeToken?: boolean; -}): Promise { - const { toAddress, amountRaw, destinationNetwork, toToken, isNativeToken } = params; - - const evmClientManager = EvmClientManager.getInstance(); - const publicClient = evmClientManager.getClient(destinationNetwork); - - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - - if (isNativeToken) { - // Native token: simple value transfer to the recipient address - const txData: EvmTransactionData = { - data: "0x" as `0x${string}`, - gas: "21000", // Standard gas limit for native transfers - maxFeePerGas: String(maxFeePerGas * 3n), - maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), - to: toAddress as `0x${string}`, - value: amountRaw - }; - - return txData; - } - - // ERC-20 token: encode transfer call targeting the token contract - const transferCallData = encodeFunctionData({ - abi: erc20ABI, - args: [toAddress, amountRaw], - functionName: "transfer" - }); - - const txData: EvmTransactionData = { - data: transferCallData as `0x${string}`, - gas: "100000", - maxFeePerGas: String(maxFeePerGas * 3n), - maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), - to: toToken, - value: "0" - }; - - return txData; -} - -/** - * Creates Nabla swap transactions for Base - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param nextNonce Next available nonce - * @returns Updated nonce and state metadata - */ -export async function addNablaSwapTransactionsOnBase( - params: { - quote: QuoteTicketAttributes; - account: AccountMeta; - inputTokenAddress: `0x${string}`; - outputTokenAddress: `0x${string}`; - }, - unsignedTxs: UnsignedTx[], - nextNonce: number -): Promise<{ nextNonce: number; stateMeta: Partial }> { - const { quote, account, inputTokenAddress, outputTokenAddress } = params; - - if (!quote.metadata.nablaSwapEvm?.inputAmountForSwapRaw) { - throw new Error("Missing nablaSwapEvm input amount in quote metadata"); - } - - // The input amount for the swap was already calculated in the quote. - const inputAmountForNablaSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; - // For offramps, outputAmountRaw may include a partner subsidy (merged in - // OffRampMergeSubsidyEvmEngine). Use the AMM-only amount when available so - // the on-chain minimum reflects what the AMM can actually deliver. - const minOutputBaseRaw = quote.metadata.nablaSwapEvm.ammOutputAmountRaw ?? quote.metadata.nablaSwapEvm.outputAmountRaw; - // biome-ignore lint/correctness/noUnusedVariables: retained to keep the downstream interface stable while min-output uses the AMM-only amount - const outputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); - - const nablaSoftMinimumOutputRaw = Big(minOutputBaseRaw) - .mul(1 - AMM_MINIMUM_OUTPUT_SOFT_MARGIN) - .toFixed(0, 0); - const nablaHardMinimumOutputRaw = Big(minOutputBaseRaw) - .mul(1 - AMM_MINIMUM_OUTPUT_HARD_MARGIN) - .toFixed(0, 0); - - const { approve, swap } = await createNablaTransactionsForOnrampOnEVM( - inputAmountForNablaSwapRaw, - account, - inputTokenAddress, - outputTokenAddress, - nablaHardMinimumOutputRaw, - config.swap.deadlineMinutes, - getNablaBasePool(inputTokenAddress, outputTokenAddress).router - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: nextNonce, - phase: "nablaApprove", - signer: account.address, - txData: approve - }); - nextNonce++; - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: nextNonce, - phase: "nablaSwap", - signer: account.address, - txData: swap - }); - nextNonce++; - - return { - nextNonce, - stateMeta: { - nablaSoftMinimumOutputRaw - } - }; -} - -/** - * Creates an approval transaction on the destination chain - * @param params Transaction parameters - * @returns EvmTransactionData - */ -export async function addDestinationChainApprovalTransaction(params: { - amountRaw: string; - spenderAddress: string; - tokenAddress: `0x${string}`; - destinationNetwork: EvmNetworks; -}): Promise { - const { amountRaw, spenderAddress, tokenAddress, destinationNetwork } = params; - - const evmClientManager = EvmClientManager.getInstance(); - const publicClient = evmClientManager.getClient(destinationNetwork); - - const approveCallData = encodeFunctionData({ - abi: erc20ABI, - args: [spenderAddress, amountRaw], - functionName: "approve" - }); - - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - - const txData: EvmTransactionData = { - data: approveCallData as `0x${string}`, - gas: "100000", - maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxPriorityFeePerGas), - to: tokenAddress, - value: "0" - }; - - return txData; -} diff --git a/apps/api/src/api/services/transactions/onramp/common/types.ts b/apps/api/src/api/services/transactions/onramp/common/types.ts deleted file mode 100644 index 005b325f2..000000000 --- a/apps/api/src/api/services/transactions/onramp/common/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AccountMeta, UnsignedTx } from "@vortexfi/shared"; -import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; - -export interface OnrampTransactionParams { - quote: QuoteTicketAttributes; - signingAccounts: AccountMeta[]; - destinationAddress: string; -} - -export type AveniaOnrampTransactionParams = OnrampTransactionParams & { taxId: string }; - -export type AlfredpayOnrampTransactionParams = OnrampTransactionParams & { userId: string }; - -export type MykoboOnrampTransactionParams = OnrampTransactionParams & { - mykoboEmail: string; - ipAddress: string; -}; - -export interface OnrampTransactionsWithMeta { - unsignedTxs: UnsignedTx[]; - stateMeta: Partial>; -} diff --git a/apps/api/src/api/services/transactions/onramp/common/validation.ts b/apps/api/src/api/services/transactions/onramp/common/validation.ts deleted file mode 100644 index 2e1bb2a38..000000000 --- a/apps/api/src/api/services/transactions/onramp/common/validation.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - AccountMeta, - EvmToken, - evmTokenConfig, - FiatToken, - getAnyFiatTokenDetails, - getEvmTokenConfig, - getNetworkFromDestination, - getOnChainTokenDetails, - getOnChainTokenDetailsOrDefault, - isFiatToken, - isMoonbeamTokenDetails, - isOnChainToken, - isOnChainTokenDetails, - MoonbeamTokenDetails, - Networks, - OnChainTokenDetails -} from "@vortexfi/shared"; -import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; - -export function validateAveniaOnramp( - quote: QuoteTicketAttributes, - signingAccounts: AccountMeta[] -): { - toNetwork: Networks; - outputTokenDetails: OnChainTokenDetails; - substrateEphemeralEntry: AccountMeta; - evmEphemeralEntry: AccountMeta; - inputTokenDetails: MoonbeamTokenDetails; -} { - const toNetwork = getNetworkFromDestination(quote.to); - if (!toNetwork) { - throw new Error(`Invalid network for destination ${quote.to}`); - } - - const substrateEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "Substrate"); - if (!substrateEphemeralEntry) { - throw new Error("Pendulum ephemeral not found"); - } - - const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); - if (!evmEphemeralEntry) { - throw new Error("Moonbeam ephemeral not found"); - } - - if (!isFiatToken(quote.inputCurrency)) { - throw new Error(`Input currency must be fiat token for onramp, got ${quote.inputCurrency}`); - } - const inputTokenDetails = getAnyFiatTokenDetails(quote.inputCurrency); - - if (!isMoonbeamTokenDetails(inputTokenDetails)) { - throw new Error(`Input token must be Moonbeam token for onramp, got ${quote.inputCurrency}`); - } - - if (!isOnChainToken(quote.outputCurrency)) { - throw new Error(`Output currency cannot be fiat token ${quote.outputCurrency} for onramp.`); - } - const outputTokenDetails = getOnChainTokenDetails(toNetwork, quote.outputCurrency); - - if (!outputTokenDetails || !isOnChainTokenDetails(outputTokenDetails)) { - throw new Error(`Output token must be on-chain token for onramp, got ${quote.outputCurrency}`); - } - - return { evmEphemeralEntry, inputTokenDetails, outputTokenDetails, substrateEphemeralEntry, toNetwork }; -} - -export function validateAveniaOnrampOnBase( - quote: QuoteTicketAttributes, - signingAccounts: AccountMeta[] -): { - toNetwork: Networks; - outputTokenDetails: OnChainTokenDetails; - evmEphemeralEntry: AccountMeta; - inputTokenDetails: OnChainTokenDetails; -} { - const toNetwork = getNetworkFromDestination(quote.to); - if (!toNetwork) { - throw new Error(`Invalid network for destination ${quote.to}`); - } - - const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); - if (!evmEphemeralEntry) { - throw new Error("Base ephemeral not found"); - } - - if (!isFiatToken(quote.inputCurrency)) { - throw new Error(`Input currency must be fiat token for onramp, got ${quote.inputCurrency}`); - } - - // For Base, we use BRLA's native minted token - const inputTokenDetails = getEvmTokenConfig().base[EvmToken.BRLA]; - if (!inputTokenDetails) { - throw new Error("BRLA token details not found for Base"); - } - - if (!isOnChainToken(quote.outputCurrency)) { - throw new Error(`Output currency cannot be fiat token ${quote.outputCurrency} for onramp.`); - } - const outputTokenDetails = getOnChainTokenDetails(toNetwork, quote.outputCurrency); - - if (!outputTokenDetails || !isOnChainTokenDetails(outputTokenDetails)) { - throw new Error(`Output token must be on-chain token for onramp, got ${quote.outputCurrency}`); - } - - return { evmEphemeralEntry, inputTokenDetails, outputTokenDetails, toNetwork }; -} - -export function validateMykoboOnramp( - quote: QuoteTicketAttributes, - signingAccounts: AccountMeta[] -): { - toNetwork: Networks; - outputTokenDetails: OnChainTokenDetails; - evmEphemeralEntry: AccountMeta; - inputTokenDetails: OnChainTokenDetails; -} { - const toNetwork = getNetworkFromDestination(quote.to); - if (!toNetwork) { - throw new Error(`Invalid network for destination ${quote.to}`); - } - - const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); - if (!evmEphemeralEntry) { - throw new Error("Base ephemeral not found"); - } - - if (quote.inputCurrency !== FiatToken.EURC) { - throw new Error(`Input currency must be EURC for Mykobo onramp, got ${quote.inputCurrency}`); - } - - const inputTokenDetails = getEvmTokenConfig().base[EvmToken.EURC]; - if (!inputTokenDetails) { - throw new Error("EURC token details not found for Base"); - } - - if (!isOnChainToken(quote.outputCurrency)) { - throw new Error(`Output currency cannot be fiat token ${quote.outputCurrency} for onramp.`); - } - const outputTokenDetails = getOnChainTokenDetails(toNetwork, quote.outputCurrency); - - if (!outputTokenDetails || !isOnChainTokenDetails(outputTokenDetails)) { - throw new Error(`Output token must be on-chain token for onramp, got ${quote.outputCurrency}`); - } - - return { evmEphemeralEntry, inputTokenDetails, outputTokenDetails, toNetwork }; -} diff --git a/apps/api/src/api/services/transactions/onramp/index.ts b/apps/api/src/api/services/transactions/onramp/index.ts deleted file mode 100644 index 8c66c4f0e..000000000 --- a/apps/api/src/api/services/transactions/onramp/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { FiatToken, isAlfredpayToken, Networks } from "@vortexfi/shared"; -import { - AlfredpayOnrampTransactionParams, - AveniaOnrampTransactionParams, - OnrampTransactionParams, - OnrampTransactionsWithMeta -} from "./common/types"; -import { prepareAlfredpayToEvmOnrampTransactions } from "./routes/alfredpay-to-evm"; -import { prepareAveniaToAssethubOnrampTransactions } from "./routes/avenia-to-assethub"; -import { prepareAveniaToEvmOnrampTransactionsOnBase } from "./routes/avenia-to-evm-base"; - -export async function prepareOnrampTransactions( - params: AveniaOnrampTransactionParams | AlfredpayOnrampTransactionParams | OnrampTransactionParams -): Promise { - const { quote } = params; - - if (quote.inputCurrency === FiatToken.BRL) { - if (!("taxId" in params)) { - throw new Error("taxId is required for Avenia onramp"); - } - - const aveniaParams: AveniaOnrampTransactionParams = { ...params, taxId: params.taxId }; - - if (quote.to === Networks.AssetHub) { - return prepareAveniaToAssethubOnrampTransactions(aveniaParams); - } else { - return prepareAveniaToEvmOnrampTransactionsOnBase(aveniaParams); - } - } else if (quote.inputCurrency === FiatToken.EURC) { - throw new Error( - "EURC onramp must be prepared via prepareMykoboToEvmOnrampTransactions, not through prepareOnrampTransactions" - ); - } else if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { - if (!("userId" in params)) { - throw new Error("Alfredpay onramps requires logged in user"); - } - - if (quote.to !== Networks.AssetHub) { - return prepareAlfredpayToEvmOnrampTransactions(params); - } else { - throw new Error(`Unsupported destination network for Alfredpay onramp: ${quote.to}`); - } - } else { - throw new Error(`Unsupported input currency: ${quote.inputCurrency}`); - } -} 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 deleted file mode 100644 index 1709fa0e8..000000000 --- a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - ALFREDPAY_ERC20_TOKEN, - createOnrampSquidrouterTransactionsFromPolygonToEvm, - createOnrampSquidrouterTransactionsOnDestinationChain, - ERC20_USDC_POLYGON, - EvmNetworks, - EvmToken, - EvmTokenDetails, - EvmTransactionData, - evmTokenConfig, - getNetworkFromDestination, - getOnChainTokenDetails, - getOnChainTokenDetailsOrDefault, - isEvmToken, - isOnChainToken, - multiplyByPowerOfTen, - Networks, - UnsignedTx -} from "@vortexfi/shared"; -import { isAddress } from "viem"; -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"; -import { AlfredpayOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; - -function getEthereumUsdcAddressForFallback(): `0x${string}` { - const ethereumUsdc = evmTokenConfig.ethereum.USDC; - if (!ethereumUsdc) { - throw new Error("Ethereum USDC token config is required for Alfredpay EVM onramp fallback swap"); - } - - return ethereumUsdc.erc20AddressSourceChain; -} - -/** - * Prepares all transactions for Alfredpay (USD) onramp to EVM chain. - * This route handles: USD → Polygon (USDC/USDT) → EVM (final transfer) - */ -export async function prepareAlfredpayToEvmOnrampTransactions({ - quote, - signingAccounts, - destinationAddress, - userId -}: AlfredpayOnrampTransactionParams): Promise { - let stateMeta: Partial = {}; - const unsignedTxs: UnsignedTx[] = []; - - // Validate that destinationAddress is a valid EVM address for EVM routes - if (!isAddress(destinationAddress)) { - throw new Error(`Invalid destination address for EVM route: ${destinationAddress}. Must be a valid EVM address.`); - } - - const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); - if (!evmEphemeralEntry) { - throw new Error("EVM ephemeral entry not found"); - } - - if (quote.metadata.alfredpayMint?.outputAmountRaw === undefined) { - throw new Error("Missing alfredpay raw mint amount in quote metadata"); - } - - if (!quote.metadata.evmToEvm?.outputAmountRaw) { - throw new Error("Missing evmToEvm raw output amount in quote metadata"); - } - - const toNetwork = getNetworkFromDestination(quote.to); - if (!toNetwork || toNetwork === Networks.AssetHub) { - throw new Error(`Invalid network for destination ${quote.to}`); - } - - if (!isOnChainToken(quote.outputCurrency)) { - throw new Error(`Output currency cannot be fiat token ${quote.outputCurrency} for onramp.`); - } - - const outputTokenDetails = getOnChainTokenDetails(toNetwork, quote.outputCurrency); - if (!outputTokenDetails || !isEvmToken(quote.outputCurrency)) { - throw new Error(`Output token details not found for ${quote.outputCurrency} on network ${toNetwork}`); - } - - const alfredPayId = await resolveAlfredpayCustomerId(quote.inputCurrency, userId); - - // Setup state metadata - stateMeta = { - alfredpayUserId: alfredPayId, - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address - }; - - let polygonAccountNonce = 0; // Starts fresh - const fundingAccount = getEvmFundingAccount(Networks.Polygon); - - // Special case: onramping the AlfredPay token directly on Polygon. Skip SquidRouter and transfer directly. - if ((outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain === ALFREDPAY_ERC20_TOKEN) { - const finalTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), - destinationNetwork: toNetwork as EvmNetworks, - toAddress: destinationAddress, - toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain - }); - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: polygonAccountNonce++, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalTransferTxData) as EvmTransactionData - }); - - const polygonCleanupApproval = await preparePolygonCleanupApproval( - ERC20_USDC_POLYGON, - fundingAccount.address, - Networks.Polygon - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, - nonce: polygonAccountNonce++, - phase: "polygonCleanup", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(polygonCleanupApproval) as EvmTransactionData - }); - - return { stateMeta, unsignedTxs }; - } - - const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = - await createOnrampSquidrouterTransactionsFromPolygonToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: ALFREDPAY_ERC20_TOKEN, - rawAmount: quote.metadata.alfredpayMint.outputAmountRaw, - toNetwork, - toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, // Hardcoded to mint on Polygon - nonce: polygonAccountNonce++, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, - nonce: polygonAccountNonce++, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - - // Same-chain Polygon: destinationTransfer must be the next executable nonce after the swap. The cleanup - // approval runs post-complete, so it follows the transfer. Backup re-swap txs are omitted here (no handler - // executes them, and on a shared nonce sequence they would push destinationTransfer beyond the live nonce). - if (toNetwork === Networks.Polygon) { - const sameChainTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), - destinationNetwork: Networks.Polygon, - toAddress: destinationAddress, - toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain - }); - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, - nonce: polygonAccountNonce++, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(sameChainTransferTxData) as EvmTransactionData - }); - - const sameChainCleanupApproval = await preparePolygonCleanupApproval( - ERC20_USDC_POLYGON, - fundingAccount.address, - Networks.Polygon - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, - nonce: polygonAccountNonce++, - phase: "polygonCleanup", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(sameChainCleanupApproval) as EvmTransactionData - }); - - stateMeta = { - ...stateMeta, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId - }; - - return { stateMeta, unsignedTxs }; - } - - const polygonCleanupApproval = await preparePolygonCleanupApproval( - ERC20_USDC_POLYGON, - fundingAccount.address, - Networks.Polygon - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, - nonce: polygonAccountNonce++, - phase: "polygonCleanup", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(polygonCleanupApproval) as EvmTransactionData - }); - - const finalTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), - destinationNetwork: toNetwork as EvmNetworks, - toAddress: destinationAddress, - toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain - }); - - let destinationNonce = 0; - const destinationStartingNonce = destinationNonce; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce++, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalTransferTxData) as EvmTransactionData - }); - - // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. - const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; - const bridgedTokenForFallback = - toNetwork === Networks.Ethereum ? getEthereumUsdcAddressForFallback() : destinationAxlUsdcDetails.erc20AddressSourceChain; - const bridgedTokenAddress = bridgedTokenForFallback as `0x${string}`; - - const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: bridgedTokenAddress, - network: toNetwork as EvmNetworks, - rawAmount: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), - toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(destApproveData) as EvmTransactionData - }); - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(destSwapData) as EvmTransactionData - }); - destinationNonce++; - - const maxUint256 = 2n ** 256n - 1n; - - const backupApproveTransaction = await addDestinationChainApprovalTransaction({ - amountRaw: maxUint256.toString(), - destinationNetwork: toNetwork as EvmNetworks, - spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenAddress - }); - - // We set this to the destinationTransfer nonce on purpose because we don't want to risk that the required nonce is never reached - const backupApproveNonce = destinationStartingNonce; - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: backupApproveNonce, - phase: "backupApprove", - signer: evmEphemeralEntry.address, - txData: backupApproveTransaction - }); - - const alfredMintFallbackTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: quote.metadata.alfredpayMint.outputAmountRaw, - destinationNetwork: Networks.Polygon as EvmNetworks, - toAddress: destinationAddress, - toToken: ALFREDPAY_ERC20_TOKEN - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Polygon, - nonce: polygonAccountNonce++, - phase: "alfredOnrampMintFallback", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(alfredMintFallbackTransferTxData) as EvmTransactionData - }); - - stateMeta = { - ...stateMeta, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId - }; - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-assethub.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-assethub.ts deleted file mode 100644 index 45dc7bf8e..000000000 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-assethub.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - createPendulumToAssethubTransfer, - createPendulumToHydrationTransfer, - encodeSubmittableExtrinsic, - getNetworkId, - getPendulumDetails, - isAssetHubTokenDetails, - Networks, - normalizeTaxId, - UnsignedTx -} from "@vortexfi/shared"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { addFeeDistributionTransaction } from "../../common/feeDistribution"; -import { buildHydrationSwapTransaction, buildHydrationToAssetHubTransfer } from "../../hydration"; -import { prepareHydrationCleanupTransaction } from "../../hydration/cleanup"; -import { addMoonbeamTransactions, addNablaSwapTransactions, addPendulumCleanupTx } from "../common/transactions"; -import { AveniaOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; -import { validateAveniaOnramp } from "../common/validation"; - -/** - * Prepares all transactions for an Avenia (BRL) onramp to AssetHub. - * This route handles: BRL → Moonbeam (BRLA) → Pendulum (swap) → AssetHub (final transfer) - */ -export async function prepareAveniaToAssethubOnrampTransactions({ - quote, - signingAccounts, - destinationAddress, - taxId -}: AveniaOnrampTransactionParams): Promise { - let stateMeta: Partial = {}; - const unsignedTxs: UnsignedTx[] = []; - - // Validate inputs and extract required data - const { toNetwork, outputTokenDetails, substrateEphemeralEntry, evmEphemeralEntry, inputTokenDetails } = validateAveniaOnramp( - quote, - signingAccounts - ); - const toNetworkId = getNetworkId(toNetwork); - - // Get token details - const inputTokenPendulumDetails = getPendulumDetails(quote.inputCurrency); - const outputTokenPendulumDetails = getPendulumDetails(quote.outputCurrency, toNetwork); - - // Setup state metadata - stateMeta = { - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - substrateEphemeralAddress: substrateEphemeralEntry.address, - taxId: normalizeTaxId(taxId) - }; - - // Moonbeam: Initial BRLA transfer to Pendulum - if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { - throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); - } - const inputAmountPostAnchorFeeRaw = quote.metadata.aveniaTransfer.outputAmountRaw; - - await addMoonbeamTransactions( - { - account: evmEphemeralEntry, - fromToken: inputTokenDetails.moonbeamErc20Address, - inputAmountRaw: inputAmountPostAnchorFeeRaw, - pendulumEphemeralAddress: substrateEphemeralEntry.address, - toNetworkId - }, - unsignedTxs, - 0 // start nonce - ); - - // Pendulum: Nabla swap and fee distribution - let pendulumNonce = 0; - - // Add Nabla swap transactions - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactions( - { - account: substrateEphemeralEntry, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - quote - }, - unsignedTxs, - pendulumNonce - ); - stateMeta = { ...stateMeta, ...nablaStateMeta }; - pendulumNonce = nonceAfterNabla; - - // Add fee distribution - pendulumNonce = await addFeeDistributionTransaction(quote, substrateEphemeralEntry, unsignedTxs, pendulumNonce); - - // Finalization: Transfer to AssetHub - const pendulumCleanupTx = await addPendulumCleanupTx({ - account: substrateEphemeralEntry, - inputTokenPendulumDetails, - outputTokenPendulumDetails - }); - - if (quote.outputCurrency === "USDC") { - if (!quote.metadata.pendulumToAssethubXcm?.inputAmountRaw) { - throw new Error("Missing input amount for Pendulum to Assethub transfer"); - } - const transferAmountRaw = quote.metadata.pendulumToAssethubXcm.inputAmountRaw; - - const pendulumToAssethubXcmTransaction = await createPendulumToAssethubTransfer( - destinationAddress, - outputTokenDetails.pendulumRepresentative.currencyId, - transferAmountRaw - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: pendulumNonce, - phase: "pendulumToAssethubXcm", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(pendulumToAssethubXcmTransaction) - }); - pendulumNonce++; - } else { - if (!quote.metadata.pendulumToHydrationXcm?.inputAmountRaw) { - throw new Error("Missing input amount for Pendulum to Hydration transfer"); - } - const transferAmountRaw = quote.metadata.pendulumToHydrationXcm.inputAmountRaw; - - const pendulumToHydrationXcmTransaction = await createPendulumToHydrationTransfer( - substrateEphemeralEntry.address, - outputTokenDetails.pendulumRepresentative.currencyId, - transferAmountRaw - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: pendulumNonce, - phase: "pendulumToHydrationXcm", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(pendulumToHydrationXcmTransaction) - }); - pendulumNonce++; - - if (!quote.metadata.hydrationSwap) { - throw new Error("Missing hydration swap details for Hydration finalization"); - } - - // Keep the hydration nonce at 0. It doesn't increase on the network for some reason - const hydrationNonce = 0; - const { inputAsset, outputAsset, inputAmountDecimal, minOutputAmountRaw } = quote.metadata.hydrationSwap; - const hydrationSwap = await buildHydrationSwapTransaction( - inputAsset, - outputAsset, - inputAmountDecimal, - substrateEphemeralEntry.address, - quote.metadata.hydrationSwap.slippagePercent - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Hydration, - nonce: hydrationNonce, - phase: "hydrationSwap", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(hydrationSwap) - }); - - // Transfer from Hydration to AssetHub - if (!isAssetHubTokenDetails(outputTokenDetails)) { - throw new Error( - `Output token must be an AssetHub token for finalization to AssetHub, got ${outputTokenDetails.assetSymbol}` - ); - } - const hydrationAssetId = outputTokenDetails.hydrationId; - // biome-ignore lint/style/noNonNullAssertion: Checked by isAssetHubTokenDetails - const assethubAssetId = outputTokenDetails.isNative ? "native" : outputTokenDetails.foreignAssetId!; - - const hydrationToAssethubTransfer = await buildHydrationToAssetHubTransfer( - destinationAddress, - minOutputAmountRaw, - hydrationAssetId, - assethubAssetId - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Hydration, - nonce: hydrationNonce, - phase: "hydrationToAssethubXcm", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(hydrationToAssethubTransfer) - }); - - const hydrationCleanupTx = await prepareHydrationCleanupTransaction(inputAsset, outputAsset); - unsignedTxs.push({ - meta: {}, - network: Networks.Hydration, - nonce: hydrationNonce, - phase: "hydrationCleanup", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(hydrationCleanupTx) - }); - } - - // Add cleanup - unsignedTxs.push({ - ...pendulumCleanupTx, - nonce: pendulumNonce - }); - - return { stateMeta, unsignedTxs }; -} 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 deleted file mode 100644 index 6ee471483..000000000 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import {afterAll, beforeEach, describe, expect, it, mock} from "bun:test"; -import Big from "big.js"; - -const EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; -const DESTINATION_ADDRESS = "0x2222222222222222222222222222222222222222"; -const FUNDING_ADDRESS = "0x3333333333333333333333333333333333333333"; -const BRLA_BASE = "0xfCB34c47f850f452C15EA1B84d51231C38A61783"; -const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; -const USDT_BSC = "0x55d398326f99059fF775485246999027B3197955"; -const AXL_USDC_BSC = "0x4268B8F0B87b6Eae5d897996E6b845ddbD99Adf3"; - -const Networks = { - BSC: "bsc", - Base: "base", - Ethereum: "ethereum" -} as const; - -const EvmToken = { - AXLUSDC: "AXLUSDC", - USDC: "USDC", - USDT: "USDT" -} as const; - -const FiatToken = { - BRL: "BRL", - EURC: "EUR" -} as const; - -const destinationTransferCalls: Array<{ amountRaw: string; destinationNetwork: string }> = []; - -const addOnrampDestinationChainTransactions = mock(async (params: { amountRaw: string; destinationNetwork: string }) => { - destinationTransferCalls.push(params); - if (!/^\d+$/.test(params.amountRaw)) { - throw new Error(`expected integer raw amount, got ${params.amountRaw}`); - } - return { - data: "0xdestination", - gas: "100000", - maxFeePerGas: "1", - maxPriorityFeePerGas: "1", - to: DESTINATION_ADDRESS, - value: "0" - }; -}); - - -// 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", - squidRouterReceiverHash: "0xreceiverhash", - squidRouterReceiverId: "receiver", - swapData: { data: "0xswap", gas: "200000", to: USDC_BASE, value: "0" } - })), - createOnrampSquidrouterTransactionsOnDestinationChain: mock(async () => ({ - approveData: { data: "0xbackupapprove", gas: "100000", to: AXL_USDC_BSC, value: "0" }, - swapData: { data: "0xbackupswap", gas: "200000", to: AXL_USDC_BSC, value: "0" } - })), - EvmToken, - FiatToken, - evmTokenConfig: { - [Networks.Base]: { - [EvmToken.USDC]: { - assetSymbol: "USDC", - decimals: 6, - erc20AddressSourceChain: USDC_BASE, - isNative: false, - network: Networks.Base, - type: "evm" - } - }, - [Networks.BSC]: { - [EvmToken.AXLUSDC]: { - assetSymbol: "axlUSDC", - decimals: 6, - erc20AddressSourceChain: AXL_USDC_BSC, - isNative: false, - network: Networks.BSC, - type: "evm" - }, - [EvmToken.USDT]: { - assetSymbol: "USDT", - decimals: 18, - erc20AddressSourceChain: USDT_BSC, - isNative: false, - network: Networks.BSC, - type: "evm" - } - }, - [Networks.Ethereum]: { - [EvmToken.USDC]: { - assetSymbol: "USDC", - decimals: 6, - erc20AddressSourceChain: USDC_BASE, - isNative: false, - network: Networks.Ethereum, - type: "evm" - } - } - }, - getOnChainTokenDetailsOrDefault: mock(() => ({ - assetSymbol: "axlUSDC", - decimals: 6, - erc20AddressSourceChain: AXL_USDC_BSC, - isNative: false, - network: Networks.BSC, - type: "evm" - })), - isEvmTokenDetails: () => true, - isNativeEvmToken: (details: { isNative?: boolean }) => details.isNative === true, - multiplyByPowerOfTen: (value: Big.BigSource, power: number) => { - const result = new Big(value); - if (result.c[0] !== 0) result.e += power; - return result; - }, - Networks -})); - -mock.module("../common/validation", () => ({ - validateAveniaOnrampOnBase: mock(() => ({ - evmEphemeralEntry: { - address: EVM_EPHEMERAL_ADDRESS, - type: "EVM" - }, - inputTokenDetails: { - assetSymbol: "BRLA", - decimals: 18, - erc20AddressSourceChain: BRLA_BASE, - isNative: false, - network: Networks.Base, - type: "evm" - }, - outputTokenDetails: { - assetSymbol: "USDT", - decimals: 18, - erc20AddressSourceChain: USDT_BSC, - isNative: false, - network: Networks.BSC, - type: "evm" - }, - toNetwork: Networks.BSC - })) -})); - -mock.module("../common/transactions", () => ({ - addDestinationChainApprovalTransaction: mock(async () => ({ - data: "0xbackupapprove", - gas: "100000", - maxFeePerGas: "1", - maxPriorityFeePerGas: "1", - to: AXL_USDC_BSC, - value: "0" - })), - addNablaSwapTransactionsOnBase: mock(async (_params, _unsignedTxs, nextNonce: number) => ({ - nextNonce: nextNonce + 2, - stateMeta: { nablaSoftMinimumOutputRaw: "4818988497" } - })), - addOnrampDestinationChainTransactions -})); - -mock.module("../../common/feeDistribution", () => ({ - addEvmFeeDistributionTransaction: mock(async (_quote, _account, _unsignedTxs, nextNonce: number) => nextNonce) -})); - -mock.module("../../base/cleanup", () => ({ - prepareBaseCleanupApproval: mock(async () => ({ - data: "0xcleanup", - gas: "100000", - maxFeePerGas: "1", - maxPriorityFeePerGas: "1", - to: USDC_BASE, - value: "0" - })) -})); - -mock.module("../../index", () => ({ - encodeEvmTransactionData: (data: unknown) => data -})); - -mock.module("../../../phases/evm-funding", () => ({ - getEvmFundingAccount: () => ({ address: FUNDING_ADDRESS }) -})); - -mock.module("../../../../../config/logger", () => ({ - default: { - debug: mock(() => undefined) - } -})); - -const { prepareAveniaToEvmOnrampTransactionsOnBase } = await import("./avenia-to-evm-base"); - -describe("prepareAveniaToEvmOnrampTransactionsOnBase", () => { - beforeEach(() => { - destinationTransferCalls.length = 0; - addOnrampDestinationChainTransactions.mockClear(); - }); - - it("preserves 18-decimal BSC USDT precision for the final destination raw amount", async () => { - await prepareAveniaToEvmOnrampTransactionsOnBase({ - destinationAddress: DESTINATION_ADDRESS, - quote: { - inputCurrency: "BRL", - metadata: { - aveniaTransfer: { - outputAmountRaw: "25002249808000000000000" - }, - evmToEvm: { - inputAmountRaw: "4818926798" - }, - nablaSwapEvm: { - inputAmountForSwapRaw: "25002249808000000000000", - outputAmountRaw: "4818988497" - } - }, - network: Networks.BSC, - outputAmount: "4817.805726163073314321", - outputCurrency: EvmToken.USDT, - to: Networks.BSC - } as never, - signingAccounts: [{ address: EVM_EPHEMERAL_ADDRESS, type: "EVM" }] as never, - taxId: "12345678901" - }); - - expect(destinationTransferCalls).toContainEqual( - expect.objectContaining({ - amountRaw: "4817805726163073314321", - destinationNetwork: Networks.BSC - }) - ); - }); -}); diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts deleted file mode 100644 index de0394e55..000000000 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { - createOnrampSquidrouterTransactionsFromBaseToEvm, - createOnrampSquidrouterTransactionsOnDestinationChain, - EvmNetworks, - EvmToken, - EvmTokenDetails, - EvmTransactionData, - evmTokenConfig, - getOnChainTokenDetailsOrDefault, - isEvmTokenDetails, - isNativeEvmToken, - multiplyByPowerOfTen, - Networks, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import { isAddress } from "viem"; -import logger from "../../../../../config/logger"; -import { getEvmFundingAccount } from "../../../phases/evm-funding"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { isBrlToBrlaBaseDirect } from "../../../quote/utils"; -import { prepareBaseCleanupApproval } from "../../base/cleanup"; -import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; -import { encodeEvmTransactionData } from "../../index"; -import { - addDestinationChainApprovalTransaction, - addNablaSwapTransactionsOnBase, - addOnrampDestinationChainTransactions -} from "../common/transactions"; -import { AveniaOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; -import { validateAveniaOnrampOnBase } from "../common/validation"; - -/** - * Prepares all transactions for an Avenia (BRL) onramp to EVM chain via Base. - * This route handles: BRL → Base (BRLA) -> Swap (to USDC) → EVM (final transfer) - */ -export async function prepareAveniaToEvmOnrampTransactionsOnBase({ - quote, - signingAccounts, - destinationAddress, - taxId -}: AveniaOnrampTransactionParams): Promise { - let stateMeta: Partial = {}; - const unsignedTxs: UnsignedTx[] = []; - - // Validate that destinationAddress is a valid EVM address for EVM routes - if (!isAddress(destinationAddress)) { - throw new Error(`Invalid destination address for EVM route: ${destinationAddress}. Must be a valid EVM address.`); - } - - // Validate inputs and extract required data - const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateAveniaOnrampOnBase( - quote, - signingAccounts - ); - logger.debug(`Starting prepareAveniaToEvmOnrampTransactionsOnBase with destinationAddress: ${destinationAddress}`); - const isDirectTransfer = isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network); - // Setup state metadata - stateMeta = { - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - isDirectTransfer, - taxId - }; - - let baseNonce = 0; - - if (!isEvmTokenDetails(outputTokenDetails)) { - throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); - } - - // BRL→BRLA on Base: Avenia already minted the requested BRLA, so no Nabla swap, fee-token - // conversion, or SquidRouter step is needed — transfer the minted BRLA straight to the user. - if (isDirectTransfer) { - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw, - destinationNetwork: Networks.Base, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - return { stateMeta, unsignedTxs }; - } - - if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { - throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); - } - - if (!quote.metadata.evmToEvm?.inputAmountRaw) { - throw new Error("Missing evmToEvm inputAmountRaw in quote metadata"); - } - - // Output for BRLA onramp will always go through USDC. - // TODO. Unless the actual BRLA token wants to be onramped. - const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; - if (!nablaSwapOutputTokenAddress) { - throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); - } - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( - { - account: evmEphemeralEntry, - inputTokenAddress: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, - outputTokenAddress: nablaSwapOutputTokenAddress, - quote - }, - unsignedTxs, - baseNonce - ); - stateMeta = { ...stateMeta, ...nablaStateMeta }; - baseNonce = nonceAfterNabla; - - baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); - - // Special case, onramping USDC on Base. We need to skip the SquidRouter step and go directly to the destination transfer. - if (toNetwork === Networks.Base && outputTokenDetails.erc20AddressSourceChain === nablaSwapOutputTokenAddress) { - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw, - destinationNetwork: Networks.Base, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; - const brlaTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain as `0x${string}`; - - const brlaCleanupApproval = await prepareBaseCleanupApproval(brlaTokenAddress, baseFundingAccountAddress, Networks.Base); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupBrla", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(brlaCleanupApproval) as EvmTransactionData - }); - - const usdcCleanupApproval = await prepareBaseCleanupApproval( - nablaSwapOutputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData - }); - - return { stateMeta, unsignedTxs }; - } - - const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = - await createOnrampSquidrouterTransactionsFromBaseToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: nablaSwapOutputTokenAddress, - rawAmount: quote.metadata.evmToEvm?.inputAmountRaw, - toNetwork, - toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - - // Same-chain Base: destinationTransfer must be the next executable nonce after the swap. Cleanups run - // post-complete, so they follow the transfer. Backup re-swap txs are omitted here (no handler executes - // them, and on a shared nonce sequence they would push destinationTransfer beyond the live nonce). - if (toNetwork === Networks.Base) { - const sameChainDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw, - destinationNetwork: Networks.Base, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: sameChainDestinationTransfer - }); - - const sameChainFundingAddress = getEvmFundingAccount(Networks.Base).address; - const sameChainBrlaAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain as `0x${string}`; - - const brlaCleanup = await prepareBaseCleanupApproval(sameChainBrlaAddress, sameChainFundingAddress, Networks.Base); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupBrla", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(brlaCleanup) as EvmTransactionData - }); - - const usdcCleanup = await prepareBaseCleanupApproval( - nablaSwapOutputTokenAddress as `0x${string}`, - sameChainFundingAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(usdcCleanup) as EvmTransactionData - }); - - stateMeta = { - ...stateMeta, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId - }; - - return { stateMeta, unsignedTxs }; - } - - const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; - const brlaTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain as `0x${string}`; - - const brlaCleanupApproval = await prepareBaseCleanupApproval(brlaTokenAddress, baseFundingAccountAddress, Networks.Base); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupBrla", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(brlaCleanupApproval) as EvmTransactionData - }); - - const usdcCleanupApproval = await prepareBaseCleanupApproval( - nablaSwapOutputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData - }); - - let destinationNonce = 0; - const destinationStartingNonce = destinationNonce; - - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw, - destinationNetwork: toNetwork as EvmNetworks, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. - const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; - let bridgedTokenForFallback: `0x${string}`; - if (toNetwork === Networks.Ethereum) { - const ethereumUsdc = evmTokenConfig.ethereum.USDC; - if (!ethereumUsdc) { - throw new Error("USDC config missing for Ethereum"); - } - bridgedTokenForFallback = ethereumUsdc.erc20AddressSourceChain as `0x${string}`; - } else { - bridgedTokenForFallback = destinationAxlUsdcDetails.erc20AddressSourceChain as `0x${string}`; - } - - const inputAmountRawFinalBridge = quote.metadata.evmToEvm?.inputAmountRaw; - if (!inputAmountRawFinalBridge) { - throw new Error("Missing input amount for final bridge in quote metadata"); - } - - // Destination chain: Squidrouter swap to final token - const { approveData: finalApproveData, swapData: finalSwapData } = - await createOnrampSquidrouterTransactionsOnDestinationChain({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: bridgedTokenForFallback, - network: toNetwork as EvmNetworks, - rawAmount: inputAmountRawFinalBridge, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalApproveData) as EvmTransactionData - }); - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalSwapData) as EvmTransactionData - }); - destinationNonce++; - - const fundingAccount = getEvmFundingAccount(Networks.Base); - - // Bound approval to the bridged amount + 5% slippage cushion (replaces unbounded maxUint256). - const backupApproveAmountRaw = new Big(inputAmountRawFinalBridge).mul("1.05").toFixed(0, 0); - - const backupApproveTransaction = await addDestinationChainApprovalTransaction({ - amountRaw: backupApproveAmountRaw, - destinationNetwork: toNetwork as EvmNetworks, - spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenForFallback - }); - - // We set this to the destinationTransfer nonce on purpose because we don't want to risk that the required nonce is never reached - const backupApproveNonce = destinationStartingNonce; - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: backupApproveNonce, - phase: "backupApprove", - signer: evmEphemeralEntry.address, - txData: backupApproveTransaction - }); - - stateMeta = { - ...stateMeta, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId - }; - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts deleted file mode 100644 index a6791729d..000000000 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { - AXL_USDC_MOONBEAM_DETAILS, - createOnrampSquidrouterTransactionsFromMoonbeamToEvm, - createOnrampSquidrouterTransactionsOnDestinationChain, - createPendulumToMoonbeamTransfer, - EvmNetworks, - EvmToken, - EvmTokenDetails, - EvmTransactionData, - encodeSubmittableExtrinsic, - evmTokenConfig, - getNetworkId, - getOnChainTokenDetailsOrDefault, - getPendulumDetails, - isEvmTokenDetails, - isNativeEvmToken, - multiplyByPowerOfTen, - Networks, - normalizeTaxId, - UnsignedTx -} from "@vortexfi/shared"; -import { isAddress } from "viem"; -import { getEvmFundingAccount } from "../../../phases/evm-funding"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { addFeeDistributionTransaction } from "../../common/feeDistribution"; -import { encodeEvmTransactionData } from "../../index"; -import { - addDestinationChainApprovalTransaction, - addMoonbeamTransactions, - addNablaSwapTransactions, - addOnrampDestinationChainTransactions, - addPendulumCleanupTx -} from "../common/transactions"; -import { AveniaOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; -import { validateAveniaOnramp } from "../common/validation"; - -/** - * Prepares all transactions for an Avenia (BRL) onramp to EVM chain. - * This route handles: BRL → Moonbeam (BRLA) → Pendulum (swap) → Moonbeam → EVM (final transfer) - */ -export async function prepareAveniaToEvmOnrampTransactions({ - quote, - signingAccounts, - destinationAddress, - taxId -}: AveniaOnrampTransactionParams): Promise { - let stateMeta: Partial = {}; - const unsignedTxs: UnsignedTx[] = []; - - // Validate that destinationAddress is a valid EVM address for EVM routes - if (!isAddress(destinationAddress)) { - throw new Error(`Invalid destination address for EVM route: ${destinationAddress}. Must be a valid EVM address.`); - } - - // Validate inputs and extract required data - const { toNetwork, outputTokenDetails, substrateEphemeralEntry, evmEphemeralEntry, inputTokenDetails } = validateAveniaOnramp( - quote, - signingAccounts - ); - const toNetworkId = getNetworkId(toNetwork); - - // Get token details - const inputTokenPendulumDetails = getPendulumDetails(quote.inputCurrency); - const outputTokenPendulumDetails = getPendulumDetails(quote.outputCurrency, toNetwork); - - // Setup state metadata - stateMeta = { - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - substrateEphemeralAddress: substrateEphemeralEntry.address, - taxId: normalizeTaxId(taxId) - }; - - let moonbeamNonce = 0; - - // Moonbeam: Initial BRLA transfer to Pendulum - if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { - throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); - } - const inputAmountPostAnchorFeeRaw = quote.metadata.aveniaTransfer.outputAmountRaw; - - moonbeamNonce = await addMoonbeamTransactions( - { - account: evmEphemeralEntry, - fromToken: inputTokenDetails.moonbeamErc20Address, - inputAmountRaw: inputAmountPostAnchorFeeRaw, - pendulumEphemeralAddress: substrateEphemeralEntry.address, - toNetworkId - }, - unsignedTxs, - moonbeamNonce - ); - - // Pendulum: Nabla swap and transfer to Moonbeam - let pendulumNonce = 0; - - // Add Nabla swap transactions - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactions( - { - account: substrateEphemeralEntry, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - quote - }, - unsignedTxs, - pendulumNonce - ); - stateMeta = { ...stateMeta, ...nablaStateMeta }; - pendulumNonce = nonceAfterNabla; - - // Add fee distribution - pendulumNonce = await addFeeDistributionTransaction(quote, substrateEphemeralEntry, unsignedTxs, pendulumNonce); - - // Transfer from Pendulum to Moonbeam - const pendulumCleanupTx = await addPendulumCleanupTx({ - account: substrateEphemeralEntry, - inputTokenPendulumDetails, - outputTokenPendulumDetails - }); - - if (!quote.metadata.pendulumToMoonbeamXcm?.inputAmountRaw || !quote.metadata.moonbeamToEvm?.inputAmountRaw) { - throw new Error("Missing bridge output amount for Moonbeam"); - } - - const pendulumToMoonbeamXcmTransaction = await createPendulumToMoonbeamTransfer( - evmEphemeralEntry.address, - quote.metadata.pendulumToMoonbeamXcm.inputAmountRaw, - outputTokenDetails.pendulumRepresentative.currencyId - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: pendulumNonce, - phase: "pendulumToMoonbeamXcm", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(pendulumToMoonbeamXcmTransaction) - }); - pendulumNonce++; - - unsignedTxs.push({ - ...pendulumCleanupTx, - nonce: pendulumNonce - }); - pendulumNonce++; - - // Moonbeam: Squidrouter swap to target EVM token - if (!isEvmTokenDetails(outputTokenDetails)) { - throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); - } - - const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; - - const { approveData, swapData } = await createOnrampSquidrouterTransactionsFromMoonbeamToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: AXL_USDC_MOONBEAM_DETAILS.erc20AddressSourceChain, - moonbeamEphemeralStartingNonce: moonbeamNonce, - rawAmount: quote.metadata.moonbeamToEvm.inputAmountRaw, - toNetwork: outputTokenDetails.network, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Moonbeam, - nonce: moonbeamNonce, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - moonbeamNonce++; - - unsignedTxs.push({ - meta: {}, - network: Networks.Moonbeam, - nonce: moonbeamNonce, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - moonbeamNonce++; - - // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. - let bridgedTokenForFallback: `0x${string}`; - if (toNetwork === Networks.Ethereum) { - const ethereumUsdc = evmTokenConfig.ethereum.USDC; - if (!ethereumUsdc) { - throw new Error("USDC config missing for Ethereum"); - } - bridgedTokenForFallback = ethereumUsdc.erc20AddressSourceChain as `0x${string}`; - } else { - bridgedTokenForFallback = destinationAxlUsdcDetails.erc20AddressSourceChain as `0x${string}`; - } - - const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: bridgedTokenForFallback, - network: toNetwork as EvmNetworks, - rawAmount: quote.metadata.moonbeamToEvm.inputAmountRaw, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - let destinationNonce = 0; - - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); - - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw, - destinationNetwork: toNetwork as EvmNetworks, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(destApproveData) as EvmTransactionData - }); - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(destSwapData) as EvmTransactionData - }); - destinationNonce++; - - const maxUint256 = 2n ** 256n - 1n; - const fundingAccount = getEvmFundingAccount(Networks.Moonbeam); - - const backupApproveTransaction = await addDestinationChainApprovalTransaction({ - amountRaw: maxUint256.toString(), - destinationNetwork: toNetwork as EvmNetworks, - spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenForFallback - }); - - // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached - const backupApproveNonce = 0; - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: backupApproveNonce, - phase: "backupApprove", - signer: evmEphemeralEntry.address, - txData: backupApproveTransaction - }); - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts deleted file mode 100644 index 02a0a1454..000000000 --- a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { - createOnrampSquidrouterTransactionsFromBaseToEvm, - createOnrampSquidrouterTransactionsOnDestinationChain, - EvmNetworks, - EvmToken, - EvmTokenDetails, - EvmTransactionData, - evmTokenConfig, - getOnChainTokenDetailsOrDefault, - isEvmTokenDetails, - isNativeEvmToken, - multiplyByPowerOfTen, - Networks, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import { isAddress } from "viem"; -import logger from "../../../../../config/logger"; -import { getEvmFundingAccount } from "../../../phases/evm-funding"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { isEurToEurcBaseDirect } from "../../../quote/utils"; -import { prepareBaseCleanupApproval } from "../../base/cleanup"; -import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; -import { encodeEvmTransactionData } from "../../index"; -import { - addDestinationChainApprovalTransaction, - addNablaSwapTransactionsOnBase, - addOnrampDestinationChainTransactions -} from "../common/transactions"; -import { MykoboOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; -import { validateMykoboOnramp } from "../common/validation"; - -/** - * Prepares all transactions for a Mykobo (EUR) onramp to an EVM chain via Base. - * - * Flow: user SEPA deposit → EURC on Base ephemeral → Nabla swap EURC→USDC → SquidRouter to destination chain. - * - * Unlike Avenia/BRLA, no on-chain mint step is required: Mykobo settles the SEPA deposit - * directly on the Base ephemeral as EURC. The Mykobo deposit intent is expected to have been - * created by the caller; its identifiers are threaded into stateMeta. - */ -export async function prepareMykoboToEvmOnrampTransactions({ - quote, - signingAccounts, - destinationAddress, - mykoboEmail, - mykoboTransactionId, - mykoboTransactionReference -}: MykoboOnrampTransactionParams & { - mykoboTransactionId: string; - mykoboTransactionReference: string; -}): Promise { - let stateMeta: Partial = {}; - const unsignedTxs: UnsignedTx[] = []; - - if (!isAddress(destinationAddress)) { - throw new Error(`Invalid destination address for EVM route: ${destinationAddress}. Must be a valid EVM address.`); - } - - const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateMykoboOnramp(quote, signingAccounts); - logger.debug(`Starting prepareMykoboToEvmOnrampTransactions with destinationAddress: ${destinationAddress}`); - - if (!isEvmTokenDetails(outputTokenDetails)) { - throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); - } - - const isDirectTransfer = isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network); - if (!isDirectTransfer && !quote.metadata.nablaSwapEvm?.outputAmountRaw) { - throw new Error("Missing nablaSwapEvm.outputAmountRaw in quote metadata for Mykobo onramp"); - } - - if (!isDirectTransfer && !quote.metadata.evmToEvm?.inputAmountRaw) { - throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Mykobo onramp"); - } - const bridgeInputAmountRaw = quote.metadata.evmToEvm?.inputAmountRaw; - - stateMeta = { - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - isDirectTransfer, - mykoboEmail, - mykoboTransactionId, - mykoboTransactionReference, - walletAddress: destinationAddress - }; - - let baseNonce = 0; - - if (isDirectTransfer) { - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw, - destinationNetwork: Networks.Base, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - return { stateMeta, unsignedTxs }; - } - - const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; - if (!nablaSwapOutputTokenAddress) { - throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); - } - const eurcInputTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain; - - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( - { - account: evmEphemeralEntry, - inputTokenAddress: eurcInputTokenAddress, - outputTokenAddress: nablaSwapOutputTokenAddress, - quote - }, - unsignedTxs, - baseNonce - ); - stateMeta = { ...stateMeta, ...nablaStateMeta }; - baseNonce = nonceAfterNabla; - - baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); - - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0); - - // Special case: onramping USDC on Base. Skip SquidRouter and transfer directly to destination. - if (toNetwork === Networks.Base && outputTokenDetails.erc20AddressSourceChain === nablaSwapOutputTokenAddress) { - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw, - destinationNetwork: Networks.Base, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; - - const eurcCleanupApproval = await prepareBaseCleanupApproval( - eurcInputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupEurc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(eurcCleanupApproval) as EvmTransactionData - }); - - const usdcCleanupApproval = await prepareBaseCleanupApproval( - nablaSwapOutputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData - }); - - return { stateMeta, unsignedTxs }; - } - - const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = - await createOnrampSquidrouterTransactionsFromBaseToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: nablaSwapOutputTokenAddress, - rawAmount: bridgeInputAmountRaw as string, - toNetwork, - toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - - // Same-chain Base: destinationTransfer must be the next executable nonce after the swap. Cleanups run - // post-complete, so they follow the transfer. Backup re-swap txs are omitted here (no handler executes - // them, and on a shared nonce sequence they would push destinationTransfer beyond the live nonce). - if (toNetwork === Networks.Base) { - const sameChainDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw, - destinationNetwork: Networks.Base, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: sameChainDestinationTransfer - }); - - const sameChainFundingAddress = getEvmFundingAccount(Networks.Base).address; - - const eurcCleanup = await prepareBaseCleanupApproval( - eurcInputTokenAddress as `0x${string}`, - sameChainFundingAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupEurc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(eurcCleanup) as EvmTransactionData - }); - - const usdcCleanup = await prepareBaseCleanupApproval( - nablaSwapOutputTokenAddress as `0x${string}`, - sameChainFundingAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(usdcCleanup) as EvmTransactionData - }); - - stateMeta = { - ...stateMeta, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId - }; - - return { stateMeta, unsignedTxs }; - } - - const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; - - const eurcCleanupApproval = await prepareBaseCleanupApproval( - eurcInputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupEurc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(eurcCleanupApproval) as EvmTransactionData - }); - - const usdcCleanupApproval = await prepareBaseCleanupApproval( - nablaSwapOutputTokenAddress as `0x${string}`, - baseFundingAccountAddress, - Networks.Base - ); - unsignedTxs.push({ - meta: {}, - network: Networks.Base, - nonce: baseNonce++, - phase: "baseCleanupUsdc", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData - }); - - let destinationNonce = 0; - const destinationStartingNonce = destinationNonce; - - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw, - destinationNetwork: toNetwork as EvmNetworks, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - // Fallback bridged token: USDC for Ethereum, axlUSDC for all other EVM chains. Mirrors avenia-to-evm-base. - const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; - let bridgedTokenForFallback: `0x${string}`; - if (toNetwork === Networks.Ethereum) { - const ethereumUsdc = evmTokenConfig.ethereum.USDC; - if (!ethereumUsdc) { - throw new Error("USDC config missing for Ethereum"); - } - bridgedTokenForFallback = ethereumUsdc.erc20AddressSourceChain as `0x${string}`; - } else { - bridgedTokenForFallback = destinationAxlUsdcDetails.erc20AddressSourceChain as `0x${string}`; - } - - const inputAmountRawFinalBridge = bridgeInputAmountRaw as string; - - const { approveData: finalApproveData, swapData: finalSwapData } = - await createOnrampSquidrouterTransactionsOnDestinationChain({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: bridgedTokenForFallback, - network: toNetwork as EvmNetworks, - rawAmount: inputAmountRawFinalBridge, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalApproveData) as EvmTransactionData - }); - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(finalSwapData) as EvmTransactionData - }); - destinationNonce++; - - const fundingAccount = getEvmFundingAccount(Networks.Base); - - // Bound approval to bridged amount + 5% slippage cushion (matches avenia-to-evm-base). - const backupApproveAmountRaw = new Big(inputAmountRawFinalBridge).mul("1.05").toFixed(0, 0); - - const backupApproveTransaction = await addDestinationChainApprovalTransaction({ - amountRaw: backupApproveAmountRaw, - destinationNetwork: toNetwork as EvmNetworks, - spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenForFallback - }); - - // Nonce 0 on purpose: ensures the approval can land even if other destination-chain txs are missed. - // When source chain == destination chain, the ephemeral has already consumed nonces 0..N-1, so we - // reuse destinationTransfer's nonce (the first destination-chain nonce) for the same effect. - const backupApproveNonce = destinationStartingNonce; - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: backupApproveNonce, - phase: "backupApprove", - signer: evmEphemeralEntry.address, - txData: backupApproveTransaction - }); - - stateMeta = { - ...stateMeta, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId - }; - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/validation.test.ts b/apps/api/src/api/services/transactions/validation.test.ts index 80998e934..cb892db89 100644 --- a/apps/api/src/api/services/transactions/validation.test.ts +++ b/apps/api/src/api/services/transactions/validation.test.ts @@ -867,6 +867,25 @@ describe("Presigned Transaction validation", () => { ); }); + it("rejects presignedTx submitted for user-authority AssetHub to Pendulum XCM", async () => { + const tx: PresignedTx = { + meta: {}, + network: Networks.AssetHub, + nonce: 0, + phase: "assethubToPendulum", + signer: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", + txData: MOCK_TX_DATA_SUBSTRATE_SIGNER_1 + }; + await expect( + validatePresignedTxs( + RampDirection.SELL, + [tx], + { EVM: "", Substrate: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" }, + [tx] + ) + ).rejects.toThrow("Phase assethubToPendulum is broadcast by the user wallet"); + }); + it("rejects presignedTx for squidRouterNoPermitApprove and squidRouterNoPermitSwap (user-wallet phases)", async () => { const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2 }; const approveTx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "squidRouterNoPermitApprove", signer: EVM_SIGNER, txData: "data" }; diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index 338d00468..41101bf50 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -231,6 +231,7 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "baseCleanupAxlUsdc": case "alfredOnrampMintFallback": case "alfredpayOfframpTransferFallback": + case "ethereumCleanupUsdc": return EphemeralAccountType.EVM; default: throw new APIError({ @@ -350,12 +351,22 @@ export async function validatePresignedTxs( // them — only the resulting on-chain tx hash via /v1/ramp/update additionalData. The receipt // is then verified against the unsigned blueprint by user-tx-verifier at phase execution time. // Accepting a presignedTx here would create a fake authority surface that bypasses that check. - const isUserWalletPhase = + // The signer is the source of truth: an unsigned entry whose signer is an ephemeral address + // is broadcast by the ephemeral (and requires a presignedTx); an entry signed by the user is + // broadcast by the user (and must NOT have a presignedTx). + const ephemeralSigners = new Set( + Object.values(ephemerals) + .filter((v): v is string => Boolean(v)) + .map(s => s.toLowerCase()) + ); + const isSquidBridgePhase = tx.phase === "squidRouterSwap" || tx.phase === "squidRouterApprove"; + const isAlwaysUserWalletPhase = + tx.phase === "assethubToPendulum" || tx.phase === "squidRouterNoPermitTransfer" || tx.phase === "squidRouterNoPermitApprove" || tx.phase === "squidRouterNoPermitSwap" || - (direction === RampDirection.SELL && (tx.phase === "squidRouterSwap" || tx.phase === "squidRouterApprove")); - if (isUserWalletPhase) { + (isSquidBridgePhase && direction === RampDirection.SELL && !ephemeralSigners.has(tx.signer.toLowerCase())); + if (isAlwaysUserWalletPhase) { throw new APIError({ message: `Phase ${tx.phase} is broadcast by the user wallet; do not submit a presigned transaction for it. Submit only the on-chain tx hash via additionalData.`, status: httpStatus.BAD_REQUEST 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 88c4fc5fb..69fb691f9 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 @@ -18,9 +18,12 @@ const originalDeactivateWebhook = webhookService.deactivateWebhook; const findWebhooksForEventMock = mock(async (): Promise => []); const deactivateWebhookMock = mock(async (): Promise => true); +// IP-literal URLs keep the tests hermetic: the pre-delivery SSRF guard validates +// IP literals without a DNS lookup. 93.184.216.34 (example.com, a real public +// address) passes the guard; fetch is always stubbed, so nothing leaves the box. const fakeWebhook = (overrides: Record = {}) => ({ id: "webhook-1", - url: "https://example.com/hook", + url: "https://93.184.216.34/hook", ...overrides }); @@ -76,8 +79,8 @@ describe("WebhookDeliveryService", () => { 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" }) + fakeWebhook({ id: "webhook-1", url: "https://93.184.216.34/hook1" }), + fakeWebhook({ id: "webhook-2", url: "https://93.184.216.34/hook2" }) ]); stubFetch(async () => new Response(null, { status: 200 })); @@ -85,11 +88,12 @@ describe("WebhookDeliveryService", () => { expect(findWebhooksForEventMock).toHaveBeenCalledWith(WebhookEventType.TRANSACTION_CREATED, "quote-123", "session-456"); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchCall(0).url).toBe("https://example.com/hook1"); - expect(fetchCall(1).url).toBe("https://example.com/hook2"); + expect(fetchCall(0).url).toBe("https://93.184.216.34/hook1"); + expect(fetchCall(1).url).toBe("https://93.184.216.34/hook2"); const payload = JSON.parse(fetchCall(0).body); expect(payload).toEqual({ + eventId: expect.any(String), eventType: WebhookEventType.TRANSACTION_CREATED, payload: { quoteId: "quote-123", @@ -150,6 +154,32 @@ describe("WebhookDeliveryService", () => { expect(deactivateWebhookMock).not.toHaveBeenCalled(); }); + it("keeps the eventId stable across delivery retries", async () => { + findWebhooksForEventMock.mockResolvedValue([fakeWebhook()]); + let attempts = 0; + stubFetch(async () => { + attempts++; + return new Response(null, { status: attempts < 3 ? 502 : 200 }); + }); + + await service.triggerTransactionCreated("quote-123", "session-456", "tx-789", RampDirection.BUY); + + const first = JSON.parse(fetchCall(0).body); + const last = JSON.parse(fetchCall(2).body); + expect(first.eventId).toEqual(expect.any(String)); + expect(last.eventId).toBe(first.eventId); + }); + + it("never fetches a webhook whose URL points at a private address, and deactivates it", async () => { + findWebhooksForEventMock.mockResolvedValue([fakeWebhook({ url: "https://10.0.0.5/hook" })]); + stubFetch(async () => new Response(null, { status: 200 })); + + await service.triggerTransactionCreated("quote-123", "session-456", "tx-789", RampDirection.BUY); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(deactivateWebhookMock).toHaveBeenCalledWith("webhook-1"); + }); + it("treats network errors like failures and deactivates after maxRetries without throwing", async () => { findWebhooksForEventMock.mockResolvedValue([fakeWebhook()]); stubFetch(async () => { @@ -214,6 +244,9 @@ describe("WebhookDeliveryService", () => { const { body, headers, init } = fetchCall(0); expect(init.method).toBe("POST"); + // Redirects must be rejected so a public host cannot bounce the delivery + // to a private address. + expect(init.redirect).toBe("error"); expect(headers["Content-Type"]).toBe("application/json"); expect(headers["User-Agent"]).toBe("Vortex-Webhooks/1.0"); @@ -221,12 +254,15 @@ describe("WebhookDeliveryService", () => { expect(headers["X-Vortex-Timestamp"]).toMatch(/^\d+$/); expect(Math.abs(Number(headers["X-Vortex-Timestamp"]) - Date.now() / 1000)).toBeLessThan(60); - // Raw base64 RSA-PSS signature over the exact body — no "sha256=" prefix - // (that belonged to the removed HMAC scheme) + // Raw base64 RSA-PSS signature over `${timestamp}.${body}` — binding the + // timestamp so a captured body+signature cannot be replayed with a fresh + // timestamp. 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); + expect(cryptoService.verifySignature(`${headers["X-Vortex-Timestamp"]}.${body}`, signature)).toBe(true); + // Neither the body alone nor a shifted timestamp verifies + expect(cryptoService.verifySignature(body, signature)).toBe(false); + expect(cryptoService.verifySignature(`${Number(headers["X-Vortex-Timestamp"]) + 1}.${body}`, signature)).toBe(false); }); }); }); diff --git a/apps/api/src/api/services/webhook/__tests__/webhook-url.test.ts b/apps/api/src/api/services/webhook/__tests__/webhook-url.test.ts new file mode 100644 index 000000000..713f38673 --- /dev/null +++ b/apps/api/src/api/services/webhook/__tests__/webhook-url.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import { getWebhookUrlViolation, isPublicIpAddress } from "../webhook-url"; + +describe("isPublicIpAddress", () => { + it.each([ + "8.8.8.8", + "93.184.216.34", + "1.1.1.1", + "2600:1f18::1" + ])("accepts public address %s", address => { + expect(isPublicIpAddress(address)).toBe(true); + }); + + it.each([ + "0.0.0.0", + "10.1.2.3", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "172.31.255.255", + "192.0.0.1", + "192.168.0.1", + "198.18.0.1", + "224.0.0.1", + "240.0.0.1", + "255.255.255.255", + "::", + "::1", + "fc00::1", + "fd12:3456::1", + "fe80::1", + "ff02::1", + "2001:db8::1", + "::ffff:10.0.0.1", + "::ffff:127.0.0.1", + // IANA special-purpose ranges that are not publicly routable: a webhook pointing + // at one either reaches internal infrastructure or can never deliver. + "192.0.2.10", // TEST-NET-1 + "198.51.100.10", // TEST-NET-2 + "203.0.113.10", // TEST-NET-3 + "192.88.99.1", // 6to4 relay anycast (deprecated) + "fec0::1", // site-local (deprecated, still routed in some networks) + "2002::1", // 6to4 + "2001:2::1", // benchmarking + "64:ff9b::1" // NAT64 well-known prefix + ])("rejects private/reserved address %s", address => { + expect(isPublicIpAddress(address)).toBe(false); + }); + + it("rejects non-IP strings", () => { + expect(isPublicIpAddress("localhost")).toBe(false); + expect(isPublicIpAddress("not-an-ip")).toBe(false); + }); +}); + +describe("getWebhookUrlViolation", () => { + it("accepts a plain HTTPS URL", () => { + expect(getWebhookUrlViolation("https://partner.example.com/hooks/vortex")).toBeNull(); + }); + + it("accepts an HTTPS URL with a custom port", () => { + expect(getWebhookUrlViolation("https://partner.example.com:8443/hooks")).toBeNull(); + }); + + it("rejects non-HTTPS schemes", () => { + expect(getWebhookUrlViolation("http://partner.example.com/hooks")).toContain("HTTPS"); + expect(getWebhookUrlViolation("ftp://partner.example.com/hooks")).toContain("HTTPS"); + }); + + it("rejects unparseable URLs", () => { + expect(getWebhookUrlViolation("not a url")).toContain("Invalid URL"); + }); + + it("rejects embedded credentials", () => { + expect(getWebhookUrlViolation("https://user:pw@example.com/hooks")).toContain("credentials"); + }); + + it("rejects private and reserved IP literals, including bracketed IPv6", () => { + expect(getWebhookUrlViolation("https://127.0.0.1/hooks")).toContain("private or reserved"); + expect(getWebhookUrlViolation("https://192.168.0.10/hooks")).toContain("private or reserved"); + expect(getWebhookUrlViolation("https://[::1]/hooks")).toContain("private or reserved"); + expect(getWebhookUrlViolation("https://[fd00::1]/hooks")).toContain("private or reserved"); + }); + + it("accepts public IP literals", () => { + expect(getWebhookUrlViolation("https://8.8.8.8/hooks")).toBeNull(); + }); +}); 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 ba9d62b48..8c15fc65d 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,9 +1,10 @@ import { describe, it, expect, afterAll, beforeEach } from 'bun:test'; import { mock } from 'bun:test'; +import { Op } from 'sequelize'; 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'; +import { WebhookService, WebhookOwner } 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. @@ -19,15 +20,20 @@ afterAll(() => { } }); import { APIError } from '../../../errors/api-error'; -import { WebhookEventType, RegisterWebhookRequest, RegisterWebhookResponse } from '@vortexfi/shared'; +import { WebhookEventType } from '@vortexfi/shared'; import Webhook, { WebhookAttributes } from '../../../../models/webhook.model'; +const PARTNER_OWNER: WebhookOwner = { partnerId: 'partner-1', userId: null }; +const USER_OWNER: WebhookOwner = { partnerId: null, userId: 'user-1' }; + // Mock factory functions const createMockWebhook = (overrides: Partial = {}) => ({ id: 'webhook-123', url: 'https://example.com/webhook', quoteId: 'quote-123', sessionId: null, + partnerId: 'partner-1', + userId: null, events: [WebhookEventType.TRANSACTION_CREATED, WebhookEventType.STATUS_CHANGE], isActive: true, createdAt: new Date('2025-01-15T10:30:00.000Z'), @@ -35,8 +41,10 @@ const createMockWebhook = (overrides: Partial = {}) => ({ ...overrides } as Webhook); -const createMockRampState = (overrides: Partial<{ id: string }> = {}) => ({ - id: 'tx-123', +const createMockQuote = (overrides: Partial<{ id: string; partnerId: string | null; userId: string | null }> = {}) => ({ + id: 'quote-123', + partnerId: 'partner-1', + userId: null, ...overrides }); @@ -49,6 +57,7 @@ const createMockWebhookArray = (webhooks: Partial[] = []) => // Create mock functions first const createMock = mock(async (): Promise => ({})); const findByPkMock = mock(async (): Promise => ({})); +const findOneMock = mock(async (): Promise => ({})); const findAllMock = mock(async (): Promise => ([])); const destroyMock = mock(async (): Promise => true); const updateMock = mock(async (): Promise => ({})); @@ -60,6 +69,7 @@ mock.module('../../../../models/webhook.model', () => ({ default: { create: createMock, findByPk: findByPkMock, + findOne: findOneMock, findAll: findAllMock } })); @@ -87,6 +97,7 @@ describe('WebhookService', () => { webhookService = new WebhookService(); createMock.mockReset(); findByPkMock.mockReset(); + findOneMock.mockReset(); findAllMock.mockReset(); destroyMock.mockReset(); updateMock.mockReset(); @@ -94,27 +105,29 @@ describe('WebhookService', () => { }); describe('registerWebhook', () => { - it('should register a webhook with quoteId', async () => { + it('should register a webhook with quoteId owned by the partner', async () => { const mockWebhook = createMockWebhook(); // Setup mocks - quoteTicketFindByPkMock.mockResolvedValue(createMockRampState()); // Quote exists + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote()); createMock.mockResolvedValue(mockWebhook); // Execute const result = await webhookService.registerWebhook({ url: 'https://example.com/webhook', quoteId: 'quote-123' - }); + }, PARTNER_OWNER); // Verify expect(quoteTicketFindByPkMock).toHaveBeenCalledWith('quote-123'); expect(createMock).toHaveBeenCalledWith({ events: [WebhookEventType.TRANSACTION_CREATED, WebhookEventType.STATUS_CHANGE], isActive: true, + partnerId: 'partner-1', sessionId: null, quoteId: 'quote-123', - url: 'https://example.com/webhook' + url: 'https://example.com/webhook', + userId: null }); expect(result).toEqual({ @@ -128,6 +141,68 @@ describe('WebhookService', () => { }); }); + it('should register a quote webhook for a user-scoped key when the quote belongs to the user', async () => { + const mockWebhook = createMockWebhook({ partnerId: null, userId: 'user-1' }); + + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote({ partnerId: null, userId: 'user-1' })); + createMock.mockResolvedValue(mockWebhook); + + await webhookService.registerWebhook({ + url: 'https://example.com/webhook', + quoteId: 'quote-123' + }, USER_OWNER); + + expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ + partnerId: null, + userId: 'user-1' + })); + }); + + it('should reject a quote webhook when the quote belongs to another partner', async () => { + // Quote exists but is owned by a different partner — must be indistinguishable + // from a nonexistent quote (uniform 404). + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote({ partnerId: 'partner-other' })); + + const error = await webhookService.registerWebhook({ + url: 'https://example.com/webhook', + quoteId: 'quote-123' + }, PARTNER_OWNER).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'); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('should reject a quote webhook when a user-scoped key does not own the quote', async () => { + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote({ partnerId: null, userId: 'user-other' })); + + const error = await webhookService.registerWebhook({ + url: 'https://example.com/webhook', + quoteId: 'quote-123' + }, USER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(404); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('should reject registration when the key has no principal', async () => { + const error = await webhookService.registerWebhook({ + url: 'https://example.com/webhook', + quoteId: 'quote-123' + }, { partnerId: null, userId: null }).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(403); + }); + it('should register a webhook with sessionId', async () => { const mockWebhook = createMockWebhook({ id: 'webhook-456', @@ -142,15 +217,17 @@ describe('WebhookService', () => { const result = await webhookService.registerWebhook({ url: 'https://example.com/webhook', sessionId: 'session-456' - }); + }, PARTNER_OWNER); // Verify expect(createMock).toHaveBeenCalledWith({ events: [WebhookEventType.TRANSACTION_CREATED, WebhookEventType.STATUS_CHANGE], isActive: true, + partnerId: 'partner-1', sessionId: 'session-456', quoteId: null, - url: 'https://example.com/webhook' + url: 'https://example.com/webhook', + userId: null }); expect(result).toEqual({ @@ -180,15 +257,17 @@ describe('WebhookService', () => { url: 'https://example.com/webhook', sessionId: 'session-789', events: [WebhookEventType.STATUS_CHANGE] - }); + }, PARTNER_OWNER); // Verify expect(createMock).toHaveBeenCalledWith({ events: [WebhookEventType.STATUS_CHANGE], isActive: true, + partnerId: 'partner-1', sessionId: 'session-789', quoteId: null, - url: 'https://example.com/webhook' + url: 'https://example.com/webhook', + userId: null }); expect(result.events).toEqual([WebhookEventType.STATUS_CHANGE]); @@ -197,14 +276,14 @@ describe('WebhookService', () => { it('should handle registration errors', async () => { // 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' }); + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote()); createMock.mockRejectedValue(new Error('Database error')); // Execute and verify const error = await webhookService.registerWebhook({ url: 'https://example.com/webhook', quoteId: 'quote-123' - }).then( + }, PARTNER_OWNER).then( () => { throw new Error('registerWebhook did not reject'); }, e => e ); @@ -218,14 +297,45 @@ describe('WebhookService', () => { await expect(webhookService.registerWebhook({ url: 'http://example.com/webhook', quoteId: 'quote-123' - })).rejects.toBeInstanceOf(APIError); + }, PARTNER_OWNER)).rejects.toBeInstanceOf(APIError); + }); + + it.each([ + 'https://127.0.0.1/webhook', + 'https://10.0.0.5/webhook', + 'https://192.168.1.1/webhook', + 'https://169.254.169.254/webhook', + 'https://[::1]/webhook' + ])('should reject private or reserved IP-literal URL %s', async (url) => { + const error = await webhookService.registerWebhook({ + url, + quoteId: 'quote-123' + }, PARTNER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('should reject URLs with embedded credentials', async () => { + const error = await webhookService.registerWebhook({ + url: 'https://user:secret@example.com/webhook', + quoteId: 'quote-123' + }, PARTNER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); }); it('should reject missing URL', async () => { // Execute and verify await expect(webhookService.registerWebhook({ quoteId: 'quote-123' - } as any)).rejects.toBeInstanceOf(APIError); + } as any, PARTNER_OWNER)).rejects.toBeInstanceOf(APIError); }); it('should reject invalid event types', async () => { @@ -234,7 +344,7 @@ describe('WebhookService', () => { url: 'https://example.com/webhook', quoteId: 'quote-123', events: ['INVALID_EVENT' as any] - })).rejects.toBeInstanceOf(APIError); + }, PARTNER_OWNER)).rejects.toBeInstanceOf(APIError); }); it('should reject empty events array', async () => { @@ -243,14 +353,14 @@ describe('WebhookService', () => { url: 'https://example.com/webhook', quoteId: 'quote-123', events: [] - })).rejects.toBeInstanceOf(APIError); + }, PARTNER_OWNER)).rejects.toBeInstanceOf(APIError); }); it('should reject when neither quoteId nor sessionId is provided', async () => { // Execute and verify await expect(webhookService.registerWebhook({ url: 'https://example.com/webhook' - })).rejects.toBeInstanceOf(APIError); + }, PARTNER_OWNER)).rejects.toBeInstanceOf(APIError); }); it('should reject when quoteId does not exist', async () => { @@ -261,7 +371,7 @@ describe('WebhookService', () => { const error = await webhookService.registerWebhook({ url: 'https://example.com/webhook', quoteId: 'non-existent-quote' - }).then( + }, PARTNER_OWNER).then( () => { throw new Error('registerWebhook did not reject'); }, e => e ); @@ -272,7 +382,7 @@ describe('WebhookService', () => { }); describe('deleteWebhook', () => { - it('should delete an existing webhook', async () => { + it('should delete an existing webhook owned by the caller', async () => { // Mock data const mockWebhook = { id: 'webhook-123', @@ -281,35 +391,50 @@ describe('WebhookService', () => { // Setup mocks destroyMock.mockResolvedValue(true); - findByPkMock.mockResolvedValue(mockWebhook); + findOneMock.mockResolvedValue(mockWebhook); // Execute - const result = await webhookService.deleteWebhook('webhook-123'); + const result = await webhookService.deleteWebhook('webhook-123', PARTNER_OWNER); - // Verify - expect(findByPkMock).toHaveBeenCalledWith('webhook-123'); + // Verify — the lookup itself is owner-scoped + expect(findOneMock).toHaveBeenCalledWith({ where: { id: 'webhook-123', partnerId: 'partner-1' } }); expect(destroyMock).toHaveBeenCalled(); expect(result).toBe(true); }); - it('should return false when webhook not found', async () => { + it('should scope deletion to the user for user-scoped keys', async () => { + findOneMock.mockResolvedValue(null); + + await webhookService.deleteWebhook('webhook-123', USER_OWNER); + + expect(findOneMock).toHaveBeenCalledWith({ where: { id: 'webhook-123', userId: 'user-1' } }); + }); + + it('should return false when webhook not found or owned by another principal', async () => { // Setup mocks - findByPkMock.mockResolvedValue(null); + findOneMock.mockResolvedValue(null); // Execute - const result = await webhookService.deleteWebhook('non-existent-id'); + const result = await webhookService.deleteWebhook('non-existent-id', PARTNER_OWNER); // Verify - expect(findByPkMock).toHaveBeenCalledWith('non-existent-id'); expect(result).toBe(false); + expect(destroyMock).not.toHaveBeenCalled(); + }); + + it('should return false without querying when the key has no principal', async () => { + const result = await webhookService.deleteWebhook('webhook-123', { partnerId: null, userId: null }); + + expect(result).toBe(false); + expect(findOneMock).not.toHaveBeenCalled(); }); it('should handle deletion errors', async () => { // Setup mocks - findByPkMock.mockRejectedValue(new Error('Database error')); + findOneMock.mockRejectedValue(new Error('Database error')); // Execute and verify - await expect(webhookService.deleteWebhook('webhook-123')) + await expect(webhookService.deleteWebhook('webhook-123', PARTNER_OWNER)) .rejects.toBeInstanceOf(APIError); }); }); @@ -323,6 +448,7 @@ describe('WebhookService', () => { ]); // Setup mocks + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote()); findAllMock.mockResolvedValue(mockWebhooks); // Execute @@ -341,6 +467,37 @@ describe('WebhookService', () => { expect(result).toEqual(mockWebhooks); }); + it('should scope matching webhooks strictly to the quote owner', async () => { + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote({ partnerId: 'partner-1', userId: 'user-1' })); + findAllMock.mockResolvedValue([]); + + await webhookService.findWebhooksForEvent(WebhookEventType.STATUS_CHANGE, 'quote-123', 'session-456'); + + const where = (findAllMock.mock.calls[0] as any)[0].where; + const [targetOr, ownerOr] = where[Op.and]; + expect(targetOr[Op.or]).toEqual([ + { quoteId: 'quote-123' }, + { sessionId: 'session-456' }, + { quoteId: null, sessionId: null } + ]); + // No ownerless clause: such a row would match every quote, which is exactly the + // cross-tenant hole a pre-ownership row could have been planted to exploit. + expect(ownerOr[Op.or]).toEqual([ + { partnerId: 'partner-1' }, + { userId: 'user-1' } + ]); + }); + + it('should deliver to nobody when the quote owner cannot be resolved', async () => { + quoteTicketFindByPkMock.mockResolvedValue(null); + findAllMock.mockResolvedValue([]); + + const result = await webhookService.findWebhooksForEvent(WebhookEventType.STATUS_CHANGE, 'quote-123'); + + expect(result).toEqual([]); + expect(findAllMock).not.toHaveBeenCalled(); + }); + it('should find webhooks for session and quote', async () => { // Use mock factory const mockWebhooks = createMockWebhookArray([ @@ -350,6 +507,7 @@ describe('WebhookService', () => { ]); // Setup mocks + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote()); findAllMock.mockResolvedValue(mockWebhooks); // Execute @@ -371,6 +529,7 @@ describe('WebhookService', () => { it('should handle errors gracefully', async () => { // Setup mocks + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote()); findAllMock.mockRejectedValue(new Error('Database error')); // Execute diff --git a/apps/api/src/api/services/webhook/webhook-delivery.service.ts b/apps/api/src/api/services/webhook/webhook-delivery.service.ts index c141f760c..b39cbc7f6 100644 --- a/apps/api/src/api/services/webhook/webhook-delivery.service.ts +++ b/apps/api/src/api/services/webhook/webhook-delivery.service.ts @@ -1,17 +1,21 @@ +import { randomUUID } from "node:crypto"; import { RampDirection, TransactionStatus, WebhookEventType, WebhookPayload } from "@vortexfi/shared"; import cryptoService from "../../../config/crypto"; import logger from "../../../config/logger"; import Webhook from "../../../models/webhook.model"; import { fetchWithTimeout } from "../../helpers/fetchWithTimeout"; import webhookService from "./webhook.service"; +import { assertResolvesToPublicAddress } from "./webhook-url"; export class WebhookDeliveryService { private readonly maxRetries = 5; private readonly timeoutMs = 30000; private readonly retryDelays = [1000, 2000, 4000, 8000, 16000]; - private generateSignature(payload: string): string { - return cryptoService.signPayload(payload); + // The signature covers the timestamp header, so a captured body+signature cannot be + // replayed later with a fresh timestamp. Consumers verify over `${timestamp}.${body}`. + private generateSignature(timestamp: number, payload: string): string { + return cryptoService.signPayload(`${timestamp}.${payload}`); } private mapPhaseToStatus(phase: string): TransactionStatus { @@ -22,9 +26,13 @@ export class WebhookDeliveryService { private async deliverWebhook(webhook: Webhook, payload: WebhookPayload, attempt = 1): Promise { try { + // Re-resolved on every attempt so a DNS record cannot be re-pointed at internal + // infrastructure after registration (SSRF guard). + await assertResolvesToPublicAddress(webhook.url); + const payloadString = JSON.stringify(payload); - const signature = this.generateSignature(payloadString); const timestamp = Math.floor(Date.now() / 1000); + const signature = this.generateSignature(timestamp, payloadString); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); @@ -34,13 +42,17 @@ export class WebhookDeliveryService { headers: { "Content-Type": "application/json", "User-Agent": "Vortex-Webhooks/1.0", + // Signature over `${timestamp}.${body}` (RSA-PSS, SHA-256). Recipients must + // verify against that exact string, check the timestamp is within a bounded + // window (e.g. 5 minutes), and deduplicate on the payload's eventId — it stays + // stable across delivery retries, so a duplicate eventId outside a retry + // window indicates a replay. "X-Vortex-Signature": signature, - // The timestamp allows webhook receivers to validate request freshness and prevent replay attacks. - // Recipients should verify the timestamp is within a reasonable window (e.g., 5 minutes) - // and reject requests with timestamps too old or too far in the future. "X-Vortex-Timestamp": timestamp.toString() }, method: "POST", + // A public host must not be able to bounce the request to a private one. + redirect: "error", signal: controller.signal }); @@ -93,6 +105,7 @@ export class WebhookDeliveryService { } const payload: WebhookPayload = { + eventId: randomUUID(), eventType: WebhookEventType.TRANSACTION_CREATED, payload: { quoteId, @@ -129,6 +142,7 @@ export class WebhookDeliveryService { } const payload: WebhookPayload = { + eventId: randomUUID(), eventType: WebhookEventType.STATUS_CHANGE, payload: { quoteId, diff --git a/apps/api/src/api/services/webhook/webhook-url.ts b/apps/api/src/api/services/webhook/webhook-url.ts new file mode 100644 index 000000000..4baa6d016 --- /dev/null +++ b/apps/api/src/api/services/webhook/webhook-url.ts @@ -0,0 +1,148 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * SSRF protection for webhook callback URLs. + * + * `assertAllowedWebhookUrl` runs at registration: HTTPS only, no embedded + * credentials, and no IP-literal host in a non-public range. + * `assertResolvesToPublicAddress` runs before every delivery: the hostname is + * resolved fresh and every returned address must be public, so a DNS record + * cannot be re-pointed at internal infrastructure after registration. A + * resolve-then-connect race remains (fetch resolves independently); redirects + * are disabled at the fetch call so a public host cannot bounce the request + * to a private one. + */ + +// IANA IPv4 Special-Purpose Address Registry. Anything listed there is not publicly +// routable, so a webhook pointing at one either reaches internal infrastructure or can +// never deliver; both are rejected. +function isPublicIPv4(address: string): boolean { + const octets = address.split(".").map(Number); + const [a, b, c] = octets; + if (a === 0 || a === 10 || a === 127) return false; // "this" network, private, loopback + if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT 100.64/10 + if (a === 169 && b === 254) return false; // link-local + if (a === 172 && b >= 16 && b <= 31) return false; // private 172.16/12 + if (a === 192 && b === 0 && c === 0) return false; // IETF protocol assignments 192.0.0.0/24 + if (a === 192 && b === 0 && c === 2) return false; // TEST-NET-1 192.0.2.0/24 + if (a === 192 && b === 88 && c === 99) return false; // 6to4 relay anycast (deprecated) + if (a === 192 && b === 168) return false; // private + if (a === 198 && (b === 18 || b === 19)) return false; // benchmarking 198.18/15 + if (a === 198 && b === 51 && c === 100) return false; // TEST-NET-2 198.51.100.0/24 + if (a === 203 && b === 0 && c === 113) return false; // TEST-NET-3 203.0.113.0/24 + if (a >= 224) return false; // multicast + reserved 224/4, 240/4, broadcast + return true; +} + +// IANA IPv6 Special-Purpose Address Registry equivalent of the IPv4 check above. +function isPublicIPv6(address: string): boolean { + const lower = address.toLowerCase(); + // IPv4-mapped/translated (::ffff:a.b.c.d) — judge the embedded IPv4. + const v4Match = lower.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (v4Match) return isPublicIPv4(v4Match[1]); + if (lower === "::" || lower === "::1") return false; // unspecified, loopback + const firstHextet = Number.parseInt(lower.split(":").find(part => part !== "") ?? "0", 16); + if ((firstHextet & 0xfe00) === 0xfc00) return false; // unique-local fc00::/7 + if ((firstHextet & 0xffc0) === 0xfe80) return false; // link-local fe80::/10 + if ((firstHextet & 0xffc0) === 0xfec0) return false; // site-local fec0::/10 (deprecated, still routed in some networks) + if ((firstHextet & 0xff00) === 0xff00) return false; // multicast ff00::/8 + if (firstHextet === 0x0100 && lower.startsWith("100:")) return false; // discard-only 100::/64 + if (firstHextet === 0x2001) { + if (lower.startsWith("2001:db8")) return false; // documentation 2001:db8::/32 + if (lower.startsWith("2001:2:")) return false; // benchmarking 2001:2::/48 + if (lower.startsWith("2001:10:") || lower.startsWith("2001:20:")) return false; // ORCHID/ORCHIDv2 + if (lower.startsWith("2001:0:") || lower === "2001::") return false; // Teredo 2001::/32 + } + if (firstHextet === 0x2002) return false; // 6to4 2002::/16 (deprecated) + if (firstHextet === 0x3fff) return false; // documentation 3fff::/20 + if ((firstHextet & 0xfffe) === 0x0064 && lower.startsWith("64:ff9b")) return false; // NAT64 well-known prefix + return true; +} + +export function isPublicIpAddress(address: string): boolean { + const version = isIP(address); + if (version === 4) return isPublicIPv4(address); + if (version === 6) return isPublicIPv6(address); + return false; +} + +/** Syntactic checks at registration time. Returns an error message, or null if allowed. */ +export function getWebhookUrlViolation(rawUrl: string): string | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return "Invalid URL format"; + } + if (url.protocol !== "https:") { + return "Webhook URL must use HTTPS"; + } + if (url.username || url.password) { + return "Webhook URL must not contain credentials"; + } + // Bracketed IPv6 literals arrive as "[::1]" — strip for isIP. + const host = url.hostname.replace(/^\[|\]$/g, ""); + if (isIP(host) !== 0 && !isPublicIpAddress(host)) { + return "Webhook URL must not point to a private or reserved address"; + } + return null; +} + +export type HostResolution = + | { kind: "public" } + | { kind: "non-public"; hostname: string; address: string } + | { kind: "unresolved"; hostname: string; reason: string }; + +/** + * Resolve the URL's hostname and classify it. IP literals are judged directly without a + * DNS lookup. The three outcomes are deliberately distinct because registration and + * delivery treat "did not resolve" differently — see the two callers below. + */ +export async function resolveHostPolicy(rawUrl: string): Promise { + const hostname = new URL(rawUrl).hostname.replace(/^\[|\]$/g, ""); + let addresses: { address: string }[]; + try { + addresses = isIP(hostname) !== 0 ? [{ address: hostname }] : await lookup(hostname, { all: true, verbatim: true }); + } catch (error) { + return { hostname, kind: "unresolved", reason: error instanceof Error ? error.message : String(error) }; + } + if (addresses.length === 0) { + return { hostname, kind: "unresolved", reason: "no addresses returned" }; + } + for (const { address } of addresses) { + if (!isPublicIpAddress(address)) { + return { address, hostname, kind: "non-public" }; + } + } + return { kind: "public" }; +} + +/** + * Registration-time check. Rejects only a host that actually resolves somewhere we refuse + * to talk to. A host that does not resolve yet is allowed — DNS is often provisioned after + * the integration is set up, and every delivery re-resolves and re-validates regardless. + * Returns a violation message, or null if allowed. + */ +export async function getResolvedUrlViolation(rawUrl: string): Promise { + const resolution = await resolveHostPolicy(rawUrl); + if (resolution.kind === "non-public") { + return `Webhook host ${resolution.hostname} resolves to non-public address ${resolution.address}`; + } + return null; +} + +/** + * Delivery-time check: fails closed on both non-public and unresolvable hosts, since + * neither can produce a legitimate delivery. Called before every attempt so a DNS record + * re-pointed after registration is still caught. + */ +export async function assertResolvesToPublicAddress(rawUrl: string): Promise { + const resolution = await resolveHostPolicy(rawUrl); + if (resolution.kind === "non-public") { + throw new Error(`Webhook host ${resolution.hostname} resolves to non-public address ${resolution.address}`); + } + if (resolution.kind === "unresolved") { + throw new Error(`Webhook host ${resolution.hostname} did not resolve: ${resolution.reason}`); + } +} diff --git a/apps/api/src/api/services/webhook/webhook.service.ts b/apps/api/src/api/services/webhook/webhook.service.ts index 174792acb..12512931f 100644 --- a/apps/api/src/api/services/webhook/webhook.service.ts +++ b/apps/api/src/api/services/webhook/webhook.service.ts @@ -5,12 +5,29 @@ import logger from "../../../config/logger"; import QuoteTicket from "../../../models/quoteTicket.model"; import Webhook from "../../../models/webhook.model"; import { APIError } from "../../errors/api-error"; +import { getResolvedUrlViolation, getWebhookUrlViolation } from "./webhook-url"; + +/** + * Principal that owns a webhook: the partner behind a partner-scoped secret key, + * or the user behind a user-scoped secret key. Exactly one side is set. + */ +export interface WebhookOwner { + partnerId: string | null; + userId: string | null; +} export class WebhookService { - public async registerWebhook(request: RegisterWebhookRequest): Promise { + public async registerWebhook(request: RegisterWebhookRequest, owner: WebhookOwner): Promise { try { const { url, quoteId, sessionId, events } = request; + if (!owner.partnerId && !owner.userId) { + throw new APIError({ + message: "API key is not linked to a partner or user", + status: httpStatus.FORBIDDEN + }); + } + // Validate URL format if (!url) { throw new APIError({ @@ -19,9 +36,22 @@ export class WebhookService { }); } - if (!url.startsWith("https://")) { + const urlViolation = getWebhookUrlViolation(url); + if (urlViolation) { + throw new APIError({ + message: urlViolation, + status: httpStatus.BAD_REQUEST + }); + } + + // Resolve at registration so a hostname pointing at internal infrastructure fails + // fast with a clear error instead of being stored and only rejected at delivery. + // A host that does not resolve yet is allowed; delivery re-resolves regardless, + // since DNS can change (or be re-pointed) after registration. + const resolvedViolation = await getResolvedUrlViolation(url); + if (resolvedViolation) { throw new APIError({ - message: "Webhook URL must use HTTPS", + message: resolvedViolation, status: httpStatus.BAD_REQUEST }); } @@ -54,10 +84,14 @@ export class WebhookService { }); } - // Validate that quoteId exists in the database if provided + // The quote must exist AND belong to the registering principal. A foreign quote + // returns the same 404 as a nonexistent one so quote IDs cannot be probed. if (quoteId) { const existingQuote = await QuoteTicket.findByPk(quoteId); - if (!existingQuote) { + const ownsQuote = + existingQuote && + (owner.partnerId ? existingQuote.partnerId === owner.partnerId : existingQuote.userId === owner.userId); + if (!ownsQuote) { throw new APIError({ message: `Quote with ID ${quoteId} not found`, status: httpStatus.NOT_FOUND @@ -70,9 +104,11 @@ export class WebhookService { const webhook = await Webhook.create({ events: webhookEvents, isActive: true, + partnerId: owner.partnerId, quoteId: quoteId || null, sessionId: sessionId || null, - url + url, + userId: owner.partnerId ? null : owner.userId }); logger.info(`Webhook registered: ${webhook.id} for URL: ${url}`); @@ -101,9 +137,16 @@ export class WebhookService { } } - public async deleteWebhook(id: string): Promise { + public async deleteWebhook(id: string, owner: WebhookOwner): Promise { try { - const webhook = await Webhook.findByPk(id); + if (!owner.partnerId && !owner.userId) { + return false; + } + + // Owner-scoped: a webhook belonging to another principal behaves exactly like a + // nonexistent one (uniform 404 upstream). + const ownerCondition: WhereOptions = owner.partnerId ? { partnerId: owner.partnerId } : { userId: owner.userId }; + const webhook = await Webhook.findOne({ where: { id, ...ownerCondition } }); if (!webhook) { return false; @@ -122,7 +165,15 @@ export class WebhookService { } /** - * Find webhooks that should receive a specific event + * Find webhooks that should receive a specific event. + * + * Target matching (quote/session/global) is combined with an owner filter: a webhook + * only receives the event if its owner principal owns the quote the event belongs to. + * This keeps session IDs (free-form strings) from leaking events across tenants. + * + * Every row has an owner (enforced by a CHECK constraint), so there is deliberately no + * ownerless escape hatch here — one would match every quote and reopen the cross-tenant + * hole for exactly the rows an attacker could have planted before ownership existed. */ public async findWebhooksForEvent( eventType: WebhookEventType, @@ -130,37 +181,46 @@ export class WebhookService { sessionId?: string | null ): Promise { try { - const whereConditions: WhereOptions = { - events: { - [Op.contains]: [eventType] - }, - isActive: true - }; - - const orConditions: WhereOptions[] = []; + const targetConditions: WhereOptions[] = []; // Match webhooks subscribed to this specific quote if (quoteId) { - orConditions.push({ quoteId }); + targetConditions.push({ quoteId }); } // Match webhooks subscribed to this specific session if (sessionId) { - orConditions.push({ sessionId }); + targetConditions.push({ sessionId }); } // Match webhooks with no specific quote or session (global webhooks) - orConditions.push({ + targetConditions.push({ quoteId: null, sessionId: null }); - if (orConditions.length > 0) { - whereConditions[Op.or as unknown as string] = orConditions; + const quote = quoteId ? await QuoteTicket.findByPk(quoteId, { attributes: ["partnerId", "userId"] }) : null; + const ownerConditions: WhereOptions[] = []; + if (quote?.partnerId) { + ownerConditions.push({ partnerId: quote.partnerId }); + } + if (quote?.userId) { + ownerConditions.push({ userId: quote.userId }); + } + + // No resolvable quote owner means no webhook may claim the event. + if (ownerConditions.length === 0) { + return []; } const webhooks = await Webhook.findAll({ - where: whereConditions + where: { + [Op.and]: [{ [Op.or]: targetConditions }, { [Op.or]: ownerConditions }], + events: { + [Op.contains]: [eventType] + }, + isActive: true + } }); return webhooks; diff --git a/apps/api/src/config/database.ts b/apps/api/src/config/database.ts index a3e94aee4..c633cc8c3 100644 --- a/apps/api/src/config/database.ts +++ b/apps/api/src/config/database.ts @@ -19,12 +19,13 @@ declare module "./vars" { } function getDialectOptions() { - if (config.env !== "production") { + const caCertPath = process.env.DB_SSL_CA_CERT_PATH?.trim(); + const sslRequired = config.env === "production" || process.env.DB_SSL_REQUIRED === "true" || Boolean(caCertPath); + + if (!sslRequired) { return undefined; } - const caCertPath = process.env.DB_SSL_CA_CERT_PATH; - return { ssl: { ...(caCertPath ? { ca: readFileSync(caCertPath, "utf8") } : {}), diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index 9957bf1f1..38f96b188 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -32,7 +32,7 @@ const dashboardPreviewOriginRegex = buildDashboardPreviewOriginRegex(process.env // enable CORS - Cross Origin Resource Sharing app.use( cors({ - allowedHeaders: ["Content-Type", "Authorization", "X-API-Key", "X-Request-ID", "X-Correlation-ID"], + allowedHeaders: ["Content-Type", "Authorization", "X-API-Key", "X-Public-Key", "X-Request-ID", "X-Correlation-ID"], credentials: true, exposedHeaders: ["X-Request-ID"], maxAge: 86400, // Cache preflight requests for 24 hours diff --git a/apps/api/src/config/logger.test.ts b/apps/api/src/config/logger.test.ts new file mode 100644 index 000000000..87a15a97d --- /dev/null +++ b/apps/api/src/config/logger.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { formatLogEntry } from "./logger"; + +describe("formatLogEntry", () => { + it("includes structured metadata in the rendered log line", () => { + const line = formatLogEntry({ + destinationNetwork: "arbitrum", + expectedAmountRaw: "772703", + level: "info", + message: "SQUIDROUTER_DELIVERY_EVIDENCE", + rampId: "ramp-123", + timestamp: "Jul 30, 2026 12:00:00" + }); + + expect(line).toContain("SQUIDROUTER_DELIVERY_EVIDENCE"); + expect(line).toContain( + '{"destinationNetwork":"arbitrum","expectedAmountRaw":"772703","rampId":"ramp-123"}' + ); + }); + + it("keeps metadata serialization safe for bigint and circular values", () => { + const circular: Record = { amount: 772703n }; + circular.self = circular; + + const line = formatLogEntry({ + evidence: circular, + level: "info", + message: "EVIDENCE" + }); + + expect(line).toContain('"amount":"772703"'); + expect(line).toContain('"self":"[Circular]"'); + }); + + it("does not add a metadata suffix when no metadata was provided", () => { + expect(formatLogEntry({ level: "info", message: "Application started" })).toBe(" info Application started"); + }); +}); diff --git a/apps/api/src/config/logger.ts b/apps/api/src/config/logger.ts index b4bce39ac..c267b1868 100644 --- a/apps/api/src/config/logger.ts +++ b/apps/api/src/config/logger.ts @@ -2,12 +2,53 @@ import { StreamOptions } from "morgan"; import winston, { format } from "winston"; import { getRampId } from "./ramp-context"; -const customFormat = winston.format.printf(({ timestamp, level, message, label = "" }) => { +interface LogEntry { + label?: unknown; + level: unknown; + message: unknown; + timestamp?: unknown; + [key: string]: unknown; +} + +const RESERVED_LOG_FIELDS = new Set(["label", "level", "message", "timestamp"]); + +function stringifyMetadata(metadata: Record): string { + const seen = new WeakSet(); + + try { + return JSON.stringify(metadata, (_key, value: unknown) => { + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "object" && value !== null) { + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + } + return value; + }); + } catch (error) { + return JSON.stringify({ + metadataSerializationError: error instanceof Error ? error.message : String(error) + }); + } +} + +export function formatLogEntry(info: LogEntry): string { + const { timestamp, level, message, label = "" } = info; const rampId = getRampId(); const rampPrefix = rampId ? `[${rampId}] ` : ""; const timestampPrefix = timestamp ? `[${timestamp}]` : ""; - return `${timestampPrefix} ${level}${label ? ` ${label}` : ""} ${rampPrefix}${message}`; -}); + const metadata = Object.fromEntries( + Object.entries(info).filter(([key, value]) => !RESERVED_LOG_FIELDS.has(key) && value !== undefined) + ); + const metadataSuffix = Object.keys(metadata).length > 0 ? ` ${stringifyMetadata(metadata)}` : ""; + + return `${timestampPrefix} ${String(level)}${label ? ` ${String(label)}` : ""} ${rampPrefix}${String(message)}${metadataSuffix}`; +} + +const customFormat = winston.format.printf(formatLogEntry); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || "info", diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 6c5fd710f..2549967ec 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -108,4 +108,36 @@ describe("vars deployment environment validation", () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain("MONERIUM_CLIENT_ID"); }); + + it("accepts a lower recipient-invite discount ceiling", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + NODE_ENV: "production", + RECIPIENT_INVITE_MAX_DISCOUNT_BPS: "125" + }); + + expect(result).toEqual({ exitCode: 0, stderr: "", stdout: "ok\n" }); + }); + + it("rejects a recipient-invite discount ceiling above the hard cap", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + NODE_ENV: "production", + RECIPIENT_INVITE_MAX_DISCOUNT_BPS: "301" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("RECIPIENT_INVITE_MAX_DISCOUNT_BPS must be an integer between 0 and 300"); + }); + + it("rejects a non-integer recipient-invite discount ceiling", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + NODE_ENV: "production", + RECIPIENT_INVITE_MAX_DISCOUNT_BPS: "2.5" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("RECIPIENT_INVITE_MAX_DISCOUNT_BPS must be an integer between 0 and 300"); + }); }); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 5b3886069..610fba5be 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -104,6 +104,18 @@ function readFractionEnv(name: string, defaultValue: string): number { return value; } +export const RECIPIENT_INVITE_DISCOUNT_HARD_CAP_BPS = 300; + +function readRecipientInviteDiscountLimit(): number { + const name = "RECIPIENT_INVITE_MAX_DISCOUNT_BPS"; + const rawValue = process.env[name] ?? String(RECIPIENT_INVITE_DISCOUNT_HARD_CAP_BPS); + const value = Number(rawValue.trim()); + if (!Number.isInteger(value) || value < 0 || value > RECIPIENT_INVITE_DISCOUNT_HARD_CAP_BPS || rawValue.trim() === "") { + throw new Error(`${name} must be an integer between 0 and ${RECIPIENT_INVITE_DISCOUNT_HARD_CAP_BPS}`); + } + return value; +} + interface Config { env: string; deploymentEnv: DeploymentEnv; @@ -159,6 +171,9 @@ interface Config { discountStateTimeoutMinutes: number; deltaDBasisPoints: number; }; + recipients: { + inviteMaxDiscountBps: number; + }; mykobo: { feeFallback: MykoboFeeFallback; }; @@ -273,6 +288,9 @@ export const config: Config = { rateLimitMaxRequests: process.env.RATE_LIMIT_MAX_REQUESTS || 100, rateLimitNumberOfProxies: process.env.RATE_LIMIT_NUMBER_OF_PROXIES || 1, rateLimitWindowMinutes: process.env.RATE_LIMIT_WINDOW_MINUTES || 1, + recipients: { + inviteMaxDiscountBps: readRecipientInviteDiscountLimit() + }, sandboxEnabled: process.env.SANDBOX_ENABLED === "true", diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index f53e8f380..2a609a8ce 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -2,7 +2,6 @@ const PENDULUM_FUNDING_AMOUNT_UNITS = "10"; // 10 PEN. Minimum balance of funding account const PENDULUM_GLMR_FUNDING_AMOUNT_UNITS = "10"; // 10 GLMR. Minimum balance of funding account -const STELLAR_FUNDING_AMOUNT_UNITS = "10"; // 10 XLM. Minimum balance of funding account const MOONBEAM_FUNDING_AMOUNT_UNITS = "10"; // 10 GLMR. Minimum balance of funding account const SUBSIDY_MINIMUM_RATIO_FUND_UNITS = "5"; // 5 Subsidies considering maximum subsidy amount use on each (worst case scenario) const MOONBEAM_RECEIVER_CONTRACT_ADDRESS = "0x2AB52086e8edaB28193172209407FF9df1103CDc"; @@ -18,9 +17,8 @@ const MAX_FINAL_SETTLEMENT_SUBSIDY_USD = "10"; // 10 USD const WEBHOOKS_CACHE_URL = "https://webhooks-cache.pendulumchain.tech"; // EXAMPLE URL -const STELLAR_BASE_FEE = "1000000"; - const DEFAULT_LOGIN_EXPIRATION_TIME_HOURS = 7 * 24; +const RAMP_START_EXPIRATION_TIME_SECONDS = 15 * 60; const FIRST_TX_TIME_WINDOW_IN_SECONDS = 5 * 60; // 5 minutes const SECOND_TX_TIME_WINDOW_IN_SECONDS = 24 * 60 * 60; // 24 hours @@ -50,9 +48,8 @@ export { PENDULUM_FUNDING_AMOUNT_UNITS, PENDULUM_GLMR_FUNDING_AMOUNT_UNITS, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, + RAMP_START_EXPIRATION_TIME_SECONDS, SEQUENCE_TIME_WINDOWS, - STELLAR_BASE_FEE, - STELLAR_FUNDING_AMOUNT_UNITS, SUBSIDY_MINIMUM_RATIO_FUND_UNITS, WEBHOOKS_CACHE_URL }; diff --git a/apps/api/src/contracts/TokenRelayer.ts b/apps/api/src/contracts/TokenRelayer.ts index 412d844a0..5f579b921 100644 --- a/apps/api/src/contracts/TokenRelayer.ts +++ b/apps/api/src/contracts/TokenRelayer.ts @@ -1,4 +1,22 @@ export const tokenRelayerAbi = [ + { + inputs: [ + { name: "token", type: "address" }, + { name: "requested", type: "uint256" }, + { name: "received", type: "uint256" } + ], + name: "TokenReceiptMismatch", + type: "error" + }, + { + inputs: [ + { name: "token", type: "address" }, + { name: "balanceBefore", type: "uint256" }, + { name: "balanceAfter", type: "uint256" } + ], + name: "TokenBalanceNotRestored", + type: "error" + }, { inputs: [ { @@ -23,7 +41,7 @@ export const tokenRelayerAbi = [ } ], name: "execute", - outputs: [{ name: "", type: "bool" }], + outputs: [], stateMutability: "payable", type: "function" } diff --git a/apps/api/src/database/migrations/038-create-customer-entities.ts b/apps/api/src/database/migrations/038-create-customer-entities.ts index fd0cdd751..dd5f91a7f 100644 --- a/apps/api/src/database/migrations/038-create-customer-entities.ts +++ b/apps/api/src/database/migrations/038-create-customer-entities.ts @@ -1,7 +1,7 @@ import { DataTypes, QueryInterface } from "sequelize"; // Creates customer_entities (the legal/compliance customer anchor between profiles and -// provider/KYC tables — see docs/architecture/unified-user-management-schema.md) and +// provider/KYC tables — see docs/architecture-identity-model.md) and // backfills one 'individual' entity per existing profile. export async function up(queryInterface: QueryInterface): Promise { await queryInterface.createTable("customer_entities", { diff --git a/apps/api/src/database/migrations/042-create-recipient-tables.ts b/apps/api/src/database/migrations/042-create-recipient-tables.ts index dd5fd256b..3e3804503 100644 --- a/apps/api/src/database/migrations/042-create-recipient-tables.ts +++ b/apps/api/src/database/migrations/042-create-recipient-tables.ts @@ -1,6 +1,6 @@ import { DataTypes, QueryInterface } from "sequelize"; -// Recipient product tables (docs/architecture/recipient-transfers-schema.md, refined by the +// Recipient product tables (docs/architecture-identity-model.md, refined by the // plan's D1/D3): recipient_invitations are LINK-based — token_hash is the redemption key and // invitee_email is optional metadata; recipient_payout_references are thin pointers to // provider-side instruments (no payout PII stored locally). All net-new, atomic revert. diff --git a/apps/api/src/database/migrations/055-create-api-credentials.ts b/apps/api/src/database/migrations/055-create-api-credentials.ts new file mode 100644 index 000000000..2bbe3f128 --- /dev/null +++ b/apps/api/src/database/migrations/055-create-api-credentials.ts @@ -0,0 +1,106 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("api_credentials", { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + environment: { + allowNull: false, + type: DataTypes.ENUM("live", "test") + }, + expiresAt: { + allowNull: false, + field: "expires_at", + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + name: { + allowNull: false, + type: DataTypes.STRING(100) + }, + partnerId: { + allowNull: true, + field: "partner_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { key: "id", model: "partners" }, + type: DataTypes.UUID + }, + profileId: { + allowNull: false, + field: "profile_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + publicKeyValue: { + allowNull: false, + field: "public_key_value", + type: DataTypes.STRING(255) + }, + publicLastUsedAt: { + allowNull: true, + field: "public_last_used_at", + type: DataTypes.DATE + }, + revokedAt: { + allowNull: true, + field: "revoked_at", + type: DataTypes.DATE + }, + secretKeyDigest: { + allowNull: false, + field: "secret_key_digest", + type: DataTypes.STRING(64) + }, + secretKeyPrefix: { + allowNull: false, + field: "secret_key_prefix", + type: DataTypes.STRING(16) + }, + secretLastUsedAt: { + allowNull: true, + field: "secret_last_used_at", + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }); + + await queryInterface.addIndex("api_credentials", ["profile_id"], { name: "idx_api_credentials_profile_id" }); + await queryInterface.addIndex("api_credentials", ["partner_id"], { name: "idx_api_credentials_partner_id" }); + await queryInterface.addIndex("api_credentials", ["secret_key_prefix"], { + name: "idx_api_credentials_secret_key_prefix" + }); + await queryInterface.addIndex("api_credentials", ["public_key_value"], { + name: "uq_api_credentials_public_key_value", + unique: true + }); + await queryInterface.addIndex("api_credentials", ["secret_key_digest"], { + name: "uq_api_credentials_secret_key_digest", + unique: true + }); + await queryInterface.sequelize.query(` + ALTER TABLE api_credentials + ADD CONSTRAINT chk_api_credentials_secret_prefix_length CHECK (char_length(secret_key_prefix) = 16), + ADD CONSTRAINT chk_api_credentials_secret_digest CHECK (secret_key_digest ~ '^[0-9a-f]{64}$') + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("api_credentials"); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_api_credentials_environment";'); +} diff --git a/apps/api/src/database/migrations/055-create-financial-operations.ts b/apps/api/src/database/migrations/055-create-financial-operations.ts new file mode 100644 index 000000000..66fc7a9cc --- /dev/null +++ b/apps/api/src/database/migrations/055-create-financial-operations.ts @@ -0,0 +1,44 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("financial_operations", { + attempt_class: { allowNull: false, type: DataTypes.STRING(64) }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + error_message: { allowNull: true, type: DataTypes.STRING(500) }, + external_id: { allowNull: true, type: DataTypes.STRING(255) }, + flow_id: { allowNull: false, type: DataTypes.STRING(128) }, + flow_version: { allowNull: false, type: DataTypes.INTEGER }, + id: { allowNull: false, defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + operation_key: { allowNull: false, type: DataTypes.STRING(64), unique: true }, + phase: { allowNull: false, type: DataTypes.STRING(64) }, + provider: { allowNull: false, type: DataTypes.STRING(64) }, + request_hash: { allowNull: false, type: DataTypes.STRING(64) }, + response: { allowNull: true, type: DataTypes.JSONB }, + scope_id: { allowNull: false, type: DataTypes.STRING(128) }, + scope_type: { allowNull: false, type: DataTypes.STRING(16) }, + status: { allowNull: false, type: DataTypes.STRING(16) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + await queryInterface.addIndex("financial_operations", ["scope_type", "scope_id"], { + name: "idx_financial_operations_scope" + }); + await queryInterface.addIndex("financial_operations", ["status", "updated_at"], { + name: "idx_financial_operations_status_updated" + }); + await queryInterface.addConstraint("financial_operations", { + fields: ["scope_type"], + name: "financial_operations_scope_type_check", + type: "check", + where: { scope_type: ["quote", "ramp"] } + }); + await queryInterface.addConstraint("financial_operations", { + fields: ["status"], + name: "financial_operations_status_check", + type: "check", + where: { status: ["not_started", "submitted", "confirmed", "failed", "unknown"] } + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("financial_operations"); +} diff --git a/apps/api/src/database/migrations/056-webhook-ownership.ts b/apps/api/src/database/migrations/056-webhook-ownership.ts new file mode 100644 index 000000000..8a6540cdf --- /dev/null +++ b/apps/api/src/database/migrations/056-webhook-ownership.ts @@ -0,0 +1,48 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Webhooks previously had no owner: any authenticated API key could subscribe to any +// quote's events and delete any webhook by UUID (security spec SPEC-001). Each webhook +// now records the principal that registered it — the partner behind a partner-scoped +// secret key, or the user behind a user-scoped secret key. +// +// Rows created before this migration have no recoverable owner. Rather than grandfather +// them (which would keep the cross-tenant hole open for exactly the rows an attacker +// could have planted pre-deployment), they are deleted: there are no production webhook +// registrations, so this is a no-op there and only clears dev/staging leftovers. +// The CHECK constraint then makes an ownerless row unrepresentable going forward, so the +// delivery matcher never has to special-case one. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("webhooks", "partner_id", { + allowNull: true, + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }); + + await queryInterface.addColumn("webhooks", "user_id", { + allowNull: true, + type: DataTypes.UUID + }); + + // Every pre-existing row is ownerless by construction (the columns did not exist). + await queryInterface.sequelize.query("DELETE FROM webhooks;"); + + await queryInterface.sequelize.query(` + ALTER TABLE webhooks + ADD CONSTRAINT chk_webhooks_exactly_one_owner + CHECK (num_nonnulls(partner_id, user_id) = 1); + `); + + await queryInterface.addIndex("webhooks", ["partner_id"], { name: "idx_webhooks_partner_id" }); + await queryInterface.addIndex("webhooks", ["user_id"], { name: "idx_webhooks_user_id" }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeIndex("webhooks", "idx_webhooks_user_id"); + await queryInterface.removeIndex("webhooks", "idx_webhooks_partner_id"); + await queryInterface.sequelize.query("ALTER TABLE webhooks DROP CONSTRAINT IF EXISTS chk_webhooks_exactly_one_owner;"); + await queryInterface.removeColumn("webhooks", "user_id"); + await queryInterface.removeColumn("webhooks", "partner_id"); +} diff --git a/apps/api/src/database/migrations/057-create-partner-managed-profiles.ts b/apps/api/src/database/migrations/057-create-partner-managed-profiles.ts new file mode 100644 index 000000000..c1af40cd7 --- /dev/null +++ b/apps/api/src/database/migrations/057-create-partner-managed-profiles.ts @@ -0,0 +1,68 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("partner_managed_profiles", { + claimedAt: { + allowNull: true, + field: "claimed_at", + type: DataTypes.DATE + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + externalUserId: { + allowNull: false, + field: "external_user_id", + type: DataTypes.STRING(255) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + partnerId: { + allowNull: false, + field: "partner_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { key: "id", model: "partners" }, + type: DataTypes.UUID + }, + profileId: { + allowNull: false, + field: "profile_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + subjectType: { + allowNull: false, + field: "subject_type", + type: DataTypes.ENUM("individual", "business", "technical") + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }); + + await queryInterface.addIndex("partner_managed_profiles", ["partner_id", "external_user_id"], { + name: "uq_partner_managed_profiles_partner_external_user", + unique: true + }); + await queryInterface.addIndex("partner_managed_profiles", ["profile_id"], { + name: "uq_partner_managed_profiles_profile_id", + unique: true + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("partner_managed_profiles"); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_partner_managed_profiles_subject_type";'); +} diff --git a/apps/api/src/database/migrations/058-add-api-credential-id-to-quote-tickets.ts b/apps/api/src/database/migrations/058-add-api-credential-id-to-quote-tickets.ts new file mode 100644 index 000000000..e0c6e2fc9 --- /dev/null +++ b/apps/api/src/database/migrations/058-add-api-credential-id-to-quote-tickets.ts @@ -0,0 +1,23 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("quote_tickets", "api_credential_id", { + allowNull: true, + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "api_credentials" + }, + type: DataTypes.UUID + }); + + await queryInterface.addIndex("quote_tickets", ["api_credential_id"], { + name: "idx_quote_tickets_api_credential_id" + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeIndex("quote_tickets", "idx_quote_tickets_api_credential_id"); + await queryInterface.removeColumn("quote_tickets", "api_credential_id"); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3d9c73e53..d16949384 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,7 +10,11 @@ import { config } from "./config/vars"; import { runMigrations } from "./database/migrator"; import "./models"; // Initialize models import { AlfredpayLimitsService } from "./api/services/alfredpay/alfredpay-limits.service"; -import registerPhaseHandlers from "./api/services/phases/register-handlers"; +import { assertApiCredentialSchemaReady } from "./api/services/apiCredential.service"; +import { + assertPersistedBlockFlowVersionsSupported, + registerBlockFlowHandlers +} from "./api/services/phases/blocks/register-handlers"; import { priceFeedService } from "./api/services/priceFeed.service"; import ApiClientEventsRetentionWorker from "./api/workers/api-client-events-retention.worker"; import CleanupWorker from "./api/workers/cleanup.worker"; @@ -58,9 +62,15 @@ const initializeApp = async () => { // Run database migrations await runMigrations(); + await assertApiCredentialSchemaReady(); + // Initialize EVM clients const _evmClientManager = EvmClientManager.getInstance(); + // Recovery must not run before the flow-derived executor registry exists. + registerBlockFlowHandlers(); + await assertPersistedBlockFlowVersionsSupported(); + // Start background workers new CleanupWorker().start(); new ApiClientEventsRetentionWorker().start(); @@ -70,9 +80,6 @@ const initializeApp = async () => { // Start AlfredPay limits refresh loop (daily; falls back to hardcoded if stale) AlfredpayLimitsService.getInstance().start(); - // Register phase handlers - registerPhaseHandlers(); - // Probe the Binance price feed so a geo-block (HTTP 451) or outage surfaces // loudly in the logs instead of silently degrading to the fiat fallback. // Fire-and-forget: a blocked/hanging call must not delay startup, and the diff --git a/apps/api/src/models/apiCredential.model.ts b/apps/api/src/models/apiCredential.model.ts new file mode 100644 index 000000000..9e681c44d --- /dev/null +++ b/apps/api/src/models/apiCredential.model.ts @@ -0,0 +1,88 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export type ApiCredentialEnvironment = "live" | "test"; + +export interface ApiCredentialAttributes { + id: string; + name: string; + profileId: string; + partnerId: string | null; + environment: ApiCredentialEnvironment; + publicKeyValue: string; + publicLastUsedAt: Date | null; + secretKeyPrefix: string; + secretKeyDigest: string; + secretLastUsedAt: Date | null; + expiresAt: Date; + revokedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type ApiCredentialCreationAttributes = Optional< + ApiCredentialAttributes, + "id" | "partnerId" | "publicLastUsedAt" | "secretLastUsedAt" | "revokedAt" | "createdAt" | "updatedAt" +>; + +class ApiCredential extends Model implements ApiCredentialAttributes { + declare id: string; + declare name: string; + declare profileId: string; + declare partnerId: string | null; + declare environment: ApiCredentialEnvironment; + declare publicKeyValue: string; + declare publicLastUsedAt: Date | null; + declare secretKeyPrefix: string; + declare secretKeyDigest: string; + declare secretLastUsedAt: Date | null; + declare expiresAt: Date; + declare revokedAt: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +ApiCredential.init( + { + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + environment: { allowNull: false, type: DataTypes.ENUM("live", "test") }, + expiresAt: { allowNull: false, field: "expires_at", type: DataTypes.DATE }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + name: { allowNull: false, type: DataTypes.STRING(100) }, + partnerId: { + allowNull: true, + field: "partner_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { key: "id", model: "partners" }, + type: DataTypes.UUID + }, + profileId: { + allowNull: false, + field: "profile_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + publicKeyValue: { allowNull: false, field: "public_key_value", type: DataTypes.STRING(255), unique: true }, + publicLastUsedAt: { allowNull: true, field: "public_last_used_at", type: DataTypes.DATE }, + revokedAt: { allowNull: true, field: "revoked_at", type: DataTypes.DATE }, + secretKeyDigest: { allowNull: false, field: "secret_key_digest", type: DataTypes.STRING(64), unique: true }, + secretKeyPrefix: { allowNull: false, field: "secret_key_prefix", type: DataTypes.STRING(16) }, + secretLastUsedAt: { allowNull: true, field: "secret_last_used_at", type: DataTypes.DATE }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE } + }, + { + indexes: [ + { fields: ["profile_id"], name: "idx_api_credentials_profile_id" }, + { fields: ["partner_id"], name: "idx_api_credentials_partner_id" }, + { fields: ["secret_key_prefix"], name: "idx_api_credentials_secret_key_prefix" } + ], + sequelize, + tableName: "api_credentials", + timestamps: true + } +); + +export default ApiCredential; diff --git a/apps/api/src/models/financialOperation.model.ts b/apps/api/src/models/financialOperation.model.ts new file mode 100644 index 000000000..89f4af4f8 --- /dev/null +++ b/apps/api/src/models/financialOperation.model.ts @@ -0,0 +1,92 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export type FinancialOperationStatus = "not_started" | "submitted" | "confirmed" | "failed" | "unknown"; + +export interface FinancialOperationAttributes { + id: string; + operationKey: string; + scopeType: "quote" | "ramp"; + scopeId: string; + flowId: string; + flowVersion: number; + phase: string; + attemptClass: string; + provider: string; + requestHash: string; + status: FinancialOperationStatus; + externalId: string | null; + response: unknown | null; + errorMessage: string | null; + createdAt: Date; + updatedAt: Date; +} + +type FinancialOperationCreationAttributes = Optional< + FinancialOperationAttributes, + "id" | "externalId" | "response" | "errorMessage" | "createdAt" | "updatedAt" +>; + +class FinancialOperation + extends Model + implements FinancialOperationAttributes +{ + declare id: string; + declare operationKey: string; + declare scopeType: "quote" | "ramp"; + declare scopeId: string; + declare flowId: string; + declare flowVersion: number; + declare phase: string; + declare attemptClass: string; + declare provider: string; + declare requestHash: string; + declare status: FinancialOperationStatus; + declare externalId: string | null; + declare response: unknown | null; + declare errorMessage: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +FinancialOperation.init( + { + attemptClass: { allowNull: false, field: "attempt_class", type: DataTypes.STRING(64) }, + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + errorMessage: { allowNull: true, field: "error_message", type: DataTypes.STRING(500) }, + externalId: { allowNull: true, field: "external_id", type: DataTypes.STRING(255) }, + flowId: { allowNull: false, field: "flow_id", type: DataTypes.STRING(128) }, + flowVersion: { allowNull: false, field: "flow_version", type: DataTypes.INTEGER }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + operationKey: { allowNull: false, field: "operation_key", type: DataTypes.STRING(64), unique: true }, + phase: { allowNull: false, type: DataTypes.STRING(64) }, + provider: { allowNull: false, type: DataTypes.STRING(64) }, + requestHash: { allowNull: false, field: "request_hash", type: DataTypes.STRING(64) }, + response: { allowNull: true, type: DataTypes.JSONB }, + scopeId: { allowNull: false, field: "scope_id", type: DataTypes.STRING(128) }, + scopeType: { + allowNull: false, + field: "scope_type", + type: DataTypes.STRING(16), + validate: { isIn: [["quote", "ramp"]] } + }, + status: { + allowNull: false, + type: DataTypes.STRING(16), + validate: { isIn: [["not_started", "submitted", "confirmed", "failed", "unknown"]] } + }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE } + }, + { + indexes: [ + { fields: ["scope_type", "scope_id"], name: "idx_financial_operations_scope" }, + { fields: ["status", "updated_at"], name: "idx_financial_operations_status_updated" } + ], + modelName: "FinancialOperation", + sequelize, + tableName: "financial_operations", + timestamps: true + } +); + +export default FinancialOperation; diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index fe15590d8..e11b8bb7c 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -1,13 +1,16 @@ import sequelize from "../config/database"; import Anchor from "./anchor.model"; import ApiClientEvent from "./apiClientEvent.model"; +import ApiCredential from "./apiCredential.model"; import ApiKey from "./apiKey.model"; import CustomerEntity from "./customerEntity.model"; +import FinancialOperation from "./financialOperation.model"; import KycCase from "./kycCase.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; import Notification from "./notification.model"; import NotificationPreference from "./notificationPreference.model"; import Partner from "./partner.model"; +import PartnerManagedProfile from "./partnerManagedProfile.model"; import PartnerPricingConfig from "./partnerPricingConfig.model"; import ProfilePartnerAssignment from "./profilePartnerAssignment.model"; import ProfileRole from "./profileRole.model"; @@ -60,6 +63,16 @@ ApiKey.belongsTo(User, { as: "user", foreignKey: "userId" }); ApiKey.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); Partner.hasMany(ApiKey, { as: "apiKeys", foreignKey: "partnerId" }); +User.hasMany(ApiCredential, { as: "apiCredentials", foreignKey: "profileId" }); +ApiCredential.belongsTo(User, { as: "profile", foreignKey: "profileId" }); +Partner.hasMany(ApiCredential, { as: "apiCredentials", foreignKey: "partnerId" }); +ApiCredential.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); + +User.hasOne(PartnerManagedProfile, { as: "managedProfile", foreignKey: "profileId" }); +PartnerManagedProfile.belongsTo(User, { as: "profile", foreignKey: "profileId" }); +Partner.hasMany(PartnerManagedProfile, { as: "managedProfiles", foreignKey: "partnerId" }); +PartnerManagedProfile.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); + // Partner pricing split Partner.hasMany(PartnerPricingConfig, { as: "pricingConfigs", foreignKey: "partnerId" }); PartnerPricingConfig.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); @@ -100,13 +113,16 @@ NotificationPreference.belongsTo(User, { as: "profile", foreignKey: "profileId" const models = { Anchor, ApiClientEvent, + ApiCredential, ApiKey, CustomerEntity, + FinancialOperation, KycCase, MaintenanceSchedule, Notification, NotificationPreference, Partner, + PartnerManagedProfile, PartnerPricingConfig, ProfilePartnerAssignment, ProfileRole, diff --git a/apps/api/src/models/partnerManagedProfile.model.ts b/apps/api/src/models/partnerManagedProfile.model.ts new file mode 100644 index 000000000..0a30346b6 --- /dev/null +++ b/apps/api/src/models/partnerManagedProfile.model.ts @@ -0,0 +1,82 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export const MANAGED_PROFILE_SUBJECT_TYPES = ["individual", "business", "technical"] as const; +export type ManagedProfileSubjectType = (typeof MANAGED_PROFILE_SUBJECT_TYPES)[number]; + +export interface PartnerManagedProfileAttributes { + id: string; + partnerId: string; + profileId: string; + externalUserId: string; + subjectType: ManagedProfileSubjectType; + claimedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type PartnerManagedProfileCreationAttributes = Optional< + PartnerManagedProfileAttributes, + "id" | "claimedAt" | "createdAt" | "updatedAt" +>; + +class PartnerManagedProfile + extends Model + implements PartnerManagedProfileAttributes +{ + declare id: string; + declare partnerId: string; + declare profileId: string; + declare externalUserId: string; + declare subjectType: ManagedProfileSubjectType; + declare claimedAt: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +PartnerManagedProfile.init( + { + claimedAt: { allowNull: true, field: "claimed_at", type: DataTypes.DATE }, + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + externalUserId: { allowNull: false, field: "external_user_id", type: DataTypes.STRING(255) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + partnerId: { + allowNull: false, + field: "partner_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { key: "id", model: "partners" }, + type: DataTypes.UUID + }, + profileId: { + allowNull: false, + field: "profile_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + subjectType: { + allowNull: false, + field: "subject_type", + type: DataTypes.ENUM(...MANAGED_PROFILE_SUBJECT_TYPES) + }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE } + }, + { + indexes: [ + { + fields: ["partner_id", "external_user_id"], + name: "uq_partner_managed_profiles_partner_external_user", + unique: true + }, + { fields: ["profile_id"], name: "uq_partner_managed_profiles_profile_id", unique: true } + ], + modelName: "PartnerManagedProfile", + sequelize, + tableName: "partner_managed_profiles", + timestamps: true + } +); + +export default PartnerManagedProfile; diff --git a/apps/api/src/models/quoteTicket.model.ts b/apps/api/src/models/quoteTicket.model.ts index a6a81a960..f6720d45d 100644 --- a/apps/api/src/models/quoteTicket.model.ts +++ b/apps/api/src/models/quoteTicket.model.ts @@ -7,6 +7,7 @@ import { FlowVariant } from "../config/vars"; // Define the attributes of the QuoteTicket model export interface QuoteTicketAttributes { id: string; // UUID + apiCredentialId: string | null; userId: string | null; // UUID reference to Supabase Auth user (nullable for unauthenticated quotes) rampType: RampDirection; from: DestinationType; @@ -30,12 +31,17 @@ export interface QuoteTicketAttributes { } // Define the attributes that can be set during creation -export type QuoteTicketCreationAttributes = Optional; +export type QuoteTicketCreationAttributes = Optional< + QuoteTicketAttributes, + "id" | "apiCredentialId" | "createdAt" | "updatedAt" +>; // Define the QuoteTicket model class QuoteTicket extends Model implements QuoteTicketAttributes { declare id: string; + declare apiCredentialId: string | null; + declare userId: string | null; declare rampType: RampDirection; @@ -80,6 +86,17 @@ class QuoteTicket extends Model implem declare sessionId: string | null; + declare partnerId: string | null; + + declare userId: string | null; + declare events: WebhookEventType[]; declare isActive: boolean; @@ -71,6 +79,15 @@ Webhook.init( field: "is_active", type: DataTypes.BOOLEAN }, + partnerId: { + allowNull: true, + field: "partner_id", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }, quoteId: { allowNull: true, field: "quote_id", @@ -102,6 +119,11 @@ Webhook.init( }, isUrl: true } + }, + userId: { + allowNull: true, + field: "user_id", + type: DataTypes.UUID } }, { @@ -121,6 +143,14 @@ Webhook.init( { fields: ["is_active", "events"], name: "idx_webhooks_active_events" + }, + { + fields: ["partner_id"], + name: "idx_webhooks_partner_id" + }, + { + fields: ["user_id"], + name: "idx_webhooks_user_id" } ], modelName: "Webhook", diff --git a/apps/api/src/test-utils/contract-support.ts b/apps/api/src/test-utils/contract-support.ts index e891bd40a..a0848c82b 100644 --- a/apps/api/src/test-utils/contract-support.ts +++ b/apps/api/src/test-utils/contract-support.ts @@ -1,5 +1,5 @@ /** - * Helpers for the external API contract suites (docs/features/contract-tests.md). + * Helpers for the external API contract suites (docs/operations-testing.md). * * Partner sandboxes are allowed to be shaky: any error thrown by the live call * itself (network failure, 5xx, rate limit) makes the check INCONCLUSIVE — logged diff --git a/apps/api/src/test-utils/factories.ts b/apps/api/src/test-utils/factories.ts index 5b3d16780..b2d106210 100644 --- a/apps/api/src/test-utils/factories.ts +++ b/apps/api/src/test-utils/factories.ts @@ -11,13 +11,13 @@ import { RampDirection, type UnsignedTx } from "@vortexfi/shared"; -import { generateApiKey, getKeyPrefix, hashApiKey } from "../api/middlewares/apiKeyAuth.helpers"; +import { digestApiKey, generateApiKey, getSecretKeyLookupPrefix } from "../api/middlewares/apiKeyAuth.helpers"; import { hashTaxReference } from "../api/services/avenia/avenia-customer.service"; import { getOrCreateCustomerEntityForProfile } from "../api/services/customer-entity.service"; 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 ApiKey from "../models/apiKey.model"; +import ApiCredential from "../models/apiCredential.model"; import Partner, { type PartnerAttributes } from "../models/partner.model"; import PartnerPricingConfig, { type PartnerPricingConfigAttributes } from "../models/partnerPricingConfig.model"; import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; @@ -89,8 +89,9 @@ export async function createTestPartner(overrides: TestPartnerOverrides = {}): P */ export async function createTestApiKey( options: { partnerName?: string; userId?: string } = {} -): Promise<{ record: ApiKey; plaintextKey: string }> { +): Promise<{ record: ApiCredential; plaintextKey: string; publicKey: string }> { const plaintextKey = generateApiKey("secret", "test"); + const publicKey = generateApiKey("public", "test"); // Auth resolves partners by FK; translate the name (unique) to the id here so tests can // keep passing partnerName. @@ -100,20 +101,18 @@ export async function createTestApiKey( partnerId = partner?.id ?? null; } - const record = await ApiKey.create({ - expiresAt: null, - isActive: true, - keyHash: await hashApiKey(plaintextKey), - keyPrefix: getKeyPrefix(plaintextKey), - keyType: "secret", - keyValue: null, - lastUsedAt: null, + const profileId = options.userId ?? (await createTestUser()).id; + const record = await ApiCredential.create({ + environment: "test", + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), name: "test key", partnerId, - partnerName: options.partnerName ?? null, - userId: options.userId ?? null + profileId, + publicKeyValue: publicKey, + secretKeyDigest: digestApiKey(plaintextKey), + secretKeyPrefix: getSecretKeyLookupPrefix(plaintextKey) }); - return { plaintextKey, record }; + return { plaintextKey, publicKey, record }; } /** Minimal complete fee structure so status/fee readers work; override per test. */ @@ -126,7 +125,7 @@ export function defaultQuoteFees(currency: FiatToken = FiatToken.EURC): NonNulla /** * A pending EUR→USDC-on-Base onramp quote by default; override anything. - * Metadata carries a minimal fee structure — pass a realistic `metadata` + * Metadata carries a minimal catalog structure — pass realistic `metadata` * override for tests that exercise ramp registration. */ export async function createTestQuote(overrides: Partial = {}): Promise { @@ -138,7 +137,14 @@ export async function createTestQuote(overrides: Partial from: EPaymentMethod.SEPA as DestinationType, inputAmount: "100", inputCurrency: FiatToken.EURC, - metadata: { fees: defaultQuoteFees(), ...(overrides.metadata ?? {}) } as QuoteTicketMetadata, + metadata: (overrides.metadata ?? { + blocks: {}, + globals: { + fees: defaultQuoteFees(), + partner: null, + request: {} + } + }) as QuoteTicketMetadata, network: Networks.Base, outputAmount: "105", outputCurrency: EvmToken.USDC, diff --git a/apps/api/src/test-utils/fake-world/fake-anchors.ts b/apps/api/src/test-utils/fake-world/fake-anchors.ts index 60decc7e3..2c1329418 100644 --- a/apps/api/src/test-utils/fake-world/fake-anchors.ts +++ b/apps/api/src/test-utils/fake-world/fake-anchors.ts @@ -226,7 +226,14 @@ export class FakeBrla { maxChainOut: "10000000", maxFiatIn: "10000000", maxFiatOut: "10000000", - usedLimit: { usedChainIn: "0", usedChainOut: "0", usedFiatIn: "0", usedFiatOut: "0" } + usedLimit: { + month: new Date().getUTCMonth() + 1, + usedChainIn: "0", + usedChainOut: "0", + usedFiatIn: "0", + usedFiatOut: "0", + year: new Date().getUTCFullYear() + } } ] } diff --git a/apps/api/src/test-utils/fake-world/fake-prices.ts b/apps/api/src/test-utils/fake-world/fake-prices.ts index c225e9f78..ef4157ebb 100644 --- a/apps/api/src/test-utils/fake-world/fake-prices.ts +++ b/apps/api/src/test-utils/fake-world/fake-prices.ts @@ -49,17 +49,20 @@ export class FakePrices { } } -type PatchedMethods = "getCryptoPrice" | "getUsdToFiatExchangeRate" | "convertCurrency"; +type PatchedMethods = "getCryptoPrice" | "getFiatToUsdExchangeRate" | "getUsdToFiatExchangeRate" | "convertCurrency"; export function installFakePrices(): { fakePrices: FakePrices; restore: () => void } { const fakePrices = new FakePrices(); const originals: Partial> = { convertCurrency: priceFeedService.convertCurrency, getCryptoPrice: priceFeedService.getCryptoPrice, + getFiatToUsdExchangeRate: priceFeedService.getFiatToUsdExchangeRate, getUsdToFiatExchangeRate: priceFeedService.getUsdToFiatExchangeRate }; priceFeedService.getCryptoPrice = async (tokenId: string) => fakePrices.getCryptoUsd(tokenId); + priceFeedService.getFiatToUsdExchangeRate = async (fromCurrency: RampCurrency) => + new Big(1).div(fakePrices.getPerUsd(fromCurrency as string)); priceFeedService.getUsdToFiatExchangeRate = async (toCurrency: RampCurrency) => fakePrices.getPerUsd(toCurrency as string); priceFeedService.convertCurrency = async ( amount: string, diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 7e67411de..21fefb637 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -46,7 +46,6 @@ if (!process.env.RUN_LIVE_TESTS) { 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 = ""; // Keep rate limiting out of the way of HTTP-level tests. process.env.RATE_LIMIT_MAX_REQUESTS = "100000"; diff --git a/apps/api/src/test-utils/test-app.ts b/apps/api/src/test-utils/test-app.ts index fa4df8843..b6425ba7e 100644 --- a/apps/api/src/test-utils/test-app.ts +++ b/apps/api/src/test-utils/test-app.ts @@ -17,8 +17,8 @@ export interface TestApp { */ export async function startTestApp(): Promise { const { default: app } = await import("../config/express"); - const { default: registerPhaseHandlers } = await import("../api/services/phases/register-handlers"); - registerPhaseHandlers(); + const { registerBlockFlowHandlers } = await import("../api/services/phases/blocks/register-handlers"); + registerBlockFlowHandlers(); const server: Server = await new Promise(resolve => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); diff --git a/apps/api/src/tests/alfredpay-kyb-legacy-entity.integration.test.ts b/apps/api/src/tests/alfredpay-kyb-legacy-entity.integration.test.ts new file mode 100644 index 000000000..70ee77b5b --- /dev/null +++ b/apps/api/src/tests/alfredpay-kyb-legacy-entity.integration.test.ts @@ -0,0 +1,250 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { + AlfredPayCountry, + AlfredPayStatus, + AlfredpayApiService, + AlfredpayCustomerType, + AlfredpayKycStatus +} from "@vortexfi/shared"; +import { createAlfredpayCustomer, findAlfredpayCustomer } from "../api/services/alfredpay/alfredpay-customer.service"; +import { getOrCreateCustomerEntityForProfile } from "../api/services/customer-entity.service"; +import CustomerEntity from "../models/customerEntity.model"; +import KycCase from "../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestUser } from "../test-utils/factories"; +import { type FakeSupabaseAuth, installFakeSupabaseAuth, testUserToken } from "../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +// Regression suite for the migrated-entity mismatch: migration 038 backfilled one *individual* +// entity per profile, and migration 040 attached the legacy provider rows to it — including +// business-typed (KYB) rows. Typed business lookups then resolved a fresh, empty *business* +// entity, so every KYB wizard endpoint 404'd ("Alfredpay business customer not found") for +// migrated business customers and left the stray entity behind, while the type-less +// dashboard/ramp lookups kept finding the rows on the active entity — a resumable KYB the +// wizard could not act on. Typed lookups now scan every entity the profile owns. + +let api: TestApp; +let fakeAuth: FakeSupabaseAuth; +const realGetInstance = AlfredpayApiService.getInstance; + +beforeAll(async () => { + await setupTestDatabase(); + fakeAuth = installFakeSupabaseAuth(); + api = await startTestApp(); +}); + +afterAll(async () => { + await api.close(); + fakeAuth.restore(); +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +afterEach(() => { + AlfredpayApiService.getInstance = realGetInstance; +}); + +function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +// The exact post-migration production shape: a business-typed alfredpay row (plus its kyb +// kyc_case) parked on the profile's active individual entity. +async function seedLegacyBusinessCustomer(email: string) { + const user = await createTestUser({ email }); + const token = testUserToken(user.id, email); + const entity = await getOrCreateCustomerEntityForProfile(user.id); + await user.update({ activeCustomerEntityId: entity.id }); + const record = await ProviderCustomer.create({ + country: "CO", + customerEntityId: entity.id, + customerType: "business", + provider: "alfredpay", + providerCustomerId: "ap-legacy-kyb", + rail: "cop", + status: VerificationStatus.Pending, + statusExternal: "PENDING" + }); + await KycCase.create({ + customerEntityId: entity.id, + level: "level_1", + provider: "alfredpay", + providerCaseId: "kyb-sub-legacy", + providerCustomerId: record.id, + status: VerificationStatus.Pending, + statusExternal: "PENDING", + type: "kyb" + }); + return { entity, record, token, user }; +} + +// Carries the compliance questionnaire because `validateKybSubmission` rejects a submission +// without it. +const KYB_FORM = { + accountPurpose: "Treasury management", + address: "Calle 1 # 2-3", + businessActivities: "Cross-border payments software", + businessName: "Legacy SAS", + city: "Bogota", + country: "CO", + expectedMonthlyTransactions: 120, + expectedMonthlyVolumeUsd: 50000, + isRegulatedBusiness: false, + operatesInSanctionedCountries: false, + relatedPersons: [ + { + dateOfBirth: "1990-01-01", + email: "rep@example.com", + firstName: "Ana", + lastName: "Rep", + nationalities: ["CO"], + pep: false + } + ], + sourceOfFunds: "Sale of goods/services", + state: "DC", + taxId: "900123456", + transmitsCustomerFunds: false, + walletAddresses: "N/A", + website: "https://legacy.example.com", + zipCode: "110111" +}; + +describe("Alfredpay KYB on a migrated (individual-entity) profile", () => { + it("typed and type-less lookups agree on the migrated business customer", async () => { + const { user } = await seedLegacyBusinessCustomer("kyb-legacy-agree@example.com"); + + const typeless = await findAlfredpayCustomer(user.id, AlfredPayCountry.CO); + const typed = await findAlfredpayCustomer(user.id, AlfredPayCountry.CO, AlfredpayCustomerType.BUSINESS); + + expect(typeless?.alfredPayId).toBe("ap-legacy-kyb"); + expect(typed?.alfredPayId).toBe("ap-legacy-kyb"); + }); + + it("findKybCustomerAndBusiness resolves the migrated customer without creating a stray entity", async () => { + const { token, user } = await seedLegacyBusinessCustomer("kyb-legacy-find@example.com"); + + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => [{ relatedPersons: [], submissionId: "kyb-sub-legacy" }]) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/findKybCustomerAndBusiness?country=CO", { + headers: authHeaders(token) + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual([{ relatedPersons: [], submissionId: "kyb-sub-legacy" }]); + expect(await CustomerEntity.count({ where: { profileId: user.id } })).toBe(1); + }); + + it("submitKybInformation resumes the migrated customer's pending submission in place", async () => { + const { token } = await seedLegacyBusinessCustomer("kyb-legacy-resume@example.com"); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + const submitKybInformation = mock(async () => ({ submissionId: "should-not-be-created" })); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + getLastKybSubmission: mock(async () => ({ submissionId: "kyb-sub-legacy" })), + submitKybInformation, + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ submissionId: "kyb-sub-legacy" }); + expect(updateKybInformation).toHaveBeenCalledTimes(1); + expect(updateKybInformation.mock.calls[0]).toEqual(["ap-legacy-kyb", "kyb-sub-legacy", KYB_FORM]); + expect(submitKybInformation).not.toHaveBeenCalled(); + }); + + it("createBusinessCustomer refuses to duplicate the migrated customer", async () => { + const { token } = await seedLegacyBusinessCustomer("kyb-legacy-duplicate@example.com"); + + const createCustomer = mock(async () => ({ customerId: "ap-duplicate" })); + AlfredpayApiService.getInstance = mock(() => ({ createCustomer }) as unknown as AlfredpayApiService); + + const response = await api.request("/v1/alfredpay/createBusinessCustomer", { + body: JSON.stringify({ country: "CO" }), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(400); + expect(((await response.json()) as { error: string }).error).toBe("Business customer already exists"); + expect(createCustomer).not.toHaveBeenCalled(); + expect(await ProviderCustomer.count({ where: { provider: "alfredpay" } })).toBe(1); + }); + + it("a typed lookup with no business rows answers 404 without creating an entity", async () => { + const email = "kyb-no-rows@example.com"; + const user = await createTestUser({ email }); + const token = testUserToken(user.id, email); + await getOrCreateCustomerEntityForProfile(user.id); + + const response = await api.request("/v1/alfredpay/findKybCustomerAndBusiness?country=CO", { + headers: authHeaders(token) + }); + + expect(response.status).toBe(404); + expect(await CustomerEntity.count({ where: { profileId: user.id } })).toBe(1); + }); + + it("createAlfredpayCustomer homes a new corridor's business row with the existing legacy rows", async () => { + const { entity, user } = await seedLegacyBusinessCustomer("kyb-legacy-colocate@example.com"); + + // Ramp registration resolves the active entity — a new corridor's row must land next to + // the legacy rows there, not on a fresh business entity it can never reach. + await createAlfredpayCustomer(user.id, { + alfredPayId: "ap-legacy-mx", + country: AlfredPayCountry.MX, + status: AlfredPayStatus.Consulted, + type: AlfredpayCustomerType.BUSINESS + }); + + const created = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-legacy-mx" } }); + expect(created?.customerEntityId).toBe(entity.id); + expect(await CustomerEntity.count({ where: { profileId: user.id } })).toBe(1); + }); + + it("createAlfredpayCustomer prefers the active entity when rows are split across entities", async () => { + const { entity, user } = await seedLegacyBusinessCustomer("kyb-legacy-split@example.com"); + + // A profile hit by the pre-fix duplicate bug: a *newer* same-type row sits on a stray + // business entity. The new corridor must still land on the active entity — the only one + // quote/ramp resolution reads — not on the most recently updated sibling's entity. + const strayBusinessEntity = await CustomerEntity.create({ profileId: user.id, status: "active", type: "business" }); + await ProviderCustomer.create({ + country: "MX", + customerEntityId: strayBusinessEntity.id, + customerType: "business", + provider: "alfredpay", + providerCustomerId: "ap-legacy-duplicate", + rail: "mxn", + status: VerificationStatus.Started + }); + + await createAlfredpayCustomer(user.id, { + alfredPayId: "ap-legacy-us", + country: AlfredPayCountry.US, + status: AlfredPayStatus.Consulted, + type: AlfredpayCustomerType.BUSINESS + }); + + const created = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-legacy-us" } }); + expect(created?.customerEntityId).toBe(entity.id); + }); +}); diff --git a/apps/api/src/tests/auth.invariants.test.ts b/apps/api/src/tests/auth.invariants.test.ts index 1fe483a4c..bc1241922 100644 --- a/apps/api/src/tests/auth.invariants.test.ts +++ b/apps/api/src/tests/auth.invariants.test.ts @@ -74,7 +74,7 @@ describe("auth and ownership invariants", () => { it("rejects a revoked API key", async () => { const user = await createTestUser(); const { record, plaintextKey } = await createTestApiKey({ userId: user.id }); - await record.update({ isActive: false }); + await record.update({ revokedAt: new Date() }); const response = await register(body, { "X-API-Key": plaintextKey }); expect(response.status).toBe(401); @@ -219,7 +219,8 @@ describe("auth and ownership invariants", () => { 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 profile = await createTestUser(); + const path = `/v1/admin/partners/${partner.name}/api-credentials?userId=${profile.id}`; const noAuth = await app.request(path); expect(noAuth.status).toBe(401); diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index d0cb1afe4..766db0a30 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -1,5 +1,5 @@ /** - * External API contract: Alfredpay (docs/features/contract-tests.md). + * External API contract: Alfredpay (docs/operations-testing.md). * * The same consumed-contract schemas run against the fake (hermetic, PR-blocking) * and against the partner API (live, nightly). Live tests skip cleanly when diff --git a/apps/api/src/tests/contracts/avenia.contract.test.ts b/apps/api/src/tests/contracts/avenia.contract.test.ts index 172777e63..6ee19da5d 100644 --- a/apps/api/src/tests/contracts/avenia.contract.test.ts +++ b/apps/api/src/tests/contracts/avenia.contract.test.ts @@ -1,5 +1,5 @@ /** - * External API contract: Avenia/BRLA (docs/features/contract-tests.md). + * External API contract: Avenia/BRLA (docs/operations-testing.md). * * The same consumed-contract schemas run against the fake (hermetic, PR-blocking) * and against the partner API (live, nightly). Live tests skip cleanly when BRLA_* diff --git a/apps/api/src/tests/contracts/pricefeeds.contract.test.ts b/apps/api/src/tests/contracts/pricefeeds.contract.test.ts index e94f2daa4..66038f117 100644 --- a/apps/api/src/tests/contracts/pricefeeds.contract.test.ts +++ b/apps/api/src/tests/contracts/pricefeeds.contract.test.ts @@ -1,5 +1,5 @@ /** - * External API contract: CoinGecko price feed (docs/features/contract-tests.md). + * External API contract: CoinGecko price feed (docs/operations-testing.md). * * Unlike the anchor fakes, FakePrices patches PriceFeedService's methods *above* * the HTTP seam (it never produces wire JSON), so the verified-fake half is diff --git a/apps/api/src/tests/contracts/squidrouter.contract.test.ts b/apps/api/src/tests/contracts/squidrouter.contract.test.ts index 8686a0a84..978d81141 100644 --- a/apps/api/src/tests/contracts/squidrouter.contract.test.ts +++ b/apps/api/src/tests/contracts/squidrouter.contract.test.ts @@ -1,5 +1,5 @@ /** - * External API contract: SquidRouter (docs/features/contract-tests.md). + * External API contract: SquidRouter (docs/operations-testing.md). * * The same consumed-contract schemas run against the fake (hermetic, PR-blocking) * and against the real public API (live, nightly). The live half needs no diff --git a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts index ac4a7047c..4cb9d06c8 100644 --- a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts +++ b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts @@ -20,6 +20,7 @@ import { BaseError, ContractFunctionExecutionError, decodeFunctionData, encodeFu import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import { parseUnits } from "viem/utils"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import FinancialOperation from "../../models/financialOperation.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; @@ -182,9 +183,9 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { 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; + // Both direct and cross-chain Alfredpay SELL simulations price a + // Squid-delivered Polygon USDT settlement leg. + world.squidRouter.toTokenDecimals = 6; world.squidRouter.bridgeStatus = "success"; }); @@ -353,7 +354,10 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { }); const persistedQuote = await QuoteTicket.findByPk(quote.id); - const mintAmountRaw = BigInt(persistedQuote?.metadata.alfredpayMint?.outputAmountRaw ?? "0"); + const metadata = persistedQuote?.metadata as unknown as + | { blocks: { alfredpayMint?: { outputAmountRaw?: string } } } + | undefined; + const mintAmountRaw = BigInt(metadata?.blocks.alfredpayMint?.outputAmountRaw ?? "0"); expect(mintAmountRaw).toBeGreaterThan(0n); const amountRaw = parseUnits(quote.outputAmount, ALFREDPAY_ERC20_DECIMALS); @@ -433,7 +437,10 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { }); const persistedQuote = await QuoteTicket.findByPk(quote.id); - const inputAmountRaw = BigInt(persistedQuote?.metadata.alfredpayOfframp?.inputAmountRaw ?? "0"); + const metadata = persistedQuote?.metadata as unknown as + | { blocks: { alfredpayOfframp?: { inputAmountRaw?: string } } } + | undefined; + const inputAmountRaw = BigInt(metadata?.blocks.alfredpayOfframp?.inputAmountRaw ?? "0"); expect(inputAmountRaw).toBeGreaterThan(0n); const registered = await RampState.findByPk(ramp.id); @@ -529,8 +536,16 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { }); const persistedQuote = await QuoteTicket.findByPk(quote.id); - const mintAmountRaw = BigInt(persistedQuote?.metadata.alfredpayMint?.outputAmountRaw ?? "0"); - const bridgedAmountRaw = BigInt(persistedQuote?.metadata.evmToEvm?.outputAmountRaw ?? "0"); + const metadata = persistedQuote?.metadata as unknown as + | { + blocks: { + alfredpayMint?: { outputAmountRaw?: string }; + squidRouterSwap?: { outputAmountRaw?: string }; + }; + } + | undefined; + const mintAmountRaw = BigInt(metadata?.blocks.alfredpayMint?.outputAmountRaw ?? "0"); + const bridgedAmountRaw = BigInt(metadata?.blocks.squidRouterSwap?.outputAmountRaw ?? "0"); expect(mintAmountRaw).toBeGreaterThan(0n); expect(bridgedAmountRaw).toBeGreaterThan(0n); @@ -620,7 +635,10 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { }); const persistedQuote = await QuoteTicket.findByPk(quote.id); - const inputAmountRaw = BigInt(persistedQuote?.metadata.alfredpayOfframp?.inputAmountRaw ?? "0"); + const metadata = persistedQuote?.metadata as unknown as + | { blocks: { alfredpayOfframp?: { inputAmountRaw?: string } } } + | undefined; + const inputAmountRaw = BigInt(metadata?.blocks.alfredpayOfframp?.inputAmountRaw ?? "0"); expect(inputAmountRaw).toBeGreaterThan(0n); const registered = await RampState.findByPk(ramp.id); @@ -786,7 +804,7 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { }); it( - `transient failure (${currency.fiat}): an RPC outage on the destination transfer is recoverable and the onramp still completes`, + `ambiguous destination broadcast (${currency.fiat}): pauses for reconciliation without paying the recipient`, async () => { const setup = await setUpOnrampRamp(currency); // The first broadcast of this corridor is the destination transfer. @@ -796,17 +814,19 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { 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?.currentPhase).toBe("destinationTransfer"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); 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.length).toBe(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); + expect(final?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); + expect( + await FinancialOperation.findOne({ where: { phase: "destinationTransfer", scopeId: setup.rampId } }) + ).toMatchObject({ status: "unknown" }); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.destination)).toBe(0n); }, 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 index 427566678..e015fd72f 100644 --- a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts @@ -12,6 +12,7 @@ import { import { parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import { getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; @@ -144,13 +145,6 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via return (await response.json()) as { id: string; outputAmount: string }; } - // The EVM→BRL route still requires a Substrate entry in signingAccounts - // (validateOfframpQuote legacy default) even though this path never uses it to - // sign — all signing here is EVM. A static well-known SS58 address keeps the test - // off the @polkadot WASM keyring, whose CJS/ESM dual-load intermittently leaves an - // uninitialized bridge under Bun and crashed this suite in CI. - const SUBSTRATE_PLACEHOLDER_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - async function registerViaApi( quoteId: string, userId: string, @@ -166,10 +160,7 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via walletAddress: userWallet.address }, quoteId, - signingAccounts: [ - { address: ephemeral.address, type: "EVM" }, - { address: SUBSTRATE_PLACEHOLDER_ADDRESS, type: "Substrate" } - ] + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] }), headers: { Authorization: `Bearer ${testUserToken(userId)}`, @@ -229,8 +220,9 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via 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"); + const blocks = getFlowMetadata(persistedQuote?.metadata).blocks; + const swapInputRaw = BigInt((blocks.nablaSwap as { inputAmountForSwapRaw: string }).inputAmountForSwapRaw); + const swapOutputRaw = BigInt((blocks.aveniaOfframpPayout as { transferAmountRaw: string }).transferAmountRaw); expect(swapInputRaw).toBeGreaterThan(0n); expect(swapOutputRaw).toBeGreaterThan(0n); diff --git a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts index 4389cb104..c075a8adf 100644 --- a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { AveniaTicketStatus, + type CleanupPhase, EvmToken, evmTokenConfig, FiatToken, @@ -12,6 +13,8 @@ import { 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 { getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; +import FinancialOperation from "../../models/financialOperation.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import Subsidy from "../../models/subsidy.model"; @@ -136,13 +139,6 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { return (await response.json()) as { id: string; outputAmount: string }; } - // The EVM→BRL route still requires a Substrate entry in signingAccounts - // (validateOfframpQuote legacy default) even though this path never uses it to - // sign — all signing here is EVM. A static well-known SS58 address keeps the test - // off the @polkadot WASM keyring, whose CJS/ESM dual-load intermittently leaves an - // uninitialized bridge under Bun and crashed this suite in CI. - const SUBSTRATE_PLACEHOLDER_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - async function registerViaApi( quoteId: string, userId: string, @@ -158,10 +154,7 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { walletAddress: userWallet.address }, quoteId, - signingAccounts: [ - { address: ephemeral.address, type: "EVM" }, - { address: SUBSTRATE_PLACEHOLDER_ADDRESS, type: "Substrate" } - ] + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] }), headers: { Authorization: `Bearer ${testUserToken(userId)}`, @@ -173,7 +166,7 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { return (await response.json()) as { id: string }; } - function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase): UnsignedTx { + function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase | CleanupPhase): UnsignedTx { const blueprint = unsignedTxs.find(tx => tx.phase === phase); expect(blueprint, `missing ${phase} blueprint in persisted ramp state`).toBeDefined(); return blueprint as UnsignedTx; @@ -209,8 +202,9 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { 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"); + const blocks = getFlowMetadata(persistedQuote?.metadata).blocks; + const swapInputRaw = BigInt((blocks.nablaSwap as { inputAmountForSwapRaw: string }).inputAmountForSwapRaw); + const swapOutputRaw = BigInt((blocks.aveniaOfframpPayout as { transferAmountRaw: string }).transferAmountRaw); expect(swapInputRaw).toBeGreaterThan(0n); expect(swapOutputRaw).toBeGreaterThan(0n); @@ -223,6 +217,17 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { const nablaApproveBlueprint = blueprintOf(unsignedTxs, "nablaApprove"); const nablaSwapBlueprint = blueprintOf(unsignedTxs, "nablaSwap"); const payoutBlueprint = blueprintOf(unsignedTxs, "brlaPayoutOnBase"); + expect(rampState.state.phaseFlow).toEqual(HAPPY_PATH_PHASES); + expect(rampState.state.taxId).toBe(TAX_ID); + expect(rampState.state.pixDestination).toBe(PIX_KEY); + expect(rampState.state.receiverTaxId).toBe(RECEIVER_TAX_ID); + expect(rampState.state.brlaEvmAddress.toLowerCase()).toBe(world.brla.subaccountEvmWallet.toLowerCase()); + expect(payoutBlueprint.nonce).toBe(nablaSwapBlueprint.nonce + 1); + expect( + (["baseCleanupUsdc", "baseCleanupBrla", "baseCleanupAxlUsdc"] as CleanupPhase[]).map( + phase => blueprintOf(unsignedTxs, phase).nonce + ) + ).toEqual([payoutBlueprint.nonce + 1, payoutBlueprint.nonce + 2, payoutBlueprint.nonce + 3]); const signedNablaApprove = await signBlueprint(ephemeral, nablaApproveBlueprint); const signedNablaSwap = await signBlueprint(ephemeral, nablaSwapBlueprint); @@ -350,7 +355,7 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { ); it( - "transient failure: a scripted RPC outage is recorded as recoverable and the corridor still completes", + "ambiguous payout failure: a scripted RPC outage pauses the corridor for reconciliation", async () => { const setup = await setUpRegisteredRamp(); scriptHappyWorld(setup); @@ -369,14 +374,39 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { await phaseProcessor.processRamp(setup.rampId); const final = await RampState.findByPk(setup.rampId); - expect(final?.currentPhase).toBe("complete"); + expect(final?.currentPhase).toBe("brlaPayoutOnBase"); expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); - // The payout handler wraps broadcast errors in its own recoverable message. + // The first broadcast error is recoverable at the phase layer, but its + // financial-operation claim is now ambiguous. Automatic retries halt + // rather than risk paying the anchor twice. 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); + expect(final?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); + expect( + await FinancialOperation.findOne({ where: { phase: "brlaPayoutOnBase", scopeId: setup.rampId } }) + ).toMatchObject({ status: "unknown" }); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(0n); + }, + 30000 + ); + + it( + "payout recovery: an existing Avenia ticket is polled without another transfer or ticket", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const ramp = await RampState.findByPk(setup.rampId); + await ramp?.update({ state: { ...ramp.state, payOutTicketId: "existing-ticket" } }); + const ticketsBefore = world.brla.pixOutputTickets.length; + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(submissionsOf(setup.signedPayout)).toBe(0); + expect(world.brla.pixOutputTickets.length).toBe(ticketsBefore); }, 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 index eea7af1ed..f947aa301 100644 --- a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts @@ -11,6 +11,9 @@ import { import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import { getBlockMetadata } from "../../api/services/phases/blocks/core/metadata"; +import { NablaSwapContext } from "../../api/services/phases/blocks/phases/nabla-swap/simulation"; +import { SquidRouterSwapContext } from "../../api/services/phases/blocks/phases/squid-router-swap/simulation"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; @@ -210,9 +213,14 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar 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"); + if (!persistedQuote) { + throw new Error("Quote not found after creation"); + } + const nablaMetadata = getBlockMetadata(persistedQuote.metadata, NablaSwapContext); + const squidMetadata = getBlockMetadata(persistedQuote.metadata, SquidRouterSwapContext); + const swapInputRaw = BigInt(nablaMetadata.inputAmountForSwapRaw); + const swapOutputRaw = BigInt(nablaMetadata.outputAmountRaw); + const bridgedAmountRaw = BigInt(squidMetadata.outputAmountRaw); expect(swapInputRaw).toBeGreaterThan(0n); expect(swapOutputRaw).toBeGreaterThan(0n); expect(bridgedAmountRaw).toBeGreaterThan(0n); diff --git a/apps/api/src/tests/corridors/brl-onramp.scenario.test.ts b/apps/api/src/tests/corridors/brl-onramp.scenario.test.ts index 76eb75878..0ec183577 100644 --- a/apps/api/src/tests/corridors/brl-onramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-onramp.scenario.test.ts @@ -3,6 +3,7 @@ import { EvmToken, evmTokenConfig, FiatToken, Networks, RampDirection, type Ramp 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 FinancialOperation from "../../models/financialOperation.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import Subsidy from "../../models/subsidy.model"; @@ -231,7 +232,7 @@ describe("BRL onramp direct corridor (pix → BRLA on Base)", () => { ); it( - "transient failure: retries a failed destinationTransfer broadcast (recoverable) and still completes", + "ambiguous destination broadcast: pauses for reconciliation without paying the recipient", async () => { const setup = await setUpRegisteredRamp(); scriptHappyWorld(setup); @@ -241,18 +242,19 @@ describe("BRL onramp direct corridor (pix → BRLA on Base)", () => { await phaseProcessor.processRamp(setup.rampId); const final = await RampState.findByPk(setup.rampId); - expect(final?.currentPhase).toBe("complete"); + expect(final?.currentPhase).toBe("destinationTransfer"); 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.length).toBe(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); + expect(final?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); + expect(await FinancialOperation.findOne({ where: { phase: "destinationTransfer", scopeId: setup.rampId } })).toMatchObject({ + status: "unknown" + }); + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, setup.destination)).toBe(0n); }, 30000 ); @@ -284,7 +286,7 @@ describe("BRL onramp direct corridor (pix → BRLA on Base)", () => { ); it( - "retry exhaustion: a permanently failing broadcast stops processing without moving funds, and stays resumable", + "reconciliation lock: clearing an RPC outage does not authorize an ambiguous broadcast retry", async () => { const setup = await setUpRegisteredRamp(); scriptHappyWorld(setup); @@ -293,26 +295,23 @@ describe("BRL onramp direct corridor (pix → BRLA on Base)", () => { 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(outageLogs.length).toBe(1); 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. + // A later processing cycle may reconcile but must not blindly resubmit. 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); + expect(final?.currentPhase).toBe("destinationTransfer"); + expect(final?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, setup.destination)).toBe(0n); }, 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 index 223c1e3f7..d5cb42110 100644 --- a/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts @@ -16,7 +16,12 @@ import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from 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 { accountCapabilities } from "../../api/services/phases/blocks/core/accounts"; +import { getBlockMetadata, getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; +import { resolveBlockFlow } from "../../api/services/phases/blocks/flows/catalog"; +import { MykoboOfframpPayoutContext } from "../../api/services/phases/blocks/phases/mykobo-offramp-payout/simulation"; +import { NablaSwapContext } from "../../api/services/phases/blocks/phases/nabla-swap/simulation"; +import FinancialOperation from "../../models/financialOperation.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import Subsidy from "../../models/subsidy.model"; @@ -73,15 +78,15 @@ interface CorridorSetup { * 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 → + * mirrors only the thin glue and calls the real block `Flow.register` and + * `prepareTxs`, which resolve the KYC-gated Mykobo customer, create the + * WITHDRAW intent and build 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 + * kill-switch and in docs/operations-testing.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. */ @@ -156,6 +161,7 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { * seeding below deliberately starts where this rejection ends. */ async function assertRegisterEndpointStillKillSwitched(quoteId: string, userId: string, wallet: string): Promise { + const intentCount = world.mykobo.intents.length; const response = await app.request("/v1/ramp/register", { body: JSON.stringify({ additionalData: { destinationAddress: wallet, ipAddress: IP_ADDRESS, walletAddress: wallet }, @@ -169,6 +175,7 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { method: "POST" }); expect(response.status).toBe(503); + expect(world.mykobo.intents.length).toBe(intentCount); } function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase): UnsignedTx { @@ -214,8 +221,10 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { if (!persistedQuote) { throw new Error("Quote not persisted"); } - const swapInputRaw = BigInt(persistedQuote.metadata.nablaSwapEvm?.inputAmountForSwapRaw ?? "0"); - const swapOutputRaw = BigInt(persistedQuote.metadata.nablaSwapEvm?.outputAmountRaw ?? "0"); + const metadata = getFlowMetadata(persistedQuote.metadata); + const nablaMetadata = getBlockMetadata(metadata, NablaSwapContext); + const swapInputRaw = BigInt(nablaMetadata.inputAmountForSwapRaw); + const swapOutputRaw = BigInt(nablaMetadata.outputAmountRaw); expect(swapInputRaw).toBeGreaterThan(0n); expect(swapOutputRaw).toBeGreaterThan(0n); @@ -227,16 +236,31 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { const { normalizedSigningAccounts, ephemerals } = normalizeAndValidateSigningAccounts([ { address: ephemeral.address, type: EphemeralAccountType.EVM } ]); - await validateEphemeralAccountsFresh(ephemerals); + await validateEphemeralAccountsFresh(ephemerals, persistedQuote); - const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({ - destinationAddress: additionalData.destinationAddress, + const flow = resolveBlockFlow(metadata.globals.request); + const quoteFields = persistedQuote.get({ plain: true }); + const registered = await flow.register({ + authenticatedUser: { id: user.id }, + input: { walletAddress: additionalData.walletAddress }, ipAddress: additionalData.ipAddress, - quote: persistedQuote, + metadata, + quote: quoteFields, signingAccounts: normalizedSigningAccounts, - userAddress: additionalData.walletAddress, + }); + const prepared = await flow.prepareTxs({ + accounts: accountCapabilities(normalizedSigningAccounts), + destinationAddress: additionalData.destinationAddress, + metadata: registered.metadata, + quote: quoteFields, + registrationFacts: registered.registrationFacts, userId: user.id }); + const payoutState = prepared.stateMeta.blockState?.[MykoboOfframpPayoutContext.key] as Record; + const { stateMeta, unsignedTxs } = { + stateMeta: { ...prepared.stateMeta, ...payoutState, walletAddress: additionalData.walletAddress }, + unsignedTxs: prepared.unsignedTxs + }; const [consumed] = await QuoteTicket.update( { status: "consumed" }, @@ -382,9 +406,11 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { 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"); + expect(subsidies.map(subsidy => subsidy.phase)).toEqual(["subsidizePreSwap"]); + expect(subsidies.find(subsidy => subsidy.phase === "subsidizePreSwap")?.token).toBe( + EvmToken.USDC as unknown as SubsidyToken + ); + expect(Number(subsidies.find(subsidy => subsidy.phase === "subsidizePreSwap")?.amount)).toBeCloseTo(1); // The swap and payout were each broadcast exactly once; Mykobo's // receivables wallet received exactly the intent value in EURC. @@ -403,7 +429,7 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { ); it( - "transient failure: a scripted RPC outage on the payout is recorded as recoverable and the corridor still completes", + "ambiguous payout failure: a scripted RPC outage pauses the corridor for reconciliation", async () => { const setup = await setUpRegisteredRamp(); scriptHappyWorld(setup); @@ -421,14 +447,19 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { await phaseProcessor.processRamp(setup.rampId); const final = await RampState.findByPk(setup.rampId); - expect(final?.currentPhase).toBe("complete"); + expect(final?.currentPhase).toBe("mykoboPayoutOnBase"); expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); - // The payout handler wraps broadcast errors in its own recoverable message. + // The phase records the transport error, then the durable operation + // blocks blind retries because the provider outcome is unknown. 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); + expect(final?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); + expect( + await FinancialOperation.findOne({ where: { phase: "mykoboPayoutOnBase", scopeId: setup.rampId } }) + ).toMatchObject({ status: "unknown" }); + expect(world.evm.erc20Balance(Networks.Base, EURC_ON_BASE, setup.receivablesAddress)).toBe(0n); }, 30000 ); @@ -465,13 +496,13 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { { address: ephemeral.address, type: EphemeralAccountType.EVM } ]); await expect( - prepareOfframpTransactions({ - destinationAddress: userWallet.address, + resolveBlockFlow(getFlowMetadata((persistedQuote as QuoteTicket).metadata).globals.request).register({ + authenticatedUser: { id: user.id }, + input: { walletAddress: userWallet.address }, ipAddress: IP_ADDRESS, - quote: persistedQuote as QuoteTicket, + metadata: getFlowMetadata((persistedQuote as QuoteTicket).metadata), + quote: (persistedQuote as QuoteTicket).get({ plain: true }), signingAccounts: normalizedSigningAccounts, - userAddress: userWallet.address, - userId: user.id }) ).rejects.toThrow("scripted intent failure"); expect((await QuoteTicket.findByPk(quote.id))?.status).toBe("pending"); diff --git a/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts b/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts index 4e80325e7..51e35bdfc 100644 --- a/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts @@ -4,9 +4,6 @@ import { EvmToken, evmTokenConfig, FiatToken, - type IbanPaymentData, - MykoboApiService, - MykoboCurrency, MykoboCustomerStatus, MykoboTransactionType, Networks, @@ -14,15 +11,16 @@ import { 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 { accountCapabilities } from "../../api/services/phases/blocks/core/accounts"; +import { getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; +import { resolveBlockFlow } from "../../api/services/phases/blocks/flows/catalog"; 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 CustomerEntity from "../../models/customerEntity.model"; +import FinancialOperation from "../../models/financialOperation.model"; import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; @@ -70,7 +68,7 @@ interface CorridorSetup { * → 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 + * kill-switch and in docs/operations-testing.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. @@ -145,8 +143,8 @@ describe("EUR onramp direct corridor (SEPA → EURC on Base via Mykobo)", () => * 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 + * resolution + deposit intent through flow registration, flow-owned + * transaction preparation, quote consumption, and a RampState row with * the identical shape. No registration logic is re-implemented — only the * thin glue is mirrored. */ @@ -161,40 +159,37 @@ describe("EUR onramp direct corridor (SEPA → EURC on Base via Mykobo)", () => 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, + await validateEphemeralAccountsFresh(ephemerals, quote); + + const metadata = getFlowMetadata(quote.metadata); + const flow = resolveBlockFlow(metadata.globals.request); + const quoteFields = quote.get({ plain: true }); + const registered = await flow.register({ + authenticatedUser: { id: userId }, + input: additionalData, ipAddress: additionalData.ipAddress, - mykoboEmail: email, - mykoboTransactionId: intent.transaction.id, - mykoboTransactionReference: intent.transaction.reference, - quote, + metadata, + quote: quoteFields, signingAccounts: normalizedSigningAccounts }); - - const ibanPaymentData: IbanPaymentData = { - bic: "", - iban: intent.instructions.iban, - receiverName: intent.instructions.bank_account_name, - reference: intent.transaction.reference + const { unsignedTxs, stateMeta } = await flow.prepareTxs({ + accounts: accountCapabilities(normalizedSigningAccounts), + destinationAddress: additionalData.destinationAddress, + metadata: registered.metadata, + quote: quoteFields, + registrationFacts: registered.registrationFacts, + userId + }); + const compatibilityState = Object.assign( + {}, + ...Object.values(registered.registrationFacts), + ...Object.values(stateMeta.blockState ?? {}) + ); + const responseArtifacts = Object.assign({}, ...Object.values(registered.responseArtifacts)) as { + ibanPaymentData: RampState["state"]["ibanPaymentData"]; }; + await quote.update({ metadata: registered.metadata as unknown as QuoteTicket["metadata"] }); const [consumed] = await QuoteTicket.update({ status: "consumed" }, { where: { id: quote.id, status: "pending" } }); expect(consumed).toBe(1); @@ -206,10 +201,11 @@ describe("EUR onramp direct corridor (SEPA → EURC on Base via Mykobo)", () => quoteId: quote.id, state: { evmEphemeralAddress: ephemerals.EVM, - ibanPaymentData, + ibanPaymentData: responseArtifacts.ibanPaymentData, substrateEphemeralAddress: ephemerals.Substrate, ...additionalData, - ...stateMeta + ...stateMeta, + ...compatibilityState } as unknown as RampState["state"], to: quote.to, type: quote.rampType, @@ -235,7 +231,10 @@ describe("EUR onramp direct corridor (SEPA → EURC on Base via Mykobo)", () => if (!persistedQuote) { throw new Error("Quote not persisted"); } - const mykoboMintRaw = BigInt(persistedQuote.metadata.mykoboMint?.outputAmountRaw ?? "0"); + const metadata = persistedQuote.metadata as unknown as { + blocks: { mykoboMint?: { mint: { outputAmountRaw?: string } } }; + }; + const mykoboMintRaw = BigInt(metadata.blocks.mykoboMint?.mint.outputAmountRaw ?? "0"); expect(mykoboMintRaw).toBeGreaterThan(0n); const rampState = await registerEurOnrampBelowKillSwitch(persistedQuote, user.id, ephemeral, destination); @@ -358,7 +357,7 @@ describe("EUR onramp direct corridor (SEPA → EURC on Base via Mykobo)", () => ); it( - "transient failure: a scripted RPC outage on the destination transfer is recoverable and the corridor still completes", + "ambiguous destination broadcast: pauses for reconciliation without paying the recipient", async () => { const setup = await setUpRegisteredRamp(); scriptHappyWorld(setup); @@ -368,16 +367,19 @@ describe("EUR onramp direct corridor (SEPA → EURC on Base via Mykobo)", () => await phaseProcessor.processRamp(setup.rampId); const final = await RampState.findByPk(setup.rampId); - expect(final?.currentPhase).toBe("complete"); + expect(final?.currentPhase).toBe("destinationTransfer"); 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.length).toBe(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); + expect(final?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); + expect(await FinancialOperation.findOne({ where: { phase: "destinationTransfer", scopeId: setup.rampId } })).toMatchObject({ + status: "unknown" + }); + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, EURC_ON_BASE, setup.destination)).toBe(0n); }, 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 index dbfa5a82e..1f37c8375 100644 --- a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { + ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, AlfredpayOfframpStatus, EvmToken, @@ -13,6 +14,7 @@ import { BaseError, ContractFunctionExecutionError, decodeFunctionData, erc20Abi import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import { parseUnits } from "viem/utils"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import FinancialOperation from "../../models/financialOperation.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; @@ -85,6 +87,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () world.evm.failNextSends = 0; world.evm.onTransaction = undefined; world.squidRouter.computeToAmount = params => params.fromAmount; + world.squidRouter.toTokenDecimals = ALFREDPAY_ERC20_DECIMALS; 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 @@ -106,6 +109,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () }); async function createQuoteViaApi(): Promise<{ id: string; inputAmount: string; outputAmount: string }> { + const squidRouteCount = world.squidRouter.requestedRoutes.length; const response = await app.request("/v1/quotes", { body: JSON.stringify({ from: Networks.Polygon, @@ -120,6 +124,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () method: "POST" }); expect(response.status).toBe(201); + expect(world.squidRouter.requestedRoutes).toHaveLength(squidRouteCount); return (await response.json()) as { id: string; inputAmount: string; outputAmount: string }; } @@ -161,7 +166,10 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () 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"); + const metadata = persistedQuote?.metadata as unknown as + | { blocks: { alfredpayOfframp?: { inputAmountRaw?: string } } } + | undefined; + const inputAmountRaw = BigInt(metadata?.blocks.alfredpayOfframp?.inputAmountRaw ?? "0"); expect(inputAmountRaw).toBeGreaterThan(0n); // The register RESPONSE withholds user-wallet txs until the ephemeral @@ -275,6 +283,25 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () return world.evm.sentTransactions.filter(tx => tx.serialized === signedTransfer).length; } + it("quotes direct Polygon USDT 1:1 without requesting a Squid route", async () => { + const quote = await createQuoteViaApi(); + const persistedQuote = await QuoteTicket.findByPk(quote.id); + const metadata = persistedQuote?.metadata as unknown as + | { + blocks: { + alfredpayOfframp?: { + bridgeInputAmountRaw?: string; + bridgeOutputAmountRaw?: string; + }; + }; + } + | undefined; + const expectedRaw = parseUnits(quote.inputAmount, ALFREDPAY_ERC20_DECIMALS).toString(); + + expect(metadata?.blocks.alfredpayOfframp?.bridgeInputAmountRaw).toBe(expectedRaw); + expect(metadata?.blocks.alfredpayOfframp?.bridgeOutputAmountRaw).toBe(expectedRaw); + }); + it( "happy path: processes the full Alfredpay offramp phase sequence to complete", async () => { @@ -302,7 +329,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () ); it( - "transient failure: an RPC outage on the ephemeral gas funding is recoverable and the ramp still completes", + "ambiguous funding failure: an RPC outage pauses the ramp for reconciliation", async () => { const setup = await setUpRegisteredRamp(); scriptHappyWorld(setup); @@ -327,19 +354,21 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () 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?.currentPhase).toBe("fundEphemeral"); expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); - // The outage surfaced as exactly one recoverable fundEphemeral error... + // The transport error is recoverable at the phase layer, but the + // financial outcome is unknown, so retries halt instead of risking a + // duplicate funding transfer. 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); + expect(final?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); + expect(await FinancialOperation.findOne({ where: { phase: "fundEphemeral", scopeId: setup.rampId } })).toMatchObject({ + status: "unknown" + }); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, depositAddress)).toBe(0n); }, 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 index 188f2f752..804823077 100644 --- a/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts @@ -12,9 +12,13 @@ import { } 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 { getEvmFundingAccount } from "../../api/services/phases/blocks/core/evm-funding"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import { getBlockMetadata } from "../../api/services/phases/blocks/core/metadata"; +import { AlfredpayMintContext } from "../../api/services/phases/blocks/phases/alfredpay-mint/simulation"; +import { SquidRouterSwapContext } from "../../api/services/phases/blocks/phases/squid-router-swap/simulation"; import QuoteTicket from "../../models/quoteTicket.model"; +import FinancialOperation from "../../models/financialOperation.model"; import RampState from "../../models/rampState.model"; import Subsidy, { SubsidyToken } from "../../models/subsidy.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; @@ -206,8 +210,11 @@ describe("MXN onramp cross-chain corridor (spei → Polygon mint → USDT on Arb 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"); + if (!persistedQuote) { + throw new Error("Quote not found after registration"); + } + const mintAmountRaw = BigInt(getBlockMetadata(persistedQuote.metadata, AlfredpayMintContext).outputAmountRaw); + const bridgedAmountRaw = BigInt(getBlockMetadata(persistedQuote.metadata, SquidRouterSwapContext).outputAmountRaw); expect(mintAmountRaw).toBeGreaterThan(0n); expect(bridgedAmountRaw).toBeGreaterThan(0n); @@ -351,7 +358,7 @@ describe("MXN onramp cross-chain corridor (spei → Polygon mint → USDT on Arb ); it( - "transient failure: an RPC outage on the Arbitrum destination transfer is recoverable and the corridor still completes", + "ambiguous destination broadcast: pauses the Arbitrum payout for reconciliation", async () => { const setup = await setUpRegisteredRamp(); scriptHappyWorld(setup); @@ -369,16 +376,19 @@ describe("MXN onramp cross-chain corridor (spei → Polygon mint → USDT on Arb await phaseProcessor.processRamp(setup.rampId); const final = await RampState.findByPk(setup.rampId); - expect(final?.currentPhase).toBe("complete"); + expect(final?.currentPhase).toBe("destinationTransfer"); 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.length).toBe(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); + expect(final?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); + expect(await FinancialOperation.findOne({ where: { phase: "destinationTransfer", scopeId: setup.rampId } })).toMatchObject({ + status: "unknown" + }); + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Arbitrum, USDT_ON_ARBITRUM, setup.destination)).toBe(0n); }, 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 index c9420cee7..c571d8752 100644 --- a/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts @@ -12,6 +12,7 @@ import { 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 FinancialOperation from "../../models/financialOperation.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; @@ -160,7 +161,10 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { 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 metadata = persistedQuote?.metadata as unknown as + | { blocks: { alfredpayMint?: { outputAmountRaw?: string } } } + | undefined; + const mintAmountRaw = BigInt(metadata?.blocks.alfredpayMint?.outputAmountRaw ?? "0"); expect(mintAmountRaw).toBeGreaterThan(0n); const amountRaw = parseUnits(quote.outputAmount, ALFREDPAY_ERC20_DECIMALS); @@ -292,7 +296,7 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { ); it( - "transient failure: retries a failed destinationTransfer broadcast (recoverable) and still completes", + "ambiguous destination broadcast: pauses for reconciliation without paying the recipient", async () => { const setup = await setUpRegisteredRamp(); scriptHappyWorld(setup); @@ -302,16 +306,19 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { await phaseProcessor.processRamp(setup.rampId); const final = await RampState.findByPk(setup.rampId); - expect(final?.currentPhase).toBe("complete"); + expect(final?.currentPhase).toBe("destinationTransfer"); 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.length).toBe(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); + expect(final?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); + expect(await FinancialOperation.findOne({ where: { phase: "destinationTransfer", scopeId: setup.rampId } })).toMatchObject({ + status: "unknown" + }); + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.destination)).toBe(0n); }, 30000 ); diff --git a/apps/api/src/tests/deployed-quotes.e2e.test.ts b/apps/api/src/tests/deployed-quotes.e2e.test.ts new file mode 100644 index 000000000..ac1d0a149 --- /dev/null +++ b/apps/api/src/tests/deployed-quotes.e2e.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; + +const apiBaseUrls = (process.env.VORTEX_QUOTE_SMOKE_URLS ?? "") + .split(",") + .map((value) => value.trim().replace(/\/$/, "")) + .filter(Boolean); + +const quoteCases = [ + { + direction: "BUY", + request: { + from: "pix", + inputAmount: "100", + inputCurrency: "BRL", + network: "polygon", + outputCurrency: "USDT", + paymentMethod: "pix", + rampType: "BUY", + to: "polygon", + }, + }, + { + direction: "SELL", + request: { + from: "polygon", + inputAmount: "20", + inputCurrency: "USDT", + network: "polygon", + outputCurrency: "BRL", + paymentMethod: "pix", + rampType: "SELL", + to: "pix", + }, + }, +] as const; + +interface QuoteResponse { + expiresAt: string; + from: string; + id: string; + inputAmount: string; + inputCurrency: string; + network: string; + outputAmount: string; + outputCurrency: string; + paymentMethod: string; + rampType: string; + to: string; +} + +describe.skipIf(apiBaseUrls.length === 0)("deployed quote API", () => { + test("has at least one deployment configured", () => { + expect(apiBaseUrls.length).toBeGreaterThan(0); + }); + + for (const apiBaseUrl of apiBaseUrls) { + for (const quoteCase of quoteCases) { + test(`${apiBaseUrl} serves a cross-chain ${quoteCase.direction} quote`, async () => { + const response = await fetch(`${apiBaseUrl}/v1/quotes`, { + body: JSON.stringify(quoteCase.request), + headers: { "Content-Type": "application/json" }, + method: "POST", + signal: AbortSignal.timeout(25_000), + }); + const responseText = await response.text(); + + if (response.status !== 201) { + throw new Error( + `${quoteCase.direction} quote failed on ${apiBaseUrl}: HTTP ${response.status} ${responseText}`, + ); + } + + const quote = JSON.parse(responseText) as QuoteResponse; + expect(quote).toMatchObject({ + from: quoteCase.request.from, + inputCurrency: quoteCase.request.inputCurrency, + network: quoteCase.request.network, + outputCurrency: quoteCase.request.outputCurrency, + paymentMethod: quoteCase.request.paymentMethod, + rampType: quoteCase.request.rampType, + to: quoteCase.request.to, + }); + expect(quote.id.length).toBeGreaterThan(0); + expect(Number(quote.inputAmount)).toBe( + Number(quoteCase.request.inputAmount), + ); + expect(Number(quote.outputAmount)).toBeGreaterThan(0); + expect(Date.parse(quote.expiresAt)).toBeGreaterThan(Date.now()); + }, 30_000); + } + } +}); diff --git a/apps/api/src/tests/fee-immutability.invariants.test.ts b/apps/api/src/tests/fee-immutability.invariants.test.ts index 7e2f1456f..8cfe05341 100644 --- a/apps/api/src/tests/fee-immutability.invariants.test.ts +++ b/apps/api/src/tests/fee-immutability.invariants.test.ts @@ -119,8 +119,11 @@ describe("fee immutability invariants (BRL onramp)", () => { 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(); + const creationMetadata = persistedAtCreation?.metadata as unknown as + | { globals: { fees?: unknown } } + | undefined; + const feesAtCreation = JSON.stringify(creationMetadata?.globals.fees); + expect(creationMetadata?.globals.fees).toBeDefined(); // Registration with fee fields smuggled into additionalData must succeed // while leaving the persisted fee structure byte-identical. @@ -133,7 +136,10 @@ describe("fee immutability invariants (BRL onramp)", () => { 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 registeredMetadata = persistedAfterRegister?.metadata as unknown as + | { globals: { fees?: unknown } } + | undefined; + expect(JSON.stringify(registeredMetadata?.globals.fees)).toBe(feesAtCreation); const statusResponse = await app.request(`/v1/ramp/${ramp.id}`, { headers: { Authorization: `Bearer ${testUserToken(user.id)}` } @@ -147,6 +153,9 @@ describe("fee immutability invariants (BRL onramp)", () => { // 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); + const statusMetadata = persistedAfterStatus?.metadata as unknown as + | { globals: { fees?: unknown } } + | undefined; + expect(JSON.stringify(statusMetadata?.globals.fees)).toBe(feesAtCreation); }); }); diff --git a/apps/api/src/tests/harness.smoke.test.ts b/apps/api/src/tests/harness.smoke.test.ts index 857baec01..8f1925d7d 100644 --- a/apps/api/src/tests/harness.smoke.test.ts +++ b/apps/api/src/tests/harness.smoke.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { assertApiCredentialSchemaReady } from "../api/services/apiCredential.service"; 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"; @@ -25,6 +26,10 @@ describe("test harness smoke test", () => { expect(response.status).toBe(200); }); + it("has the complete api_credentials schema and no active legacy keys", async () => { + await expect(assertApiCredentialSchemaReady()).resolves.toBeUndefined(); + }); + it("persists factory-built entities against the migrated schema", async () => { const user = await createTestUser(); const partner = await createTestPartner(); @@ -33,7 +38,8 @@ describe("test harness smoke test", () => { const ramp = await createTestRampState({ quoteId: quote.id, userId: user.id }); expect(user.id).toBeTruthy(); - expect(record.keyPrefix).toBe(plaintextKey.slice(0, 8)); + // Secret keys store the 16-char lookup prefix (non-secret key identifier). + expect(record.secretKeyPrefix).toBe(plaintextKey.slice(0, 16)); expect(quote.status).toBe("pending"); expect(ramp.currentPhase).toBe("initial"); }); diff --git a/apps/api/src/tests/http-surface.invariants.test.ts b/apps/api/src/tests/http-surface.invariants.test.ts index 8754b2c50..812c8dba0 100644 --- a/apps/api/src/tests/http-surface.invariants.test.ts +++ b/apps/api/src/tests/http-surface.invariants.test.ts @@ -123,10 +123,12 @@ describe("HTTP surface: auth flow, webhooks, history, public routes", () => { }); describe("webhooks", () => { - async function apiKeyHeaders(): Promise> { + // Webhooks are owner-scoped: a user-scoped key can only target quotes owned by + // that user, so the caller needs the user id to create ownable quotes. + async function apiKeyPrincipal(): Promise<{ headers: Record; userId: string }> { const user = await createTestUser(); const { plaintextKey } = await createTestApiKey({ userId: user.id }); - return { "x-api-key": plaintextKey }; + return { headers: { "x-api-key": plaintextKey }, userId: user.id }; } it("registration requires an API key", async () => { @@ -138,9 +140,9 @@ describe("HTTP surface: auth flow, webhooks, history, public routes", () => { 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(); + it("registers a webhook for an owned quote and deletes it exactly once", async () => { + const { headers, userId } = await apiKeyPrincipal(); + const quote = await createTestQuote({ userId }); const created = await requestJson("/v1/webhook", { body: { quoteId: quote.id, url: "https://partner.example/hook" }, @@ -161,9 +163,50 @@ describe("HTTP surface: auth flow, webhooks, history, public routes", () => { expect(again.status).toBe(404); }); + it("scopes registration and deletion to the owning principal", async () => { + const owner = await apiKeyPrincipal(); + const stranger = await apiKeyPrincipal(); + const quote = await createTestQuote({ userId: owner.userId }); + + // A foreign quote is indistinguishable from a nonexistent one. + const foreignRegistration = await requestJson("/v1/webhook", { + body: { quoteId: quote.id, url: "https://partner.example/hook" }, + headers: stranger.headers, + method: "POST" + }); + expect(foreignRegistration.status).toBe(404); + + // An anonymous quote has no owner, so nobody can subscribe to it. + const anonymousQuote = await createTestQuote(); + const anonymousRegistration = await requestJson("/v1/webhook", { + body: { quoteId: anonymousQuote.id, url: "https://partner.example/hook" }, + headers: owner.headers, + method: "POST" + }); + expect(anonymousRegistration.status).toBe(404); + + // Deleting someone else's webhook returns the same 404 as a nonexistent one. + const created = await requestJson("/v1/webhook", { + body: { quoteId: quote.id, url: "https://partner.example/hook" }, + headers: owner.headers, + method: "POST" + }); + expect(created.status).toBe(201); + const foreignDeletion = await requestJson(`/v1/webhook/${created.body.id}`, { + headers: stranger.headers, + method: "DELETE" + }); + expect(foreignDeletion.status).toBe(404); + const ownDeletion = await requestJson(`/v1/webhook/${created.body.id}`, { + headers: owner.headers, + method: "DELETE" + }); + expect(ownDeletion.status).toBe(200); + }); + it("rejects non-HTTPS URLs, unknown quotes, and registrations without a quote or session", async () => { - const headers = await apiKeyHeaders(); - const quote = await createTestQuote(); + const { headers, userId } = await apiKeyPrincipal(); + const quote = await createTestQuote({ userId }); const insecure = await requestJson("/v1/webhook", { body: { quoteId: quote.id, url: "http://partner.example/hook" }, @@ -281,12 +324,13 @@ describe("HTTP surface: auth flow, webhooks, history, public routes", () => { expect(anonymous.status).toBe(401); }); - it("rejects a partner-only secret key instead of falling back to partner-wide history", async () => { + it("scopes a partner-managed credential to its profile history", async () => { const partner = await createTestPartner(); const { plaintextKey } = await createTestApiKey({ partnerName: partner.name }); const response = await requestJson("/v1/ramp/history", { headers: { "x-api-key": plaintextKey } }); - expect(response.status).toBe(403); + expect(response.status).toBe(200); + expect(response.body).toEqual({ totalCount: 0, transactions: [] }); }); it("validates history pagination", async () => { diff --git a/apps/api/src/tests/quote-consumption.invariants.test.ts b/apps/api/src/tests/quote-consumption.invariants.test.ts index 653e5ff6f..3d9aad3ba 100644 --- a/apps/api/src/tests/quote-consumption.invariants.test.ts +++ b/apps/api/src/tests/quote-consumption.invariants.test.ts @@ -134,8 +134,11 @@ describe("quote consumption invariants (BRL onramp)", () => { const persisted = await QuoteTicket.findByPk(quote.id); expect(persisted?.status).toBe("pending"); - expect(persisted?.metadata.fees?.usd).toBeDefined(); - expect(persisted?.metadata.fees?.displayFiat).toBeDefined(); + const metadata = persisted?.metadata as unknown as + | { globals: { fees: { displayFiat?: unknown; usd?: unknown } } } + | undefined; + expect(metadata?.globals.fees.usd).toBeDefined(); + expect(metadata?.globals.fees.displayFiat).toBeDefined(); }); it("registers a ramp and consumes the quote exactly once", async () => { @@ -280,6 +283,27 @@ describe("quote consumption invariants (BRL onramp)", () => { expect(new Date(historyRamp?.expiresAt ?? 0).getTime()).toBeLessThan(Date.now()); }); + it("rejects updating an expired ramp before persisting signatures or starting its flow", async () => { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const registerResponse = await registerViaApi(quote.id, user.id, ephemeral.address); + expect(registerResponse.status).toBe(201); + const ramp = (await registerResponse.json()) as { id: string }; + const presignedTx = await presignDestinationTransfer(ephemeral, ramp.id); + + await RampState.update({ createdAt: new Date(Date.now() - 16 * 60 * 1000) }, { where: { id: ramp.id } }); + + const updateResponse = await updateViaApi(ramp.id, user.id, [presignedTx]); + + expect(updateResponse.status).toBe(400); + expect(await updateResponse.text()).toContain("Maximum time window to start process exceeded"); + const persistedRamp = await RampState.findByPk(ramp.id); + expect(persistedRamp?.currentPhase).toBe("initial"); + expect(persistedRamp?.presignedTxs).toBeNull(); + }); + // 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'). diff --git a/apps/api/src/tests/quote-pricing.golden.test.ts b/apps/api/src/tests/quote-pricing.golden.test.ts index 55bcb4fc3..ea18baf8c 100644 --- a/apps/api/src/tests/quote-pricing.golden.test.ts +++ b/apps/api/src/tests/quote-pricing.golden.test.ts @@ -24,12 +24,12 @@ describe("quote pricing goldens (fixed input matrix)", () => { 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. + // Deterministic Nabla quotes: 0.18 USDC per BRLA on BUY and the pinned + // oracle rate of 5 BRLA per USDC on SELL. world.evm.onReadContract = (_network, params) => { if (params.functionName === "quoteSwapExactTokensForTokens") { const amountIn = params.args?.[0] as bigint; - return (amountIn * 18n) / 100n / 10n ** 12n; + return amountIn >= 10n ** 18n ? (amountIn * 18n) / 100n / 10n ** 12n : amountIn * 5n * 10n ** 12n; } return undefined; }; @@ -134,9 +134,6 @@ describe("quote pricing goldens (fixed input matrix)", () => { expected: { anchorFeeFiat: "0.1", anchorFeeUsd: "0.02", - discountCurrency: "BRL", - discountFiat: "10.09", - discountUsd: "2.018000", feeCurrency: "BRL", from: "pix", inputAmount: "100.00", @@ -144,7 +141,7 @@ describe("quote pricing goldens (fixed input matrix)", () => { network: "base", networkFeeFiat: "0", networkFeeUsd: "0", - outputAmount: "20.00", + outputAmount: "17.982", outputCurrency: "USDC", partnerFeeFiat: "0", partnerFeeUsd: "0", @@ -158,7 +155,7 @@ describe("quote pricing goldens (fixed input matrix)", () => { vortexFeeFiat: "0", vortexFeeUsd: "0" }, - name: "BUY 100 BRL → USDC on Base (Nabla swap at 0.18, subsidy applied)", + name: "BUY 100 BRL → USDC on Base (Nabla swap at 0.18, zero-discount partner)", request: { from: "pix", inputAmount: "100", diff --git a/apps/api/src/tests/recipients.integration.test.ts b/apps/api/src/tests/recipients.integration.test.ts index 8b0985e00..807ab4840 100644 --- a/apps/api/src/tests/recipients.integration.test.ts +++ b/apps/api/src/tests/recipients.integration.test.ts @@ -867,6 +867,20 @@ describe("invite discounts (discount_manager)", () => { expect((await createInvite(sender.token, { discounts: { sellBps: 300 } })).status).toBe(201); }); + it("enforces an operator ceiling below the immutable 300 bps hard cap", async () => { + const sender = await createApprovedSender("sender@example.com"); + await grantDiscountManager(sender.user.id); + const originalLimit = config.recipients.inviteMaxDiscountBps; + config.recipients.inviteMaxDiscountBps = 125; + + try { + expect((await createInvite(sender.token, { discounts: { buyBps: 125 } })).status).toBe(201); + expect((await createInvite(sender.token, { discounts: { buyBps: 126 } })).status).toBe(400); + } finally { + config.recipients.inviteMaxDiscountBps = originalLimit; + } + }); + it("treats zero bps as no discount and requires no role for it", async () => { const sender = await createApprovedSender("sender@example.com"); const { status, body } = await createInvite(sender.token, { discounts: { buyBps: 0, sellBps: 0 } }); diff --git a/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts index 49f4b0954..62f1ee2c5 100644 --- a/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts +++ b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts @@ -176,6 +176,9 @@ describe("SDK ↔ API contract (Alfredpay offramps, USDT on Polygon → bank pay world.evm.failNextSends = 0; world.evm.onTransaction = undefined; world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + // The quote simulator asks Squid for the USDT settlement leg even on the + // direct Polygon corridor. Squid therefore reports 6-decimal output. + world.squidRouter.toTokenDecimals = ALFREDPAY_ERC20_DECIMALS; // 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(); @@ -245,7 +248,10 @@ describe("SDK ↔ API contract (Alfredpay offramps, USDT on Polygon → bank pay 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"); + const metadata = quote?.metadata as unknown as + | { blocks: { alfredpayOfframp?: { inputAmountRaw?: string } } } + | undefined; + const inputAmountRaw = BigInt(metadata?.blocks.alfredpayOfframp?.inputAmountRaw ?? "0"); expect(inputAmountRaw).toBeGreaterThan(0n); world.evm.setNativeBalance(Networks.Polygon, ephemeralAddress, parseUnits("2", 18)); diff --git a/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts index 060b288d5..c86b9d836 100644 --- a/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts +++ b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts @@ -347,7 +347,10 @@ describe("SDK ↔ API contract (Alfredpay onramps, fiat → USDT on Polygon)", ( expect(order.depositAddress.toLowerCase()).toBe(ephemeralAddress.toLowerCase()); const persistedQuote = await QuoteTicket.findByPk(quote.id); - const mintAmountRaw = BigInt(persistedQuote?.metadata.alfredpayMint?.outputAmountRaw ?? "0"); + const metadata = persistedQuote?.metadata as unknown as + | { blocks: { alfredpayMint?: { outputAmountRaw?: string } } } + | undefined; + const mintAmountRaw = BigInt(metadata?.blocks.alfredpayMint?.outputAmountRaw ?? "0"); expect(mintAmountRaw).toBeGreaterThan(0n); scriptHappyWorld(ephemeralAddress, mintAmountRaw); diff --git a/apps/api/src/tests/sdk-contract.offramp.test.ts b/apps/api/src/tests/sdk-contract.offramp.test.ts index c7fc16384..37570abab 100644 --- a/apps/api/src/tests/sdk-contract.offramp.test.ts +++ b/apps/api/src/tests/sdk-contract.offramp.test.ts @@ -13,7 +13,7 @@ import { RampDirection, type UnsignedTx } from "@vortexfi/shared"; -import { parseUnits } from "viem"; +import { decodeFunctionData, erc20Abi, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { VortexSdk } from "../../../../packages/sdk/src"; import QuoteTicket from "../models/quoteTicket.model"; @@ -168,15 +168,25 @@ describe("SDK ↔ API contract (BRL offramp, USDC on Polygon → pix)", () => { } /** 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 }> { + async function scriptHappyWorld(rampId: string, quoteId: string): Promise<{ payoutTransferRaw: 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"); + const metadata = quote?.metadata as unknown as + | { + blocks: { + aveniaOfframpPayout?: { transferAmountRaw?: string }; + nablaSwap?: { inputAmountForSwapRaw?: string; outputAmountRaw?: string }; + }; + } + | undefined; + const swapInputRaw = BigInt(metadata?.blocks.nablaSwap?.inputAmountForSwapRaw ?? "0"); + const swapOutputRaw = BigInt(metadata?.blocks.nablaSwap?.outputAmountRaw ?? "0"); + const payoutTransferRaw = BigInt(metadata?.blocks.aveniaOfframpPayout?.transferAmountRaw ?? "0"); expect(swapInputRaw).toBeGreaterThan(0n); expect(swapOutputRaw).toBeGreaterThan(0n); + expect(payoutTransferRaw).toBeGreaterThanOrEqual(swapOutputRaw); 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}`; @@ -190,11 +200,19 @@ describe("SDK ↔ API contract (BRL offramp, USDC on Polygon → pix)", () => { world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, ephemeralAddress, swapOutputRaw); return; } + if (!tx.serialized && tx.to?.toLowerCase() === BRLA_ON_BASE.toLowerCase() && tx.data) { + const decoded = decodeFunctionData({ abi: erc20Abi, data: tx.data as `0x${string}` }); + if (decoded.functionName === "transfer" && String(decoded.args[0]).toLowerCase() === ephemeralAddress.toLowerCase()) { + const balance = world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, ephemeralAddress); + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, ephemeralAddress, balance + BigInt(decoded.args[1])); + } + return; + } if (tx.serialized === signedPayout) { - world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet, swapOutputRaw); + world.evm.setErc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet, payoutTransferRaw); } }; - return { swapOutputRaw }; + return { payoutTransferRaw }; } /** Polls getRampStatus (itself part of the contract) until the ramp completes. */ @@ -268,7 +286,7 @@ describe("SDK ↔ API contract (BRL offramp, USDC on Polygon → pix)", () => { expect(withHashes?.state.squidRouterApproveHash).toBeTruthy(); expect(withHashes?.state.squidRouterSwapHash).toBeTruthy(); - const { swapOutputRaw } = await scriptHappyWorld(rampProcess.id, quote.id); + const { payoutTransferRaw } = await scriptHappyWorld(rampProcess.id, quote.id); const started = await sdk.startRamp(rampProcess.id); expect(started.id).toBe(rampProcess.id); @@ -279,7 +297,7 @@ describe("SDK ↔ API contract (BRL offramp, USDC on Polygon → pix)", () => { // 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.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(payoutTransferRaw); expect(world.brla.pixOutputTickets.length).toBe(1); }, 30000 diff --git a/apps/api/src/tests/sdk-contract.test.ts b/apps/api/src/tests/sdk-contract.test.ts index 88a4525da..7d9850a1f 100644 --- a/apps/api/src/tests/sdk-contract.test.ts +++ b/apps/api/src/tests/sdk-contract.test.ts @@ -290,7 +290,7 @@ describe("SDK ↔ API contract (BRL onramp, pix → BRLA on Base)", () => { const destination = privateKeyToAccount(generatePrivateKey()).address; await expect(anonymous.registerRamp(quote, { destinationAddress: destination })).rejects.toThrow( - /requires a user-linked secretKey/ + /requires a secretKey .* that resolves to a Vortex user/ ); }, 30000 diff --git a/apps/dashboard/CLAUDE.md b/apps/dashboard/CLAUDE.md new file mode 100644 index 000000000..245fa8b62 --- /dev/null +++ b/apps/dashboard/CLAUDE.md @@ -0,0 +1,34 @@ +# apps/dashboard — authenticated customer dashboard + +React 19 + Vite account surface for OTP authentication, customer-entity selection, +provider onboarding, recipients, notifications, transaction history, and self-ramp flows. +Read [`docs/product-dashboard.md`](../../docs/product-dashboard.md) before changing product +scope or an acknowledged gap. + +## Architecture + +- TanStack Router and Query for routing and server state. +- XState v5 for multi-step onboarding and transfer flows. +- `@vortexfi/kyc` for provider KYC/KYB machines; do not fork those machines locally. +- `@vortexfi/shared` for wire contracts, signing helpers, tokens, and networks. +- Zustand for local client state; React Hook Form + Zod for forms. + +## Commands (from `apps/dashboard/`) + +```bash +bun dev # Vite on port 5174 +bun test # Bun unit tests under src/ +bun test:e2e # Playwright +bun typecheck +bun run build +``` + +Lint from the repository root with `bun lint:fix`. After changing +`packages/shared`, run `bun build:shared` before testing the dashboard. + +## Documentation + +Follow [`docs/README.md`](../../docs/README.md). Update the dashboard product spec instead +of adding plans under this app. Identity architecture belongs in +`docs/architecture-identity-model.md`; tests in `docs/operations-testing.md`; security +behavior in `docs/security-spec/`. diff --git a/apps/dashboard/docs/full-scope-mock-build-plan.md b/apps/dashboard/docs/full-scope-mock-build-plan.md deleted file mode 100644 index 437feb1f4..000000000 --- a/apps/dashboard/docs/full-scope-mock-build-plan.md +++ /dev/null @@ -1,143 +0,0 @@ -# Vortex Dashboard — Full-Scope Mock Build Plan - -Plan to bring the mocked dashboard app in line with the full product brief -(sender onboarding → recipient KYC/KYB invite → wallet-to-fiat payout). - -This app is a **standalone, fully-mocked** React app (no API). All flows are -simulated client-side via Zustand stores + XState machines + timers. - -Stack: React 19, TanStack Router, XState 5, Zustand (persist), shadcn/ui, -Tailwind v4, react-hook-form + zod, sonner. - ---- - -## Workstream 1 — Domain & data model - -`src/domain/` - -1. **Routing method.** Add `OnboardingRoute = "headless" | "google_form" | "redirect"`. - Add `routeFor(corridorId, kind)` helper implementing the §2 matrix: - - `US` → `redirect` - - `EU` + `kyb` → `google_form` - - else → `headless` -2. **Make all 6 corridors onboardable.** Drop the `coming_soon` gate for - onboarding (or repurpose it). Remove the `supportsKyb`-only-for-BR gate; - `kind = accountType === "company" ? "kyb" : "kyc"` for every country. -3. **Recipient model (see Decision Q1).** Target shape per brief: - `{ id, accountId, email, recipientType: AccountType, corridorId, - amount, payoutCurrency, bankDetails: { method, value... }, status, - createdAt }`. -4. **Recipient status model.** Expand `RecipientStatus` to - `"invite_sent" | "pending" | "approved" | "rejected"`. -5. **Transaction model.** Rework to payout-centric per §8: - `{ id, accountId, recipientId, payinWallet, payinNetwork, amountIn, - amountInToken, fiatPayoutAmount, payoutCurrency, corridorId, - status, createdAt }`. - Add `TransactionStatus = "awaiting_payin" | "processing" | "completed" | "failed"`. -6. Update `STATUS_META` / `TX_STATUS_META` for the new statuses. - -## Workstream 2 — Sender onboarding (account type + routing) - -`routes/_app/overview.tsx` → rename to **Onboarding**; `components/onboarding/` - -1. **Account type selection.** On first login (empty account) prompt - Individual vs Company before/with country selection. Persist on - `SenderAccount.type`. Allow change while no onboardings exist. -2. **Generic headless machines.** Replace the 3 bespoke machines with **2 - config-driven machines** (`headlessKyc`, `headlessKyb`) parameterized by a - per-country step config map (`onboardingSteps[corridorId][kind]`). Covers - BR/EU/CO/MX/AR KYC and BR/CO/MX/AR KYB. -3. **Google Form route** (EU company KYB). `OnboardingWizard` branches on - `routeFor`: render a card with an "Open Google Form" external link + - "I've submitted the form" → status `pending` → (timer) `approved`. -4. **Redirect route** (USA). Render a "You'll be redirected to our partner" - screen → button simulates redirect (mock partner screen / new tab) → - returns `pending` → (timer) `approved`. -5. `CorridorCard` actions already cover not_started/pending/in_review/approved/ - rejected — keep; wire the three route branches into the action handler. -6. `AddCorridorDropdown` — list all 6, no "Soon" gating. - -## Workstream 3 — Recipients - -`routes/_app/recipients.tsx`; `components/recipients/` - -1. **Rich invite form** (Decision Q1) — fields: email, recipient type - (individual/company), country, amount, payout currency (derived from - country, editable if needed), bank payout details (method-specific input: - PIX key / IBAN / CLABE / ACH routing+account, driven by - `corridor.recipientMethod`). -2. **4-status model + actions** per §6: - - `invite_sent` → Resend invite · View - - `pending` → View (transfer blocked) - - `approved` → Create transfer - - `rejected` → Retry · View (transfer blocked) -3. **Recipient onboarding simulation** (Decision Q2) — route the invited - recipient through widget/Google-Form/redirect per the same matrix, ending - in pending → approved/rejected. -4. `RecipientsTable` — columns: Recipient (email), Type, Country, Amount, - Payout currency, Status, Added. Compliance status only (no payment status). - -## Workstream 4 — New transfer (Privy payin) - -`routes/_app/transfer.tsx`; `components/transfer/TransferForm.tsx` - -1. **Recipient-driven.** Select an **approved** recipient (only approved are - selectable — blocking rule §7; others shown disabled with reason). -2. Show read-only **amount + bank payout details** captured at recipient - creation (no amount input here). -3. **Privy wallet payin mock.** "Create / use Vortex wallet" → show a - generated deposit **address** + network + payin instructions (static mock - address; no real Privy). -4. "I've sent the payin" → create transaction `awaiting_payin` → - (timer) `processing` → `completed`; navigate to Transactions. - -## Workstream 5 — Transactions - -`routes/_app/transactions.tsx`; `components/transactions/TransactionsTable.tsx` - -1. Drop the onramp/offramp tabs (brief transactions are payout-centric). -2. Columns per §8: Created at · Recipient · Payin wallet (shortened addr) · - Amount in · Fiat payout amount · Country/currency · Status. -3. Status badges: Awaiting payin · Processing · Completed · Failed. - -## Workstream 6 — Settings & nav - -1. Sidebar/nav: label first tab **Onboarding**. -2. Settings — keep read-only profile + accounts; add basic notification - toggle stub if cheap (per §3 "notifications, basic workspace settings"). - -## Workstream 7 — Seed data refresh - -`src/domain/seed.ts` - -- Refresh seed accounts to include an Individual example and multi-country - onboarding (EU + BR + MX) in mixed statuses. -- Seed recipients with the rich shape across statuses (invite_sent/pending/ - approved/rejected). -- Seed transactions with the new payout-centric shape across all 4 statuses. -- Bump `dashboard.store` persist version (→ v4) so the new shapes migrate. - ---- - -## Decisions (locked) - -- **Q1 — Recipient form richness → RICH FORM (follow brief).** Sender captures - email, recipient type, country, amount, payout currency, and method-specific - bank details. Reverts the Jun-24 email-only simplification. -- **Q2 — Recipient-side onboarding mock → TIMER AUTO-ADVANCE.** No recipient- - facing screens; status advances on a timer - (`invite_sent → pending → approved`), matching the existing pattern. No - `/invite` route built. -- **Q3 — Country scope → ALL 6 (BR, EU, CO, MX, AR, US).** Full §2 matrix: - headless for BR/EU/CO/MX/AR KYC + BR/CO/MX/AR KYB, Google Form for EU company - KYB, redirect for US. `coming_soon` no longer gates onboarding. - -## Suggested build order - -1. WS1 domain types + seed scaffolding (unblocks everything). -2. WS2 onboarding (account type + routing matrix + generic machines). -3. WS3 recipients (rich form + statuses + sim). -4. WS4 transfer (Privy payin). -5. WS5 transactions. -6. WS6 nav/settings polish. -7. Lint, typecheck, visual pass per `figma-design-system.md`. diff --git a/apps/dashboard/docs/registration-and-country-selection-plan.md b/apps/dashboard/docs/registration-and-country-selection-plan.md deleted file mode 100644 index 235a11be9..000000000 --- a/apps/dashboard/docs/registration-and-country-selection-plan.md +++ /dev/null @@ -1,207 +0,0 @@ -# Plan — Registration + Country-of-Interest Selection (Vortex Dashboard) - -> Status: **PLAN ONLY — not implemented.** Scope addition to the existing mocked -> `apps/dashboard`. This document is the spec to implement later. - -## 1. Goal / User story - -> As a new Vortex user, I want to **register for the service** and **choose which -> countries/corridors I'm interested in**. The dashboard then shows only those -> corridors for verification, and I can **add the remaining corridors later from a -> dropdown**. - -This builds on the existing dashboard (fake login, account switcher with 2 seeded -accounts, Brazil/Europe corridor cards, XState KYB/KYC wizards, recipients gated by -approval, notifications). - -## 2. Decisions locked during grilling - -| Decision | Choice | -|---|---| -| Entry routes | Two dedicated routes: **`/register`** and **`/login`** (cross-linked). | -| What register produces | Creates a **new sender account**, added to the switcher and made active. The 2 seeded demo accounts **remain**. | -| Auth UX | **Mirror the Vortex Widget**: email + Terms checkbox → OTP (6-digit) → authenticated. Same for KYB/KYC (already mirrored). | -| Corridor catalog | **Brazil + Europe are the only working corridors.** Alfredpay corridors (Mexico, Colombia, USA, Argentina) appear as **"Coming soon"** (selectable for interest, locked in dashboard). | -| Country selection | Chosen during `/register`; dashboard shows only selected corridors; the rest are addable from an **"Add country" dropdown**. | -| State management | XState v5 for the auth flow (consistent with the widget and existing dashboard wizards); Zustand for stored data. | - -## 3. What "mirror the Vortex Widget" means (reference) - -Source flow in `apps/frontend` (the widget), to replicate **as a mock** (no real -Supabase / no real API — accept any email, any 6-digit code): - -``` -EnterEmail [AuthEmailStep] email + Terms & Conditions checkbox → ENTER_EMAIL - → CheckingEmail (mock: always proceed) - → RequestingOTP (mock: pretend to send code) - → EnterOTP [AuthOTPStep] 6-digit InputOTP, auto-submits on 6th digit → VERIFY_OTP - → VerifyingOTP (mock: any code accepted) - → authenticated (store mock token in localStorage) -``` - -Widget reference files (for UX parity only — do **not** import from the frontend app): -- `apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx` (email + T&C) -- `apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx` (6-digit `InputOTP`, auto-submit, "we sent a code to ") -- `apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx` (region `DropdownSelector`) -- `apps/frontend/src/machines/ramp.machine.ts` (auth states embedded), `src/machines/actors/auth.actor.ts` -- Provider routing: `src/machines/kyc.states.ts` (BRL→Avenia, EURC→Mykobo, ARS/USD/MXN/COP→Alfredpay) - -Mock parity notes: -- Email step requires checking the **Terms & Conditions** box before continuing. -- OTP step uses a **6-digit numeric `InputOTP`** that **auto-submits** when full; shows - the target email; offers "Change email" and "Resend code" (both no-op/mock). -- Tokens stored in `localStorage` (reuse the existing persisted auth store). - -## 4. Corridor catalog change - -Today corridors come from a fixed `CORRIDOR_LIST` of 2 (BR, EU). Expand the **catalog** -to 6, matching the real `FiatToken` set, with an availability flag. - -| Corridor | Country | Currency | Provider | Availability | -|---|---|---|---|---| -| `BR` | Brazil | BRL | Avenia | **live** | -| `EU` | Europe | EURC | Mykobo | **live** | -| `MX` | Mexico | MXN | Alfredpay | coming_soon | -| `CO` | Colombia | COP | Alfredpay | coming_soon | -| `US` | USA | USD | Alfredpay | coming_soon | -| `AR` | Argentina | ARS | Alfredpay | coming_soon | - -- Add `availability: "live" | "coming_soon"` to the `Corridor` type. -- Only `live` corridors run the XState wizards. `coming_soon` corridors render a - locked card (badge "Coming soon", no Start button, disabled in the wizard). -- `coming_soon` corridors **can still be selected** at registration / added later - (the user expresses interest); they simply can't be verified yet. - -## 5. Data model changes - -### `Corridor` (`src/domain/types.ts` + `corridors.ts`) -- Add `availability: "live" | "coming_soon"`. -- Add the 4 Alfredpay corridors to `CORRIDORS` and `CORRIDOR_LIST`. - -### `SenderAccount` (`src/domain/types.ts`) -- Add `selectedCorridors: CorridorId[]` — the corridors the account chose to track. -- `onboardings` becomes **partial**: `Partial>`, - populated only for selected corridors. (Adding a country creates its onboarding.) -- Helper: when a corridor is selected, its onboarding initializes to - `not_started` (live) — coming_soon corridors show a derived "Coming soon" state. - -### Seed (`src/domain/seed.ts`) -- Give the 2 seeded accounts a `selectedCorridors: ["BR", "EU"]` so existing demo - is unchanged. - -### New status surface -- Either add a derived display state `"coming_soon"` in `STATUS_META` / `StatusBadge`, - or compute it from `corridor.availability` at render. Recommended: compute from - `corridor.availability` (don't pollute the onboarding status enum, which mirrors - real provider enums). - -## 6. New routes & flow - -### `/register` (outside the app shell, like `/login`) -A multi-step flow (XState `registerMachine`), mirroring the widget: - -1. **Account details** — name, email, account type (company / individual), - **Terms & Conditions** checkbox. (RHF + Zod.) -2. **OTP** — 6-digit `InputOTP`, auto-submit, mock-accept any code. "Change email" / - "Resend". -3. **Choose countries** — multi-select grid/list over the 6-corridor catalog: - - Brazil & Europe tagged **Available**; the 4 Alfredpay corridors tagged **Coming soon**. - - At least one selection required (recommend defaulting Brazil + Europe checked). -4. **Finish** → `useDashboardStore.createAccount({...})`: - - creates a new `SenderAccount` with `selectedCorridors`, account type, name, identifier; - - initializes onboardings (`not_started` for selected live corridors); - - sets it active; authenticates (auth store `login(email)`); navigates to `/overview`. - -### `/login` (existing, refactored to mirror widget) -- Step 1: email + Terms checkbox. -- Step 2: OTP (6-digit, auto-submit, mock). -- → `/overview` (seeded demo accounts). -- Add a "Don't have an account? **Register**" link; `/register` gets "Already have an - account? **Log in**". - -### Route guards -- `_app` layout already redirects to `/login` when unauthenticated — unchanged. -- `/register` and `/login`: if already authenticated, ``. - -## 7. Dashboard changes (Overview) - -- **Filter corridor cards to `activeAccount.selectedCorridors`** (instead of the full - `CORRIDOR_LIST`). -- **"Add country" dropdown** (top-right of the corridors section): lists catalog - corridors **not** in `selectedCorridors`. Selecting one calls - `dashboardStore.addCorridorToAccount(accountId, corridorId)` → appends to - `selectedCorridors` + creates its onboarding → card appears. - - Live corridors appear startable; coming_soon corridors appear locked. -- **Coming-soon card**: badge "Coming soon", greyed progress, button disabled - ("Available soon"). -- Summary cards: count over selected corridors (Corridors / Approved / In progress); - optionally a 4th "Coming soon" count. -- Recipients gating unchanged (only **approved live** corridors unlock recipients). - -## 8. Store changes (`src/stores/dashboard.store.ts`) -- `createAccount(input: { name; email; type; selectedCorridors })`: builds a - `SenderAccount`, pushes to `accounts`, sets `activeAccountId`, returns id. -- `addCorridorToAccount(accountId, corridorId)`: adds to `selectedCorridors` and seeds - its `Onboarding` (`not_started`). No-op if already present. -- Existing `setOnboardingStatus` / recipient actions unchanged (guard for partial - onboardings). -- Consider persisting accounts to `localStorage` so a registered account survives a - reload (today the dashboard store is in-memory; auth persists but accounts don't — - a full reload currently resets to seeds). **Decision needed** (see open questions). - -## 9. New / changed files (estimate) - -**New** -- `src/routes/register.tsx` — `/register` route hosting the register flow. -- `src/machines/register.machine.ts` — XState v5 (details → otp → countries → done). -- `src/machines/auth.machine.ts` *(optional)* — shared email→OTP sub-flow reused by - `/login` and `/register`. -- `src/components/auth/AuthEmailStep.tsx` — email + T&C (mirrors widget). -- `src/components/auth/AuthOtpStep.tsx` — 6-digit OTP (needs an `InputOTP` ui component). -- `src/components/auth/CountrySelectStep.tsx` — catalog multi-select. -- `src/components/onboarding/AddCorridorDropdown.tsx` — "Add country" dropdown. -- `src/components/ui/input-otp.tsx` — shadcn `InputOTP` (uses `input-otp` dep — add it). - -**Changed** -- `src/domain/types.ts` — `Corridor.availability`, `SenderAccount.selectedCorridors`, partial onboardings. -- `src/domain/corridors.ts` — add MX/CO/US/AR + availability + flags. -- `src/domain/seed.ts` — `selectedCorridors` on seeded accounts. -- `src/domain/status.ts` / `StatusBadge.tsx` — coming-soon rendering. -- `src/stores/dashboard.store.ts` — `createAccount`, `addCorridorToAccount`. -- `src/routes/login.tsx` — refactor to email→OTP, add register link. -- `src/routes/_app/overview.tsx` — filter by selectedCorridors + Add-country dropdown. -- `src/components/onboarding/CorridorCard.tsx` — coming-soon variant. -- `package.json` — add `input-otp` (and `@radix-ui`? no — `input-otp` is standalone). - -## 10. Dependencies -- Add **`input-otp`** (used by the widget for the 6-digit code) — `bun add input-otp -F vortex-dashboard`. - -## 11. Edge cases / rules -- Register requires ≥1 selected country and the Terms box checked. -- OTP mock: accept any 6 digits; "Resend" is a no-op toast. -- Adding a country already selected is a no-op. -- Coming-soon corridors: never startable, never unlock recipients, excluded from - "Approved/In progress" counts (or shown separately). -- Switching accounts shows that account's `selectedCorridors` only. -- A registered account with no live corridors selected → Recipients stays locked. - -## 12. Verification (when implemented) -- `bun typecheck`, `bun lint:fix` clean; `vite build` + prerender pass. -- Live browser walk-through: - 1. `/register` → details + T&C → OTP → select Brazil + Mexico → land on dashboard - showing Brazil (startable) + Mexico (coming soon); new account active in switcher. - 2. "Add country" → add Europe → card appears. - 3. Start Brazil KYC → approve → recipients unlock. - 4. `/login` (email→OTP) → lands on seeded demo accounts. - -## 13. Open questions (resolve before building) -1. **Persist registered accounts to localStorage?** (so they survive reload like auth - does). Recommended: yes, persist `accounts` + `activeAccountId` + `recipients` so the - registered account isn't lost on refresh. Trade-off: seeded demo edits also persist. -2. **`/login` Terms checkbox** — widget shows T&C on the email step always; for a - returning-user login it's arguably redundant. Recommended: show T&C only on - `/register`, plain email on `/login`. -3. **Country selection minimum** — force Brazil+Europe preselected, or start empty? - Recommended: preselect Brazil + Europe (the live corridors), allow deselect. -4. **Coming-soon in summary counts** — separate "Coming soon" stat card, or hide from - counts? Recommended: separate stat. diff --git a/apps/dashboard/e2e/api-keys.spec.ts b/apps/dashboard/e2e/api-keys.spec.ts new file mode 100644 index 000000000..3e927a954 --- /dev/null +++ b/apps/dashboard/e2e/api-keys.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { seedSession } from "./support/session"; + +test("creates a credential, shows its secret once, and revokes it", async ({ page }) => { + const backend = await mockBackend(page); + await seedSession(page); + await page.goto("/api-keys"); + + await expect(page.getByText("No API credentials yet")).toBeVisible(); + await page.getByRole("button", { name: "Create credential" }).click(); + + const createDialog = page.getByRole("dialog"); + await createDialog.getByLabel("Name").fill("Production backend"); + await createDialog.getByRole("button", { name: "Create credential" }).click(); + + await expect(createDialog.getByText("Save your credential")).toBeVisible(); + await expect(createDialog.getByRole("textbox", { name: "Secret key" })).toHaveValue( + "sk_test_abcdefghijklmnopqrstuvwxyz123456" + ); + await createDialog.getByLabel("I saved the secret key").click(); + await createDialog.getByRole("button", { name: "Done" }).click(); + + await expect(page.getByText("Production backend", { exact: true })).toBeVisible(); + await expect(page.getByText("sk_test_abcdefghijklmnopqrstuvwxyz123456")).toHaveCount(0); + + await page.getByRole("button", { name: "Revoke Production backend" }).click(); + const revokeDialog = page.getByRole("dialog"); + await revokeDialog.getByRole("button", { name: "Revoke credential" }).click(); + + await expect(page.getByText("Revoked", { exact: true })).toBeVisible(); + await expect(page.getByText("Production backend", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Revoke Production backend" })).toHaveCount(0); + expect(backend.apiCredentialRequests).toHaveLength(2); + expect(backend.apiCredentialRequests[1]).toMatchObject({ + body: null, + method: "DELETE", + path: "/v1/api-credentials/credential-e2e-1" + }); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("API keys are available without an active sender entity", async ({ page }) => { + await mockBackend(page, { selectionRequired: true }); + await seedSession(page); + await page.goto("/api-keys"); + + await expect(page.getByRole("heading", { name: "API keys" })).toBeVisible(); + await expect(page.getByText("No API credentials yet")).toBeVisible(); +}); diff --git a/apps/dashboard/e2e/limits.spec.ts b/apps/dashboard/e2e/limits.spec.ts new file mode 100644 index 000000000..7ea330cca --- /dev/null +++ b/apps/dashboard/e2e/limits.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { seedSession } from "./support/session"; + +test("shows monthly limits for approved corridors", async ({ page }) => { + const backend = await mockBackend(page, { approvedCorridors: ["MX", "BR"] }); + await seedSession(page); + await page.goto("/limits"); + + await expect(page.getByRole("heading", { name: "Limits" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Brazil" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Mexico" })).toBeVisible(); + await expect(page.getByText("1,250 of 10,000 BRL")).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "On-ramp limit usage" })).toHaveAttribute("aria-valuenow", "12.5"); + + await page.getByRole("tab", { name: "Mexico" }).click(); + await expect(page.getByText("500 of 5,000 USDC")).toBeVisible(); + expect(backend.limitsRequests).toEqual([{ corridors: ["BR", "MX"] }]); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("places Limits directly below API keys without requesting unapproved corridors", async ({ page }) => { + const backend = await mockBackend(page, { selectionRequired: true }); + await seedSession(page); + await page.goto("/limits"); + + const apiKeysLink = page.getByRole("link", { name: "API keys" }); + await expect(apiKeysLink.locator("xpath=../following-sibling::li[1]")).toContainText("Limits"); + await expect(page.getByText("Limits will appear here once onboarding is approved for a supported corridor.")).toBeVisible(); + expect(backend.limitsRequests).toEqual([]); +}); diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts index 43cc0dd9c..0c0fe090c 100644 --- a/apps/dashboard/e2e/support/mockBackend.ts +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -31,24 +31,25 @@ const NATIVE_TOKEN_ADDRESS = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; * resolves by rail first, then provider + country — both are set so the corridor maps to MX * whichever branch runs; `state` (not `status`) is what the approval gate reads. */ -export function buildOnboardingStatus(state: OnboardingState = "approved", corridor: "AR" | "BR" | "CO" | "MX" | "US" = "MX") { - const rail = { AR: "ars", BR: "brl", CO: "cop", MX: "mxn", US: "usd" }[corridor]; +export function buildOnboardingStatus( + state: OnboardingState = "approved", + corridor: "AR" | "BR" | "CO" | "MX" | "US" | Array<"AR" | "BR" | "CO" | "MX" | "US"> = "MX" +) { + const corridors = Array.isArray(corridor) ? corridor : [corridor]; return { activeEntityId: "entity-e2e-1", entities: [ { - accounts: [ - { - country: corridor, - customerType: "individual", - id: "acct-e2e-mx", - kycCase: null, - provider: corridor === "BR" ? "avenia" : "alfredpay", - rail, - state, - status: state - } - ], + accounts: corridors.map(accountCorridor => ({ + country: accountCorridor, + customerType: "individual", + id: `acct-e2e-${accountCorridor.toLowerCase()}`, + kycCase: null, + provider: accountCorridor === "BR" ? "avenia" : "alfredpay", + rail: { AR: "ars", BR: "brl", CO: "cop", MX: "mxn", US: "usd" }[accountCorridor], + state, + status: state + })), id: "entity-e2e-1", status: state, type: "individual" @@ -251,6 +252,9 @@ export function buildSellUnsignedTxs(evmEphemeral: string) { } interface MockBackendOptions { + apiCredentials?: Array>; + approvedCorridors?: Array<"AR" | "BR" | "CO" | "MX" | "US">; + limits?: Array>; onboardingState?: OnboardingState; companyMode?: boolean; selectionRequired?: boolean; @@ -385,6 +389,8 @@ function answerRpc(chainIdHex: string) { * changed default RPC URL fails the suite instead of silently reaching the network. */ export async function mockBackend(page: Page, options: MockBackendOptions = {}) { + const apiCredentialRequests: Array<{ body: Record | null; method: string; path: string }> = []; + const limitsRequests: Array> = []; const requestOtpRequests: Array> = []; const verifyOtpRequests: Array> = []; const quoteRequests: Array> = []; @@ -423,6 +429,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) let selectedCompany = options.companyMode ?? false; let hasActiveEntity = options.selectionRequired !== true; const fiatAccounts = [...(options.fiatAccounts ?? buildFiatAccounts())]; + let apiCredentials = [...(options.apiCredentials ?? [])]; const onrampCorridor = { ARS: "AR", BRL: "BR", COP: "CO", MXN: "MX", USD: "US" }[options.onrampCurrency ?? "MXN"] as | "AR" | "BR" @@ -532,7 +539,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) await fulfillStatus( options.companyMode ? buildCompanyOnboardingStatus("alfredpay", "MX", options.onboardingState ?? "approved") - : buildOnboardingStatus(options.onboardingState, onrampCorridor) + : buildOnboardingStatus(options.onboardingState, options.approvedCorridors ?? onrampCorridor) ); return; } @@ -563,6 +570,70 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return; } + if (path === "/v1/limits" && method === "POST") { + const body = request.postDataJSON() as { corridors?: Array<"AR" | "BR" | "CO" | "MX" | "US"> }; + limitsRequests.push(body); + const currencyByCorridor = { AR: "ARS", BR: "BRL", CO: "COP", MX: "MXN", US: "USD" }; + const period = { endsAt: "2026-08-01T00:00:00.000Z", startsAt: "2026-07-01T00:00:00.000Z", type: "calendar_month" }; + await fulfillJson({ + limits: + options.limits ?? + (body.corridors ?? []).flatMap(corridor => [ + { corridor, currency: currencyByCorridor[corridor], direction: "BUY", max: "10000", period, used: "1250" }, + { corridor, currency: corridor === "BR" ? "BRL" : "USDC", direction: "SELL", max: "5000", period, used: "500" } + ]) + }); + return; + } + + if (path === "/v1/api-credentials" && method === "GET") { + await fulfillJson({ credentials: apiCredentials }); + return; + } + + if (path === "/v1/api-credentials" && method === "POST") { + const body = request.postDataJSON() as Record; + apiCredentialRequests.push({ body, method, path }); + const credentialId = `credential-e2e-${apiCredentialRequests.length}`; + const createdAt = "2026-07-30T12:00:00.000Z"; + const publicKey = "pk_test_abcdefghijklmnopqrstuvwxyz123456"; + const secretKey = "sk_test_abcdefghijklmnopqrstuvwxyz123456"; + const name = String(body.name ?? "API Key"); + const expiresAt = String(body.expiresAt); + const credential = { + createdAt, + environment: "test", + expiresAt, + id: credentialId, + name, + partnerId: "partner-e2e-1", + profileId: "profile-e2e-1", + publicKey, + publicLastUsedAt: null, + revokedAt: null, + secretKeyPrefix: "sk_test_", + secretLastUsedAt: null, + updatedAt: createdAt + }; + apiCredentials = [credential, ...apiCredentials]; + await fulfillJson({ ...credential, secretKey }); + return; + } + + if (path.startsWith("/v1/api-credentials/") && method === "DELETE") { + apiCredentialRequests.push({ + body: request.postData() ? (request.postDataJSON() as Record) : null, + method, + path + }); + const credentialId = path.split("/").at(-1); + apiCredentials = apiCredentials.map(credential => + credential.id === credentialId ? { ...credential, revokedAt: "2026-07-30T12:05:00.000Z" } : credential + ); + await route.fulfill({ status: 204 }); + return; + } + if (path === "/v1/monerium/status" && method === "GET" && options.moneriumKyc) { if (!monerium.authorized) { await fulfillJson( @@ -1060,6 +1131,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return { acceptInviteRequests, + apiCredentialRequests, archiveInvitationRequests, auth, avenia, @@ -1074,6 +1146,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) kybUploads, kyc, kycFormSubmissions, + limitsRequests, monerium, quoteRequests, registerRequests, diff --git a/apps/dashboard/playwright.config.ts b/apps/dashboard/playwright.config.ts index dd7edc522..f7c0d9055 100644 --- a/apps/dashboard/playwright.config.ts +++ b/apps/dashboard/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from "@playwright/test"; -// E2E journeys are non-PR-blocking (see docs/testing-strategy.md): they run nightly in CI +// E2E journeys are non-PR-blocking (see docs/operations-testing.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({ @@ -21,7 +21,10 @@ export default defineConfig({ command: "bun x --bun vite --port 5174 --strictPort --host 127.0.0.1", // A placeholder Alchemy key keeps the frontend-matched transport path active; every endpoint // is intercepted per-test. VITE_API_URL defaults to the likewise-intercepted localhost API. - env: { VITE_ALCHEMY_API_KEY: "e2e-mock-key" }, + env: { + VITE_ALCHEMY_API_KEY: "e2e-mock-key", + VITE_WIDGET_URL: "http://127.0.0.1:5173" + }, reuseExistingServer: !process.env.CI, timeout: 120_000, url: "http://127.0.0.1:5174/" diff --git a/apps/dashboard/src/components/api-keys/ApiCredentialsTable.tsx b/apps/dashboard/src/components/api-keys/ApiCredentialsTable.tsx new file mode 100644 index 000000000..bb844a8a0 --- /dev/null +++ b/apps/dashboard/src/components/api-keys/ApiCredentialsTable.tsx @@ -0,0 +1,193 @@ +import { Copy, KeyRound, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { type ApiCredential, keyPreview, toApiCredentials } from "@/domain/api-credentials"; +import { useApiCredentials, useRevokeApiCredential } from "@/hooks/useApiCredentials"; + +function formatDate(value: string | null): string { + if (!value) return "Never"; + return new Date(value).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); +} + +function formatEnvironment(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +export function ApiCredentialsTable() { + const [selected, setSelected] = useState(null); + const apiCredentials = useApiCredentials(); + const credentials = toApiCredentials(apiCredentials.data?.credentials ?? []); + + return ( + <> + + + API credentials + Public keys identify requests. Secret keys authenticate requests from your server. + + + {apiCredentials.isLoading ? ( +
+ + +
+ ) : apiCredentials.isError ? ( +
+

Could not load your API credentials.

+ +
+ ) : credentials.length === 0 ? ( +
+ + + +

No API credentials yet

+

+ Create a credential when you are ready to connect a trusted backend to the Vortex SDK. +

+
+ ) : ( + + + + Name + Public key + Environment + Status + Created + Expires + Last used + Actions + + + + {credentials.map(credential => ( + + + {credential.name} + + +
+ {keyPreview(credential.publicKey)} + +
+
+ + + {formatEnvironment(credential.environment)} + + + + + {credential.status === "active" ? "Active" : credential.status === "expired" ? "Expired" : "Revoked"} + + + {formatDate(credential.createdAt)} + {formatDate(credential.expiresAt)} + +
+ Public: {formatDate(credential.publicLastUsedAt)} + Secret: {formatDate(credential.secretLastUsedAt)} +
+
+ + {credential.status !== "revoked" && ( + + )} + +
+ ))} +
+
+ )} +
+
+ !open && setSelected(null)} /> + + ); +} + +function RevokeCredentialDialog({ + credential, + onOpenChange +}: { + credential: ApiCredential | null; + onOpenChange: (open: boolean) => void; +}) { + const revoke = useRevokeApiCredential(); + + if (!credential) return null; + + const credentialId = credential.id; + const credentialName = credential.name; + + function revokeCredential() { + revoke.mutate(credentialId, { + onError: error => { + toast.error("Could not revoke the API credential", { + description: error instanceof Error ? error.message : undefined + }); + }, + onSuccess: () => { + toast.success(`${credentialName} revoked`); + onOpenChange(false); + } + }); + } + + return ( + + + + Revoke {credential.name}? + + Requests using this credential will fail immediately. This action cannot be undone. + + +

+ For rotation, deploy a replacement credential and verify it works before revoking this one. +

+ + + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/api-keys/CreateApiCredentialDialog.tsx b/apps/dashboard/src/components/api-keys/CreateApiCredentialDialog.tsx new file mode 100644 index 000000000..e07f0e5f8 --- /dev/null +++ b/apps/dashboard/src/components/api-keys/CreateApiCredentialDialog.tsx @@ -0,0 +1,257 @@ +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { Check, Copy, KeyRound, Plus, TriangleAlert } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useCreateApiCredential } from "@/hooks/useApiCredentials"; + +const schema = z + .object({ + customExpiresAt: z.string(), + expiration: z.enum(["90", "365", "730", "custom"]), + name: z.string().trim().min(1, "Enter a name for this credential").max(91, "Keep it under 92 characters") + }) + .superRefine((values, context) => { + if (values.expiration !== "custom") return; + const expiresAt = new Date(`${values.customExpiresAt}T23:59:59.999Z`); + if (!values.customExpiresAt || Number.isNaN(expiresAt.getTime()) || expiresAt <= new Date()) { + context.addIssue({ code: "custom", message: "Choose a future expiration date", path: ["customExpiresAt"] }); + return; + } + if (expiresAt.getTime() > Date.now() + 2 * 365 * 24 * 60 * 60 * 1000) { + context.addIssue({ code: "custom", message: "Choose a date within two years", path: ["customExpiresAt"] }); + } + }); + +type FormValues = z.infer; + +function expirationDate(values: FormValues): string { + if (values.expiration === "custom") return new Date(`${values.customExpiresAt}T23:59:59.999Z`).toISOString(); + return new Date(Date.now() + Number(values.expiration) * 24 * 60 * 60 * 1000).toISOString(); +} + +export function CreateApiCredentialDialog() { + const [open, setOpen] = useState(false); + const [acknowledged, setAcknowledged] = useState(false); + const createCredential = useCreateApiCredential(); + const form = useForm({ + defaultValues: { customExpiresAt: "", expiration: "365", name: "" }, + resolver: standardSchemaResolver(schema) + }); + const expiration = form.watch("expiration"); + + function reset() { + createCredential.reset(); + form.reset(); + setAcknowledged(false); + } + + function onOpenChange(next: boolean) { + if (!next && createCredential.data && !acknowledged) { + toast.warning("Save your secret key before closing"); + return; + } + setOpen(next); + if (!next) reset(); + } + + function onSubmit(values: FormValues) { + createCredential.mutate( + { expiresAt: expirationDate(values), name: values.name.trim() }, + { + onError: error => { + toast.error("Could not create the API credential", { + description: error instanceof Error ? error.message : undefined + }); + } + } + ); + } + + return ( + + + + + + {createCredential.data ? ( + onOpenChange(false)} + publicKey={createCredential.data.publicKey} + secretKey={createCredential.data.secretKey} + /> + ) : ( + <> + + Create API credential + + Use this credential from a trusted server to authenticate Vortex SDK requests. + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Expiration + + + + )} + /> + {expiration === "custom" && ( + ( + + Expiration date + + + + + + )} + /> + )} +
+ +

The secret key will be shown once. Store it in a server-side secret manager, never in browser code.

+
+ + + + + + + + )} +
+
+ ); +} + +function CreatedCredential({ + acknowledged, + onAcknowledgedChange, + onDone, + publicKey, + secretKey +}: { + acknowledged: boolean; + onAcknowledgedChange: (checked: boolean) => void; + onDone: () => void; + publicKey: string; + secretKey: string; +}) { + return ( + <> + + Save your credential + The secret key cannot be retrieved after this dialog closes. + +
+ + + +
+ + + + + ); +} + +function CredentialValue({ label, secret = false, value }: { label: string; secret?: boolean; value: string }) { + const [copied, setCopied] = useState(false); + + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + toast.success(`${label} copied`); + } catch { + toast.error(`Could not copy the ${label.toLowerCase()}`); + } + } + + return ( +
+
+ + {secret && Shown once} +
+
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx index 4f36abdda..d6c54e869 100644 --- a/apps/dashboard/src/components/layout/AppSidebar.tsx +++ b/apps/dashboard/src/components/layout/AppSidebar.tsx @@ -1,5 +1,5 @@ import { Link, useRouterState } from "@tanstack/react-router"; -import { ArrowLeftRight, Calculator, Send, Settings, ShieldCheck, Users } from "lucide-react"; +import { ArrowLeftRight, Calculator, Gauge, KeyRound, Send, Settings, ShieldCheck, Users } from "lucide-react"; import { Sidebar, SidebarContent, @@ -19,6 +19,8 @@ const NAV_ITEMS = [ { icon: Calculator, label: "Get a quote", to: "/quote" }, { icon: Send, label: "New transfer", to: "/transfer" }, { icon: ArrowLeftRight, label: "Transactions", to: "/transactions" }, + { icon: KeyRound, label: "API keys", to: "/api-keys" }, + { icon: Gauge, label: "Limits", to: "/limits" }, { icon: Settings, label: "Settings", to: "/settings" } ] as const; diff --git a/apps/dashboard/src/components/limits/LimitsCard.tsx b/apps/dashboard/src/components/limits/LimitsCard.tsx new file mode 100644 index 000000000..a496e44db --- /dev/null +++ b/apps/dashboard/src/components/limits/LimitsCard.tsx @@ -0,0 +1,99 @@ +import { RampDirection, type UserLimit } from "@vortexfi/shared"; +import { formatAmount } from "@/components/quote/AmountPanel"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CORRIDORS } from "@/domain/corridors"; +import { useLimits } from "@/hooks/useLimits"; + +const DIRECTIONS = [RampDirection.BUY, RampDirection.SELL]; + +function LimitBar({ limit }: { limit: UserLimit }) { + const max = Number(limit.max); + const used = Number(limit.used); + const percentage = max > 0 ? Math.min(100, Math.max(0, (used / max) * 100)) : 0; + const label = limit.direction === RampDirection.BUY ? "On-ramp" : "Off-ramp"; + + return ( +
+
+
+

{label}

+

Used this month

+
+

+ {formatAmount(limit.used, 2)} + of {formatAmount(limit.max, 2)} + {limit.currency} +

+
+ +

+ Resets{" "} + {new Date(limit.period.endsAt).toLocaleDateString(undefined, { day: "numeric", month: "short", timeZone: "UTC" })} +

+
+ ); +} + +export function LimitsCard() { + const limits = useLimits(); + const isLoading = limits.isLoadingCorridors || limits.isLoading; + + return ( + + + Monthly limits + Track how much of your on-ramp and off-ramp allowance you have used. + + + {isLoading ? ( +
+ +
+ + +
+
+ ) : limits.isError ? ( +
+

Could not load your monthly limits.

+ +
+ ) : limits.corridors.length === 0 ? ( +

+ Limits will appear here once onboarding is approved for a supported corridor. +

+ ) : ( + + + {limits.corridors.map(corridor => ( + + {CORRIDORS[corridor].flag} + {CORRIDORS[corridor].name} + + ))} + + {limits.corridors.map(corridor => { + const corridorLimits = limits.data?.limits.filter(limit => limit.corridor === corridor) ?? []; + return ( + +
+ {DIRECTIONS.map(direction => { + const limit = corridorLimits.find(item => item.direction === direction); + return limit ? : null; + })} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/domain/api-credentials.test.ts b/apps/dashboard/src/domain/api-credentials.test.ts new file mode 100644 index 000000000..7b45427de --- /dev/null +++ b/apps/dashboard/src/domain/api-credentials.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { ApiCredentialRecord } from "@/services/api/api-credentials.service"; +import { credentialStatus, keyPreview, toApiCredentials } from "./api-credentials"; + +function credential(overrides: Partial = {}): ApiCredentialRecord { + return { + createdAt: "2026-01-01T00:00:00.000Z", + environment: "live", + expiresAt: "2027-01-01T00:00:00.000Z", + id: "credential-1", + name: "Production", + partnerId: "partner-1", + profileId: "profile-1", + publicKey: "pk_live_abcdefghijklmnopqrstuvwxyz123456", + publicLastUsedAt: null, + revokedAt: null, + secretKeyPrefix: "sk_live_", + secretLastUsedAt: null, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides + }; +} + +describe("credentialStatus", () => { + const now = new Date("2026-07-31T00:00:00.000Z"); + + it("derives active and expired status from the expiration date", () => { + assert.equal(credentialStatus(credential(), now), "active"); + assert.equal(credentialStatus(credential({ expiresAt: "2026-07-30T00:00:00.000Z" }), now), "expired"); + }); + + it("gives revoked status precedence over expiration", () => { + const record = credential({ expiresAt: "2026-01-02T00:00:00.000Z", revokedAt: "2026-01-01T00:00:00.000Z" }); + assert.equal(credentialStatus(record, now), "revoked"); + }); +}); + +describe("toApiCredentials", () => { + it("maps direct credential records and sorts newest first", () => { + const records = [ + credential(), + credential({ createdAt: "2026-02-01T00:00:00.000Z", id: "credential-2", name: "Staging" }) + ]; + + const credentials = toApiCredentials(records, new Date("2026-07-31T00:00:00.000Z")); + + assert.deepEqual( + credentials.map(item => item.id), + ["credential-2", "credential-1"] + ); + assert.equal(credentials[0]?.publicKey, records[1]?.publicKey); + }); + + it("masks public keys while retaining a useful preview", () => { + assert.equal(keyPreview(credential().publicKey), "pk_live_abcd••••3456"); + }); +}); diff --git a/apps/dashboard/src/domain/api-credentials.ts b/apps/dashboard/src/domain/api-credentials.ts new file mode 100644 index 000000000..365459890 --- /dev/null +++ b/apps/dashboard/src/domain/api-credentials.ts @@ -0,0 +1,23 @@ +import type { ApiCredentialRecord } from "@/services/api/api-credentials.service"; + +export type ApiCredentialStatus = "active" | "expired" | "revoked"; + +export interface ApiCredential extends ApiCredentialRecord { + status: ApiCredentialStatus; +} + +export function credentialStatus(record: ApiCredentialRecord, now = new Date()): ApiCredentialStatus { + if (record.revokedAt) return "revoked"; + if (record.expiresAt && new Date(record.expiresAt) <= now) return "expired"; + return "active"; +} + +export function toApiCredentials(records: ApiCredentialRecord[], now = new Date()): ApiCredential[] { + return records + .map(record => ({ ...record, status: credentialStatus(record, now) })) + .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()); +} + +export function keyPreview(key: string): string { + return `${key.slice(0, 12)}••••${key.slice(-4)}`; +} diff --git a/apps/dashboard/src/hooks/useApiCredentials.ts b/apps/dashboard/src/hooks/useApiCredentials.ts new file mode 100644 index 000000000..58c52db58 --- /dev/null +++ b/apps/dashboard/src/hooks/useApiCredentials.ts @@ -0,0 +1,28 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ApiCredentialsService, type CreateApiCredentialRequest } from "@/services/api/api-credentials.service"; + +export const API_CREDENTIALS_QUERY_KEY = ["api-credentials"] as const; + +export function useApiCredentials() { + return useQuery({ + queryFn: ({ signal }) => ApiCredentialsService.list(signal), + queryKey: API_CREDENTIALS_QUERY_KEY, + retry: false + }); +} + +export function useCreateApiCredential() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: CreateApiCredentialRequest) => ApiCredentialsService.create(request), + onSuccess: () => queryClient.invalidateQueries({ queryKey: API_CREDENTIALS_QUERY_KEY }) + }); +} + +export function useRevokeApiCredential() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (credentialId: string) => ApiCredentialsService.revoke(credentialId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: API_CREDENTIALS_QUERY_KEY }) + }); +} diff --git a/apps/dashboard/src/hooks/useLimits.ts b/apps/dashboard/src/hooks/useLimits.ts new file mode 100644 index 000000000..76e403c81 --- /dev/null +++ b/apps/dashboard/src/hooks/useLimits.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import type { LimitsCorridor } from "@vortexfi/shared"; +import { useMemo } from "react"; +import { useApprovedCorridors } from "@/hooks/useApprovedCorridors"; +import { LimitsService } from "@/services/api/limits.service"; + +export const LIMITS_QUERY_KEY = ["limits"] as const; + +export function useLimits() { + const { approved, isLoading: isLoadingCorridors } = useApprovedCorridors(); + const corridors = useMemo( + () => + [...approved] + .filter((corridor): corridor is LimitsCorridor => corridor !== "EU") + .sort((first, second) => first.localeCompare(second)), + [approved] + ); + const query = useQuery({ + enabled: !isLoadingCorridors && corridors.length > 0, + queryFn: () => LimitsService.get({ corridors }), + queryKey: [...LIMITS_QUERY_KEY, ...corridors], + retry: false + }); + + return { ...query, corridors, isLoadingCorridors }; +} diff --git a/apps/dashboard/src/machines/transfer.actors.ts b/apps/dashboard/src/machines/transfer.actors.ts index dc8f7a324..471957e83 100644 --- a/apps/dashboard/src/machines/transfer.actors.ts +++ b/apps/dashboard/src/machines/transfer.actors.ts @@ -25,7 +25,7 @@ import { fetchQuote, type QuoteParams } from "@/services/api/quote.service"; import { shouldRefreshQuote } from "@/services/api/quote-expiry"; import { isTerminalPhase, RampService } from "@/services/api/ramp.service"; import { fetchTokenPortfolio, getTokenBalance, hasSufficientTokenBalance } from "@/services/balance.service"; -import { bindRampEphemerals, storePendingRampEphemerals } from "@/services/rampEphemerals"; +import { bindRampEphemerals, markRampEphemeralsTerminal, storePendingRampEphemerals } from "@/services/rampEphemerals"; import { signAndSubmitEvmTransaction, signMultipleTypedData } from "@/services/transactions/userSigning"; const ALCHEMY_API_KEY: string | undefined = import.meta.env.VITE_ALCHEMY_API_KEY; @@ -254,6 +254,7 @@ export function pollRampUntilTerminal( } onStatus(status); if (isTerminalPhase(status)) { + markRampEphemeralsTerminal(rampId); onTerminal(status); return; } diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 552cac14f..a2d339a04 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -20,6 +20,8 @@ import { Route as AppSettingsRouteImport } from './routes/_app/settings' import { Route as AppRecipientsRouteImport } from './routes/_app/recipients' import { Route as AppQuoteRouteImport } from './routes/_app/quote' import { Route as AppOverviewRouteImport } from './routes/_app/overview' +import { Route as AppLimitsRouteImport } from './routes/_app/limits' +import { Route as AppApiKeysRouteImport } from './routes/_app/api-keys' const LoginRoute = LoginRouteImport.update({ id: '/login', @@ -75,10 +77,22 @@ const AppOverviewRoute = AppOverviewRouteImport.update({ path: '/overview', getParentRoute: () => AppRoute, } as any) +const AppLimitsRoute = AppLimitsRouteImport.update({ + id: '/limits', + path: '/limits', + getParentRoute: () => AppRoute, +} as any) +const AppApiKeysRoute = AppApiKeysRouteImport.update({ + id: '/api-keys', + path: '/api-keys', + getParentRoute: () => AppRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute + '/api-keys': typeof AppApiKeysRoute + '/limits': typeof AppLimitsRoute '/overview': typeof AppOverviewRoute '/quote': typeof AppQuoteRoute '/recipients': typeof AppRecipientsRoute @@ -91,6 +105,8 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute + '/api-keys': typeof AppApiKeysRoute + '/limits': typeof AppLimitsRoute '/overview': typeof AppOverviewRoute '/quote': typeof AppQuoteRoute '/recipients': typeof AppRecipientsRoute @@ -105,6 +121,8 @@ export interface FileRoutesById { '/': typeof IndexRoute '/_app': typeof AppRouteWithChildren '/login': typeof LoginRoute + '/_app/api-keys': typeof AppApiKeysRoute + '/_app/limits': typeof AppLimitsRoute '/_app/overview': typeof AppOverviewRoute '/_app/quote': typeof AppQuoteRoute '/_app/recipients': typeof AppRecipientsRoute @@ -119,6 +137,8 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' + | '/api-keys' + | '/limits' | '/overview' | '/quote' | '/recipients' @@ -131,6 +151,8 @@ export interface FileRouteTypes { to: | '/' | '/login' + | '/api-keys' + | '/limits' | '/overview' | '/quote' | '/recipients' @@ -144,6 +166,8 @@ export interface FileRouteTypes { | '/' | '/_app' | '/login' + | '/_app/api-keys' + | '/_app/limits' | '/_app/overview' | '/_app/quote' | '/_app/recipients' @@ -241,10 +265,26 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOverviewRouteImport parentRoute: typeof AppRoute } + '/_app/limits': { + id: '/_app/limits' + path: '/limits' + fullPath: '/limits' + preLoaderRoute: typeof AppLimitsRouteImport + parentRoute: typeof AppRoute + } + '/_app/api-keys': { + id: '/_app/api-keys' + path: '/api-keys' + fullPath: '/api-keys' + preLoaderRoute: typeof AppApiKeysRouteImport + parentRoute: typeof AppRoute + } } } interface AppRouteChildren { + AppApiKeysRoute: typeof AppApiKeysRoute + AppLimitsRoute: typeof AppLimitsRoute AppOverviewRoute: typeof AppOverviewRoute AppQuoteRoute: typeof AppQuoteRoute AppRecipientsRoute: typeof AppRecipientsRoute @@ -254,6 +294,8 @@ interface AppRouteChildren { } const AppRouteChildren: AppRouteChildren = { + AppApiKeysRoute: AppApiKeysRoute, + AppLimitsRoute: AppLimitsRoute, AppOverviewRoute: AppOverviewRoute, AppQuoteRoute: AppQuoteRoute, AppRecipientsRoute: AppRecipientsRoute, diff --git a/apps/dashboard/src/routes/_app/api-keys.tsx b/apps/dashboard/src/routes/_app/api-keys.tsx new file mode 100644 index 000000000..04e608ebe --- /dev/null +++ b/apps/dashboard/src/routes/_app/api-keys.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ApiCredentialsTable } from "@/components/api-keys/ApiCredentialsTable"; +import { CreateApiCredentialDialog } from "@/components/api-keys/CreateApiCredentialDialog"; +import { Stagger, StaggerItem } from "@/components/motion/Stagger"; + +export const Route = createFileRoute("/_app/api-keys")({ + component: ApiKeysPage +}); + +function ApiKeysPage() { + return ( + + +
+

API keys

+

+ Create user-linked credentials for server-side Vortex SDK integrations. Secret keys are never shown twice. +

+
+ +
+ + + +
+ ); +} diff --git a/apps/dashboard/src/routes/_app/limits.tsx b/apps/dashboard/src/routes/_app/limits.tsx new file mode 100644 index 000000000..17a622716 --- /dev/null +++ b/apps/dashboard/src/routes/_app/limits.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { LimitsCard } from "@/components/limits/LimitsCard"; +import { Stagger, StaggerItem } from "@/components/motion/Stagger"; + +export const Route = createFileRoute("/_app/limits")({ + component: LimitsPage +}); + +function LimitsPage() { + return ( + + +

Limits

+

Review your monthly limits across approved corridors.

+
+ + + +
+ ); +} diff --git a/apps/dashboard/src/services/api/api-client.ts b/apps/dashboard/src/services/api/api-client.ts index e788d1486..4fd8c8d99 100644 --- a/apps/dashboard/src/services/api/api-client.ts +++ b/apps/dashboard/src/services/api/api-client.ts @@ -1,19 +1,8 @@ import { AuthService, type AuthTokens } from "../auth"; import { API_BASE_URL } from "./base-url"; -// 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() - .catch(() => null) - .finally(() => { - refreshPromise = null; - }); - } - return refreshPromise; + return AuthService.refreshAccessToken().catch(() => null); } export class ApiError extends Error { @@ -75,7 +64,7 @@ async function apiFetch( if (response.status === 401 && initialTokens?.accessToken) { const refreshed = await refreshTokenOnce(); - if (refreshed?.accessToken) { + if (refreshed?.accessToken && refreshed.userId === initialTokens.userId) { response = await doFetch(refreshed.accessToken); } } @@ -107,7 +96,8 @@ async function apiFetch( } export const apiClient = { - delete: (url: string, config?: { params?: Params }) => apiFetch("DELETE", url, { params: config?.params }), + delete: (url: string, config?: { data?: unknown; params?: Params }) => + apiFetch("DELETE", url, { data: config?.data, params: config?.params }), get: (url: string, config?: { params?: Params; signal?: AbortSignal }) => apiFetch("GET", url, { params: config?.params, signal: config?.signal }), patch: (url: string, data?: unknown) => apiFetch("PATCH", url, { data }), diff --git a/apps/dashboard/src/services/api/api-credentials.service.ts b/apps/dashboard/src/services/api/api-credentials.service.ts new file mode 100644 index 000000000..f8cee6ce2 --- /dev/null +++ b/apps/dashboard/src/services/api/api-credentials.service.ts @@ -0,0 +1,36 @@ +import { apiClient } from "./api-client"; + +export interface ApiCredentialRecord { + id: string; + name: string; + profileId: string; + partnerId: string | null; + environment: "live" | "test"; + publicKey: string; + secretKeyPrefix: string; + publicLastUsedAt: string | null; + secretLastUsedAt: string | null; + expiresAt: string; + revokedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateApiCredentialRequest { + name: string; + expiresAt: string; +} + +export interface CreateApiCredentialResponse extends ApiCredentialRecord { + secretKey: string; +} + +interface ListApiCredentialsResponse { + credentials: ApiCredentialRecord[]; +} + +export const ApiCredentialsService = { + create: (request: CreateApiCredentialRequest) => apiClient.post("/api-credentials", request), + list: (signal?: AbortSignal) => apiClient.get("/api-credentials", { signal }), + revoke: (credentialId: string) => apiClient.delete(`/api-credentials/${credentialId}`) +}; diff --git a/apps/dashboard/src/services/api/limits.service.ts b/apps/dashboard/src/services/api/limits.service.ts new file mode 100644 index 000000000..91f6aa75b --- /dev/null +++ b/apps/dashboard/src/services/api/limits.service.ts @@ -0,0 +1,6 @@ +import type { GetUserLimitsRequest, GetUserLimitsResponse } from "@vortexfi/shared"; +import { apiClient } from "./api-client"; + +export const LimitsService = { + get: (request: GetUserLimitsRequest) => apiClient.post("/limits", request) +}; diff --git a/apps/dashboard/src/services/auth.test.ts b/apps/dashboard/src/services/auth.test.ts new file mode 100644 index 000000000..dc25ac34b --- /dev/null +++ b/apps/dashboard/src/services/auth.test.ts @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import { AuthService } from "./auth"; +import { startTokenRefresh } from "./tokenRefresh"; + +const originalFetch = globalThis.fetch; +const originalLocalStorage = Object.getOwnPropertyDescriptor( + globalThis, + "localStorage", +); +const values = new Map(); + +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }, +}); + +beforeEach(() => { + values.clear(); + AuthService.storeTokens({ + accessToken: "expired-access-token", + refreshToken: "refresh-token", + userEmail: "e2e@vortex.local", + userId: "user-1", + }); +}); + +after(() => { + globalThis.fetch = originalFetch; + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } +}); + +describe("AuthService", () => { + it("coalesces concurrent token refreshes across auth callers", async () => { + let fetchCalls = 0; + let releaseRequest: (() => void) | undefined; + const requestGate = new Promise((resolve) => { + releaseRequest = resolve; + }); + + globalThis.fetch = (async () => { + fetchCalls += 1; + await requestGate; + return new Response( + JSON.stringify({ + access_token: "rotated-access-token", + refresh_token: "rotated-refresh-token", + }), + { headers: { "Content-Type": "application/json" }, status: 200 }, + ); + }) as typeof fetch; + + const proactiveRefresh = AuthService.refreshAccessToken(); + const requestRecoveryRefresh = AuthService.refreshAccessToken(); + releaseRequest?.(); + + const [proactiveTokens, recoveryTokens] = await Promise.all([ + proactiveRefresh, + requestRecoveryRefresh, + ]); + + assert.equal(fetchCalls, 1); + assert.deepEqual(proactiveTokens, recoveryTokens); + assert.deepEqual(AuthService.getTokens(), { + accessToken: "rotated-access-token", + refreshToken: "rotated-refresh-token", + userEmail: "e2e@vortex.local", + userId: "user-1", + }); + }); + + it("clears the current session when its refresh token is rejected", async () => { + globalThis.fetch = (async () => new Response(null, { status: 401 })) as typeof fetch; + + assert.equal(await AuthService.refreshAccessToken(), null); + assert.equal(AuthService.getTokens(), null); + }); + + it("does not let an old refresh flight overwrite a newer session", async () => { + let oldRefreshRequests = 0; + let newRefreshRequests = 0; + let releaseOldRequest: (() => void) | undefined; + const oldRequestGate = new Promise((resolve) => { + releaseOldRequest = resolve; + }); + + globalThis.fetch = (async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { refresh_token: string }; + if (body.refresh_token === "refresh-token") { + oldRefreshRequests += 1; + await oldRequestGate; + return new Response( + JSON.stringify({ + access_token: "stale-access-token", + refresh_token: "stale-refresh-token", + }), + { headers: { "Content-Type": "application/json" }, status: 200 }, + ); + } + + newRefreshRequests += 1; + return new Response( + JSON.stringify({ + access_token: "new-rotated-access-token", + refresh_token: "new-rotated-refresh-token", + }), + { headers: { "Content-Type": "application/json" }, status: 200 }, + ); + }) as typeof fetch; + + const oldRefresh = AuthService.refreshAccessToken(); + AuthService.storeTokens({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + userEmail: "new@vortex.local", + userId: "user-2", + }); + const newRefresh = AuthService.refreshAccessToken(); + + assert.deepEqual(await newRefresh, { + accessToken: "new-rotated-access-token", + refreshToken: "new-rotated-refresh-token", + userEmail: "new@vortex.local", + userId: "user-2", + }); + releaseOldRequest?.(); + assert.deepEqual(await oldRefresh, { + accessToken: "new-rotated-access-token", + refreshToken: "new-rotated-refresh-token", + userEmail: "new@vortex.local", + userId: "user-2", + }); + assert.equal(oldRefreshRequests, 1); + assert.equal(newRefreshRequests, 1); + assert.deepEqual(AuthService.getTokens(), { + accessToken: "new-rotated-access-token", + refreshToken: "new-rotated-refresh-token", + userEmail: "new@vortex.local", + userId: "user-2", + }); + }); + + it("does not let a stale 401 clear a newer session", async () => { + let releaseRequest: (() => void) | undefined; + const requestGate = new Promise((resolve) => { + releaseRequest = resolve; + }); + + globalThis.fetch = (async () => { + await requestGate; + return new Response(null, { status: 401 }); + }) as typeof fetch; + + const oldRefresh = AuthService.refreshAccessToken(); + AuthService.storeTokens({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + userEmail: "new@vortex.local", + userId: "user-2", + }); + releaseRequest?.(); + + assert.deepEqual(await oldRefresh, { + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + userEmail: "new@vortex.local", + userId: "user-2", + }); + assert.deepEqual(AuthService.getTokens(), { + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + userEmail: "new@vortex.local", + userId: "user-2", + }); + }); + + it("keeps a replacement session when a proactive refresh is superseded", async () => { + let invalidated = false; + let releaseRequest: (() => void) | undefined; + const requestGate = new Promise((resolve) => { + releaseRequest = resolve; + }); + const timers: Array<() => void | Promise> = []; + + globalThis.fetch = (async () => { + await requestGate; + return new Response(null, { status: 401 }); + }) as typeof fetch; + + startTokenRefresh({ + getExpiryMs: () => 60_000, + now: () => 0, + onInvalid: () => { + invalidated = true; + AuthService.signOut(); + }, + refresh: () => AuthService.refreshAccessToken(), + setTimer: (callback) => { + timers.push(callback); + return callback as unknown as ReturnType; + }, + }); + + const proactiveRefresh = timers.shift()?.(); + AuthService.storeTokens({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + userEmail: "new@vortex.local", + userId: "user-2", + }); + releaseRequest?.(); + await proactiveRefresh; + + assert.equal(invalidated, false); + assert.equal(timers.length, 1); + assert.deepEqual(AuthService.getTokens(), { + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + userEmail: "new@vortex.local", + userId: "user-2", + }); + }); +}); diff --git a/apps/dashboard/src/services/auth.ts b/apps/dashboard/src/services/auth.ts index 4de455dc1..6a0ca9fe8 100644 --- a/apps/dashboard/src/services/auth.ts +++ b/apps/dashboard/src/services/auth.ts @@ -16,8 +16,15 @@ export class AuthService { private static readonly REFRESH_TOKEN_KEY = "vortex_dashboard_refresh_token"; private static readonly USER_ID_KEY = "vortex_dashboard_user_id"; private static readonly USER_EMAIL_KEY = "vortex_dashboard_user_email"; + private static sessionGeneration = 0; + private static refreshFlight: { + generation: number; + refreshToken: string; + promise: Promise; + } | null = null; static storeTokens(tokens: AuthTokens): void { + this.sessionGeneration += 1; localStorage.setItem(this.ACCESS_TOKEN_KEY, tokens.accessToken); localStorage.setItem(this.REFRESH_TOKEN_KEY, tokens.refreshToken); localStorage.setItem(this.USER_ID_KEY, tokens.userId); @@ -39,6 +46,7 @@ export class AuthService { } static clearTokens(): void { + this.sessionGeneration += 1; localStorage.removeItem(this.ACCESS_TOKEN_KEY); localStorage.removeItem(this.REFRESH_TOKEN_KEY); localStorage.removeItem(this.USER_ID_KEY); @@ -77,15 +85,33 @@ export class AuthService { /** * Refresh the access token via `/v1/auth/refresh`. Returns the new tokens, or `null` - * when the refresh token is confirmed invalid (401 — session cleared). Transient - * failures throw so callers can retry without destroying a still-valid session. + * when the current refresh token is confirmed invalid (401 — session cleared). A + * superseded flight returns the replacement session instead. Transient failures throw so + * callers can retry without destroying a still-valid session. Callers in the same session + * share an in-flight refresh so proactive refresh and 401 recovery cannot race refresh-token + * rotation; a replacement session starts its own flight. */ - static async refreshAccessToken(): Promise { + static refreshAccessToken(): Promise { const tokens = this.getTokens(); if (!tokens) { - return null; + return Promise.resolve(null); + } + + const generation = this.sessionGeneration; + if (this.refreshFlight?.generation === generation && this.refreshFlight.refreshToken === tokens.refreshToken) { + return this.refreshFlight.promise; } + const promise = this.performTokenRefresh(tokens, generation).finally(() => { + if (this.refreshFlight?.promise === promise) { + this.refreshFlight = null; + } + }); + this.refreshFlight = { generation, promise, refreshToken: tokens.refreshToken }; + return promise; + } + + private static async performTokenRefresh(tokens: AuthTokens, generation: number): Promise { const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, { body: JSON.stringify({ refresh_token: tokens.refreshToken }), headers: { "Content-Type": "application/json" }, @@ -93,6 +119,9 @@ export class AuthService { signal: AbortSignal.timeout(30000) }); + if (!this.isCurrentSession(tokens.refreshToken, generation)) { + return this.getTokens(); + } if (response.status === 401) { this.clearTokens(); return null; @@ -102,6 +131,9 @@ export class AuthService { } const data = (await response.json()) as { access_token: string; refresh_token: string }; + if (!this.isCurrentSession(tokens.refreshToken, generation)) { + return this.getTokens(); + } const newTokens: AuthTokens = { accessToken: data.access_token, refreshToken: data.refresh_token, @@ -112,6 +144,10 @@ export class AuthService { return newTokens; } + private static isCurrentSession(refreshToken: string, generation: number): boolean { + return this.sessionGeneration === generation && this.getTokens()?.refreshToken === refreshToken; + } + static signOut(): void { this.clearTokens(); } diff --git a/apps/dashboard/src/services/rampEphemerals.test.ts b/apps/dashboard/src/services/rampEphemerals.test.ts index 29033fac3..3f7138d04 100644 --- a/apps/dashboard/src/services/rampEphemerals.test.ts +++ b/apps/dashboard/src/services/rampEphemerals.test.ts @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; import { after, beforeEach, describe, it } from "node:test"; -import { bindRampEphemerals, getStoredRampEphemerals, storePendingRampEphemerals } from "./rampEphemerals"; +import { + bindRampEphemerals, + getStoredRampEphemerals, + markRampEphemeralsTerminal, + storePendingRampEphemerals, + TERMINAL_EPHEMERAL_RETENTION_MS +} from "./rampEphemerals"; const originalLocalStorage = globalThis.localStorage; const values = new Map(); @@ -48,4 +54,34 @@ describe("ramp ephemeral storage", () => { assert.equal(stored["current-ramp"]?.substrateEphemeral.secret, "current mnemonic"); assert.equal(stored["pending:quote-id"], undefined); }); + + it("retains unresolved keys and prunes terminal keys only after 90 days", () => { + const observedAt = 1_000_000; + values.set( + "vortex_dashboard_rampEphemerals", + JSON.stringify({ + resolved: { + evmEphemeral: { address: "0xresolved", secret: "0xresolved-secret" }, + substrateEphemeral: { address: "resolved-substrate", secret: "resolved mnemonic" }, + timestamp: 1 + }, + unresolved: { + evmEphemeral: { address: "0xunresolved", secret: "0xunresolved-secret" }, + substrateEphemeral: { address: "unresolved-substrate", secret: "unresolved mnemonic" }, + timestamp: 1 + } + }) + ); + + markRampEphemeralsTerminal("resolved", observedAt); + markRampEphemeralsTerminal("resolved", observedAt + 100); + + assert.equal( + getStoredRampEphemerals(observedAt + TERMINAL_EPHEMERAL_RETENTION_MS - 1).resolved?.terminalObservedAt, + observedAt + ); + const afterRetention = getStoredRampEphemerals(observedAt + TERMINAL_EPHEMERAL_RETENTION_MS); + assert.equal(afterRetention.resolved, undefined); + assert.equal(afterRetention.unresolved?.evmEphemeral.secret, "0xunresolved-secret"); + }); }); diff --git a/apps/dashboard/src/services/rampEphemerals.ts b/apps/dashboard/src/services/rampEphemerals.ts index 769f807db..3b5035531 100644 --- a/apps/dashboard/src/services/rampEphemerals.ts +++ b/apps/dashboard/src/services/rampEphemerals.ts @@ -1,40 +1,52 @@ import type { EphemeralAccount } from "@vortexfi/shared"; -// Namespaced away from the widget's "rampEphemerals": on any origin the two apps ever share -// (they were same-origin under /dashboard/ historically), the widget prunes its map to 50 -// entries — sharing the key would let a widget ramp evict an in-flight dashboard ramp's -// recovery keys. +// Namespaced away from the widget's "rampEphemerals": the two apps were historically +// served on the same origin under /dashboard/, and independent archives prevent either +// application's migrations or retention metadata from corrupting the other's recovery keys. const RAMP_EPHEMERALS_STORAGE_KEY = "vortex_dashboard_rampEphemerals"; +export const TERMINAL_EPHEMERAL_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; export interface RampEphemeralEntry { substrateEphemeral: EphemeralAccount; evmEphemeral: EphemeralAccount; timestamp: number; + terminalObservedAt?: number; } type RampEphemeralsMap = Record; -function readRampEphemerals(): RampEphemeralsMap { +function writeRampEphemerals(entries: RampEphemeralsMap): void { + try { + localStorage.setItem(RAMP_EPHEMERALS_STORAGE_KEY, JSON.stringify(entries)); + } catch { + throw new Error("Unable to preserve ramp recovery keys in this browser. The transfer was not registered."); + } +} + +function readRampEphemerals(now = Date.now()): RampEphemeralsMap { const raw = localStorage.getItem(RAMP_EPHEMERALS_STORAGE_KEY); if (!raw) { return {}; } try { - return JSON.parse(raw) as RampEphemeralsMap; + const entries = JSON.parse(raw) as RampEphemeralsMap; + let changed = false; + for (const [rampId, entry] of Object.entries(entries)) { + if (entry.terminalObservedAt !== undefined && now - entry.terminalObservedAt >= TERMINAL_EPHEMERAL_RETENTION_MS) { + delete entries[rampId]; + changed = true; + } + } + if (changed) { + writeRampEphemerals(entries); + } + return entries; } catch { throw new Error("The saved ramp recovery keys are unreadable. Restore or clear them before starting another transfer."); } } -function writeRampEphemerals(entries: RampEphemeralsMap): void { - try { - localStorage.setItem(RAMP_EPHEMERALS_STORAGE_KEY, JSON.stringify(entries)); - } catch { - throw new Error("Unable to preserve ramp recovery keys in this browser. The transfer was not registered."); - } -} - export function storePendingRampEphemerals( quoteId: string, ephemerals: Pick @@ -57,6 +69,16 @@ export function bindRampEphemerals(quoteId: string, rampId: string): void { writeRampEphemerals(entries); } -export function getStoredRampEphemerals(): RampEphemeralsMap { - return readRampEphemerals(); +export function markRampEphemeralsTerminal(rampId: string, observedAt = Date.now()): void { + const entries = readRampEphemerals(observedAt); + const entry = entries[rampId]; + if (!entry || entry.terminalObservedAt !== undefined) { + return; + } + entry.terminalObservedAt = observedAt; + writeRampEphemerals(entries); +} + +export function getStoredRampEphemerals(now = Date.now()): RampEphemeralsMap { + return readRampEphemerals(now); } diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 8a63872b6..82c572ef6 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -47,5 +47,11 @@ the authority for correct instrumentation in this app. `FiatToken` has 6 values (`EURC`, `ARS`, `BRL`, `USD`, `MXN`, `COP`). Any `Record` must include all six or the build fails. Common spots: -`tokenAvailability`, `mapFiatToDestination`, success-page `ARRIVAL_TEXT_BY_TOKEN`, -sep10 `tokenMapping`. +`tokenAvailability`, `mapFiatToDestination`, success-page `ARRIVAL_TEXT_BY_TOKEN`. + +## Documentation + +Follow [`docs/README.md`](../../docs/README.md). Product behavior belongs in the existing +product spec, public partner behavior in `docs/api/`, and security-sensitive flow changes +in `docs/security-spec/`. Do not create implementation plans, progress logs, or duplicate +machine walkthroughs; keep the machine and its tests as the local source. diff --git a/apps/frontend/package.json b/apps/frontend/package.json index dc5679606..2a05b5a3a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -131,7 +131,7 @@ "scripts": { "build": "bun x --bun vite build && cp -R src/assets/coins dist/assets/coins && cp _redirects dist/_redirects", "build-storybook": "storybook build", - "dev": "bun x --bun vite --host", + "dev": "rm -rf node_modules/.vite && bun x --bun vite --host", "preview": "bun x --bun vite preview", "storybook": "storybook dev -p 6006", "test": "vitest", diff --git a/apps/frontend/playwright.config.ts b/apps/frontend/playwright.config.ts index 116d1dcb3..bb4142e79 100644 --- a/apps/frontend/playwright.config.ts +++ b/apps/frontend/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from "@playwright/test"; -// E2E journeys are non-PR-blocking (see docs/testing-strategy.md): they run nightly in CI +// E2E journeys are non-PR-blocking (see docs/operations-testing.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({ diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx index 9e7bd2160..322f549d0 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx @@ -40,8 +40,8 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { const { shouldDisplay: signingBoxVisible, progress, signatureState, confirmations } = useSigningBoxState(); const rampActor = useRampActor(); - const { walletLockedFromState, isSep24Redo } = useSelector(rampActor, state => ({ - isSep24Redo: state.context.isSep24Redo, + const { walletLockedFromState, isQuoteRedo } = useSelector(rampActor, state => ({ + isQuoteRedo: state.context.isQuoteRedo, walletLockedFromState: state.context.walletLocked })); @@ -107,7 +107,7 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { isWalletAddressDisabled={!!walletLockedFromState} signingState={signingState} /> - {isSep24Redo && ( + {isQuoteRedo && (
diff --git a/apps/frontend/src/config/index.ts b/apps/frontend/src/config/index.ts index ccffd8073..89e63e62a 100644 --- a/apps/frontend/src/config/index.ts +++ b/apps/frontend/src/config/index.ts @@ -19,7 +19,6 @@ export const config = { deadlineMinutes: 60 * 24 * 7 // 1 week }, test: { - mockSep24: false, overwriteMinimumTransferAmount: false }, walletConnect: { diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 4543da9d1..725676a37 100644 --- a/apps/frontend/src/contexts/rampState.tsx +++ b/apps/frontend/src/contexts/rampState.tsx @@ -1,6 +1,5 @@ import type { AveniaKycContext } from "@vortexfi/kyc"; import { AlfredpayKycContext } from "@vortexfi/kyc"; -import { EphemeralAccount } from "@vortexfi/shared"; import { createActorContext, useSelector } from "@xstate/react"; import React, { PropsWithChildren, useEffect } from "react"; import { AnyActorRef, Snapshot } from "xstate"; @@ -15,59 +14,14 @@ import { SelectedMykoboData } from "../machines/types"; import { AuthService } from "../services/auth"; +import { markRampEphemeralsTerminal, updateRampEphemeral } from "../services/rampEphemerals"; 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; - evmEphemeral: EphemeralAccount; - timestamp?: number; -}; -type RampEphemeralsMap = Record; - -export function updateRampEphemeral(rampId: string, ephemerals: RampExecutionInput["ephemerals"]): void { - try { - const existing = readRampEphemerals(); - existing[rampId] = { ...ephemerals, timestamp: Date.now() }; - - const keys = Object.keys(existing); - if (keys.length > MAX_RAMP_EPHEMERALS) { - const sorted = keys.sort((a, b) => (existing[a]?.timestamp ?? 0) - (existing[b]?.timestamp ?? 0)); - const toRemove = sorted.slice(0, sorted.length - MAX_RAMP_EPHEMERALS); - for (const key of toRemove) { - delete existing[key]; - } - } - - localStorage.setItem(RAMP_EPHEMERALS_STORAGE_KEY, JSON.stringify(existing)); - } catch { - // localStorage may be full or unavailable — non-critical backup - } -} - -export function readRampEphemerals(): RampEphemeralsMap { - try { - const raw = localStorage.getItem(RAMP_EPHEMERALS_STORAGE_KEY); - return raw ? JSON.parse(raw) : {}; - } catch { - return {}; - } -} - -export function removeRampEphemeral(rampId: string): void { - try { - const existing = readRampEphemerals(); - delete existing[rampId]; - localStorage.setItem(RAMP_EPHEMERALS_STORAGE_KEY, JSON.stringify(existing)); - } catch { - // non-critical - } -} +export { readRampEphemerals, removeRampEphemeral, updateRampEphemeral } from "../services/rampEphemerals"; function readPersistedRampState(): Snapshot | undefined { try { @@ -135,6 +89,17 @@ const PersistenceEffect = () => { const ephemerals = (rampContext.executionInput as RampExecutionInput | undefined)?.ephemerals; if (rampId && ephemerals) { updateRampEphemeral(rampId, ephemerals); + const currentPhase = rampContext.rampState?.ramp?.currentPhase; + const status = rampContext.rampState?.ramp?.status; + if ( + currentPhase === "complete" || + currentPhase === "failed" || + currentPhase === "timedOut" || + status === "COMPLETE" || + status === "FAILED" + ) { + markRampEphemeralsTerminal(rampId); + } } }, [rampContext, rampState, aveniaState, mykoboState, isQuoteExpired, quote, rampActor.getPersistedSnapshot]); diff --git a/apps/frontend/src/hooks/useTokenIcon.ts b/apps/frontend/src/hooks/useTokenIcon.ts index a7dfa09f3..9ba62098a 100644 --- a/apps/frontend/src/hooks/useTokenIcon.ts +++ b/apps/frontend/src/hooks/useTokenIcon.ts @@ -78,7 +78,7 @@ export function useTokenIcon(currencyOrDetails: string | TokenDetails, network?: return useMemo(() => { // Handle token details objects if (typeof currencyOrDetails !== "string") { - // FiatTokenDetails (Stellar or Moonbeam) + // FiatTokenDetails (Moonbeam) if (isFiatTokenDetails(currencyOrDetails)) { return { iconSrc: fiatIcon diff --git a/apps/frontend/src/machines/ramp.context.ts b/apps/frontend/src/machines/ramp.context.ts index be3a20542..0ab847563 100644 --- a/apps/frontend/src/machines/ramp.context.ts +++ b/apps/frontend/src/machines/ramp.context.ts @@ -14,7 +14,7 @@ export const initialRampContext: RampContext = { initializeFailedMessage: undefined, isAuthenticated: false, isQuoteExpired: false, - isSep24Redo: false, + isQuoteRedo: false, kybLink: undefined, partnerId: undefined, paymentData: undefined, diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index f7e51381e..36047f0b4 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -482,7 +482,7 @@ export const rampMachine = setup({ actions: assign({ executionInput: ({ context, event }) => context.executionInput ? { ...context.executionInput, quote: event.quote } : context.executionInput, - isSep24Redo: () => true, + isQuoteRedo: () => true, quote: ({ event }) => event.quote, quoteId: ({ event }) => event.quote.id }), diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index 3befa0dc0..7e33f5a05 100644 --- a/apps/frontend/src/machines/types.ts +++ b/apps/frontend/src/machines/types.ts @@ -36,7 +36,7 @@ export interface RampContext { walletLocked?: string; callbackUrl?: string; externalSessionId?: string; - isSep24Redo?: boolean; + isQuoteRedo?: boolean; errorMessage?: string; kycFormData?: KYCFormData; enteredViaForm?: boolean; // True if user navigated from the Quote form, false if entered via direct URL diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index 9cba0e8e3..1f77a4c8f 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -102,6 +102,7 @@ export const PHASE_FLOWS = { "subsidizePreSwap", "nablaApprove", "nablaSwap", + "distributeFees", "subsidizePostSwap", "squidRouterApprove", "squidRouterSwap", diff --git a/apps/frontend/src/pages/progress/phaseMessages.test.ts b/apps/frontend/src/pages/progress/phaseMessages.test.ts new file mode 100644 index 000000000..1f0c3af6b --- /dev/null +++ b/apps/frontend/src/pages/progress/phaseMessages.test.ts @@ -0,0 +1,85 @@ +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampPhase } from "@vortexfi/shared"; +import { TFunction } from "i18next"; +import { describe, expect, it, vi } from "vitest"; +import { buildQuoteResponse, buildRampProcess } from "../../test/fixtures"; +import { RampState } from "../../types/phases"; +import { getMessageForPhase } from "./phaseMessages"; + +function buildRampState( + phase: RampPhase, + quoteOverrides: Parameters[0] = {} +): RampState { + const quote = buildQuoteResponse(quoteOverrides); + return { + quote, + ramp: buildRampProcess(phase, { + from: quote.from, + inputCurrency: quote.inputCurrency, + outputCurrency: quote.outputCurrency, + to: quote.to, + type: quote.rampType + }), + requiredUserActionsCompleted: true, + signedTransactions: [], + userSigningMeta: {} + }; +} + +function createTranslationSpy() { + return vi.fn(() => "translated") as unknown as TFunction<"translation", undefined>; +} + +describe("getMessageForPhase", () => { + it("describes BRL and EUR SquidRouter transfers as originating on Base", () => { + for (const inputCurrency of [FiatToken.BRL, FiatToken.EURC]) { + const t = createTranslationSpy(); + const ramp = buildRampState("squidRouterSwap", { + inputCurrency, + outputCurrency: EvmToken.USDC, + to: Networks.Arbitrum + }); + + getMessageForPhase(ramp, t); + + expect(t).toHaveBeenCalledWith("pages.progress.squidRouterSwap", { + assetSymbol: "USDC", + fromNetwork: "Base", + toNetwork: "Arbitrum One" + }); + } + }); + + it("describes AlfredPay SquidRouter transfers as originating on Polygon", () => { + const t = createTranslationSpy(); + const ramp = buildRampState("squidRouterPay", { + from: EPaymentMethod.CBU, + inputCurrency: FiatToken.ARS, + outputCurrency: EvmToken.USDC, + to: Networks.Arbitrum + }); + + getMessageForPhase(ramp, t); + + expect(t).toHaveBeenCalledWith("pages.progress.squidRouterSwap", { + assetSymbol: "USDC", + fromNetwork: "Polygon", + toNetwork: "Arbitrum One" + }); + }); + + it("uses same-chain wording when SquidRouter swaps on the destination network", () => { + const t = createTranslationSpy(); + const ramp = buildRampState("squidRouterSwap", { + inputCurrency: FiatToken.BRL, + outputCurrency: EvmToken.USDT, + to: Networks.Base + }); + + getMessageForPhase(ramp, t); + + expect(t).toHaveBeenCalledWith("pages.progress.squidRouterSameChainSwap", { + assetSymbol: "USDT", + network: "Base" + }); + }); +}); diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index fefabad7e..9fab932ba 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -1,8 +1,10 @@ import { FiatToken, getAnyFiatTokenDetails, + getNetworkDisplayName, getNetworkFromDestination, getOnChainTokenDetailsOrDefault, + isAlfredpayToken, Networks, OnChainToken, RampDirection, @@ -20,6 +22,8 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr const fromNetwork = getNetworkFromDestination(quote.from); const toNetwork = getNetworkFromDestination(quote.to); + const fromNetworkDisplayName = (fromNetwork && getNetworkDisplayName(fromNetwork)) || String(quote.from); + const toNetworkDisplayName = (toNetwork && getNetworkDisplayName(toNetwork)) || String(quote.to); const inputAssetSymbol = currentState.type === RampDirection.SELL @@ -42,14 +46,23 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr const getSquidRouterPermitMessage = () => t("pages.progress.squidRouterPermitExecute", { assetSymbol: inputAssetSymbol, - fromNetwork: quote.from + fromNetwork: fromNetworkDisplayName }); - const getSquidRouterSwapMessage = () => - t("pages.progress.squidRouterSwap", { + const squidRouterSourceNetwork = isAlfredpayToken(quote.inputCurrency) ? Networks.Polygon : Networks.Base; + const squidRouterSourceDisplayName = getNetworkDisplayName(squidRouterSourceNetwork); + const getSquidRouterSwapMessage = () => { + if (squidRouterSourceNetwork === toNetwork) { + return t("pages.progress.squidRouterSameChainSwap", { + assetSymbol: outputAssetSymbol, + network: squidRouterSourceDisplayName + }); + } + return t("pages.progress.squidRouterSwap", { assetSymbol: outputAssetSymbol, - fromNetwork: quote.inputCurrency === FiatToken.EURC ? "Polygon" : "Moonbeam", - toNetwork: quote.to === Networks.AssetHub ? "Moonbeam" : toNetwork + fromNetwork: squidRouterSourceDisplayName, + toNetwork: toNetworkDisplayName }); + }; const getTransferringMessage = () => t("pages.progress.transferringToLocalPartner"); const getDestinationTransferMessage = () => t("pages.progress.destinationTransfer", { assetSymbol: outputAssetSymbol }); @@ -98,14 +111,16 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr pendulumToMoonbeamXcm: t("pages.progress.pendulumToMoonbeamXcm", { assetSymbol: outputAssetSymbol }), - squidRouterApprove: getSquidRouterSwapMessage(), + squidRouterApprove: t("pages.progress.squidRouterApprove", { + network: squidRouterSourceDisplayName + }), squidRouterNoPermitApprove: t("pages.progress.squidRouterNoPermitApprove", { assetSymbol: inputAssetSymbol }), squidRouterNoPermitSwap: t("pages.progress.squidRouterNoPermitSwap", { assetSymbol: inputAssetSymbol, - fromNetwork: quote.from, - toNetwork: quote.to + fromNetwork: fromNetworkDisplayName, + toNetwork: toNetworkDisplayName }), squidRouterNoPermitTransfer: t("pages.progress.squidRouterNoPermitTransfer", { assetSymbol: inputAssetSymbol diff --git a/apps/frontend/src/services/api/README.md b/apps/frontend/src/services/api/README.md deleted file mode 100644 index 2f8b21f5f..000000000 --- a/apps/frontend/src/services/api/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# API Services - -This directory contains type-safe service classes for interacting with the backend API endpoints. Each service -corresponds to a specific domain of the API and provides methods for making requests to the endpoints. - -## Structure - -- `api-client.ts`: Base API client with error handling and request/response interceptors -- Service files for each domain: - - `brla.service.ts`: BRLA-related endpoints - - `email.service.ts`: Email storage endpoints - - `moonbeam.service.ts`: Moonbeam-related endpoints - - `pendulum.service.ts`: Pendulum-related endpoints - - `price.service.ts`: Price-related endpoints - - `quote.service.ts`: Quote-related endpoints - - `ramp.service.ts`: Ramp-related endpoints - - `rating.service.ts`: Rating storage endpoints - - `siwe.service.ts`: Sign-In with Ethereum endpoints - - `stellar.service.ts`: Stellar-related endpoints - - `storage.service.ts`: Storage-related endpoints - - `subsidize.service.ts`: Subsidize-related endpoints - -## Usage - -Import the service you need and call its methods: - -```typescript -import { BrlaService } from 'services/api'; - -// Example: Get a user's information -const getUserInfo = async (taxId: string) => { - try { - const response = await BrlaService.getUser(taxId); - console.log('User EVM address:', response.evmAddress); - } catch (error) { - console.error('Failed to get user info:', error); - } -}; -``` - -## Type Safety - -All services use TypeScript interfaces from the `shared` module to ensure type safety between the frontend and backend. -The request and response types are defined in the `shared/src/endpoints` directory. - -## Error Handling - -The base API client includes error handling that formats error messages from the backend. You can also use the -`handleApiError` function for custom error handling: - -```typescript -import { handleApiError } from 'services/api'; - -try { - // Make API request -} catch (error) { - const errorMessage = handleApiError(error, 'Default error message'); - console.error(errorMessage); -} -``` - -## Migrating from Legacy Code - -The legacy API functions in `services/backend.ts` are now deprecated and will be removed in a future release. Use the -new service classes instead. - -Legacy code: - -```typescript -import { requestRampQuote } from 'services/backend'; - -const quote = await requestRampQuote({ - rampType: 'on', - from: 'fiat', - to: 'blockchain', - inputAmount: '100', - inputCurrency: 'brl', - outputCurrency: 'usdc', -}); -``` - -New code: - -```typescript -import { QuoteService } from 'services/api'; - -const quote = await QuoteService.createQuote('on', 'fiat', 'blockchain', '100', 'brl', 'usdc'); -``` diff --git a/apps/frontend/src/services/api/ramp.service.ts b/apps/frontend/src/services/api/ramp.service.ts index 1fcb70ca7..3eb372d98 100644 --- a/apps/frontend/src/services/api/ramp.service.ts +++ b/apps/frontend/src/services/api/ramp.service.ts @@ -123,7 +123,8 @@ export class RampService { status.status === "COMPLETE" || status.status === "FAILED" || status.currentPhase === "complete" || - status.currentPhase === "failed" + status.currentPhase === "failed" || + status.currentPhase === "timedOut" ) { return status; } diff --git a/apps/frontend/src/services/balances/evmBalanceFetcher.ts b/apps/frontend/src/services/balances/evmBalanceFetcher.ts index a72003ec2..564e1b54a 100644 --- a/apps/frontend/src/services/balances/evmBalanceFetcher.ts +++ b/apps/frontend/src/services/balances/evmBalanceFetcher.ts @@ -102,14 +102,17 @@ export async function fetchEvmBalances(evmAddress: string): Promise for (const token of evmTokens) { const addressKey = `${token.network}-${token.erc20AddressSourceChain?.toLowerCase()}`; - const rawBalance = allEvmBalances.get(addressKey); + let balance = "0.00"; + let balanceUsd = "0.00"; + + const rawBalance = allEvmBalances.get(addressKey); const showDecimals = token.assetSymbol.toLowerCase().includes("usd") ? 2 : 6; - const balance = rawBalance ? multiplyByPowerOfTen(Big(rawBalance), -token.decimals).toFixed(showDecimals, 0) : "0.00"; + balance = rawBalance ? multiplyByPowerOfTen(Big(rawBalance), -token.decimals).toFixed(showDecimals, 0) : "0.00"; const matchingToken = evmTokenLookup.get(addressKey); const usdPrice = matchingToken?.usdPrice ?? 0; - const balanceUsd = usdPrice > 0 ? Big(balance).times(usdPrice).toFixed(2, 0) : "0.00"; + balanceUsd = usdPrice > 0 ? Big(balance).times(usdPrice).toFixed(2, 0) : "0.00"; const balanceKey = getBalanceKey(token.network, token.assetSymbol); newBalances.set(balanceKey, { balance, balanceUsd }); diff --git a/apps/frontend/src/services/rampEphemerals.test.ts b/apps/frontend/src/services/rampEphemerals.test.ts new file mode 100644 index 000000000..ba5cf46f2 --- /dev/null +++ b/apps/frontend/src/services/rampEphemerals.test.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { + markRampEphemeralsTerminal, + readRampEphemerals, + TERMINAL_EPHEMERAL_RETENTION_MS, + updateRampEphemeral +} from "./rampEphemerals"; + +const originalLocalStorage = globalThis.localStorage; +const values = new Map(); +const localStorageMock: Storage = { + clear: () => values.clear(), + getItem: key => values.get(key) ?? null, + key: index => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + removeItem: key => values.delete(key), + setItem: (key, value) => values.set(key, value) +}; + +Object.defineProperty(globalThis, "localStorage", { configurable: true, value: localStorageMock }); + +describe("ramp ephemeral storage", () => { + beforeEach(() => localStorage.clear()); + afterAll(() => { + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: originalLocalStorage }); + }); + + it("migrates legacy entries and retains unresolved ramps indefinitely", () => { + localStorage.setItem( + "rampEphemerals", + JSON.stringify({ + legacy: { + evmEphemeral: { address: "0xlegacy", secret: "0xlegacy-secret" }, + substrateEphemeral: { address: "legacy-substrate", secret: "legacy mnemonic" }, + timestamp: 1 + } + }) + ); + + expect(readRampEphemerals(Number.MAX_SAFE_INTEGER).legacy?.evmEphemeral.secret).toBe("0xlegacy-secret"); + }); + + it("prunes a terminal ramp after 90 days but preserves its original terminal timestamp", () => { + const observedAt = 1_000_000; + updateRampEphemeral("ramp-id", { + evmEphemeral: { address: "0xcurrent", secret: "0xcurrent-secret" }, + substrateEphemeral: { address: "current-substrate", secret: "current mnemonic" } + }); + + markRampEphemeralsTerminal("ramp-id", observedAt); + markRampEphemeralsTerminal("ramp-id", observedAt + 100); + + expect(readRampEphemerals(observedAt + TERMINAL_EPHEMERAL_RETENTION_MS - 1)["ramp-id"]?.terminalObservedAt).toBe( + observedAt + ); + expect(readRampEphemerals(observedAt + TERMINAL_EPHEMERAL_RETENTION_MS)["ramp-id"]).toBeUndefined(); + }); +}); diff --git a/apps/frontend/src/services/rampEphemerals.ts b/apps/frontend/src/services/rampEphemerals.ts new file mode 100644 index 000000000..c7c078a3a --- /dev/null +++ b/apps/frontend/src/services/rampEphemerals.ts @@ -0,0 +1,80 @@ +import type { EphemeralAccount } from "@vortexfi/shared"; +import type { RampExecutionInput } from "../types/phases"; + +const RAMP_EPHEMERALS_STORAGE_KEY = "rampEphemerals"; +export const TERMINAL_EPHEMERAL_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; + +export type RampEphemeralEntry = { + substrateEphemeral: EphemeralAccount; + evmEphemeral: EphemeralAccount; + timestamp?: number; + terminalObservedAt?: number; +}; + +export type RampEphemeralsMap = Record; + +function writeRampEphemerals(entries: RampEphemeralsMap): void { + localStorage.setItem(RAMP_EPHEMERALS_STORAGE_KEY, JSON.stringify(entries)); +} + +function pruneExpiredTerminalEntries(entries: RampEphemeralsMap, now: number): boolean { + let changed = false; + for (const [rampId, entry] of Object.entries(entries)) { + if (entry.terminalObservedAt !== undefined && now - entry.terminalObservedAt >= TERMINAL_EPHEMERAL_RETENTION_MS) { + delete entries[rampId]; + changed = true; + } + } + return changed; +} + +export function readRampEphemerals(now = Date.now()): RampEphemeralsMap { + try { + const raw = localStorage.getItem(RAMP_EPHEMERALS_STORAGE_KEY); + const entries = raw ? (JSON.parse(raw) as RampEphemeralsMap) : {}; + if (pruneExpiredTerminalEntries(entries, now)) { + writeRampEphemerals(entries); + } + return entries; + } catch { + return {}; + } +} + +export function updateRampEphemeral(rampId: string, ephemerals: RampExecutionInput["ephemerals"]): void { + try { + const existing = readRampEphemerals(); + existing[rampId] = { + ...ephemerals, + terminalObservedAt: existing[rampId]?.terminalObservedAt, + timestamp: existing[rampId]?.timestamp ?? Date.now() + }; + writeRampEphemerals(existing); + } catch { + // localStorage may be full or unavailable — non-critical backup + } +} + +export function markRampEphemeralsTerminal(rampId: string, observedAt = Date.now()): void { + try { + const existing = readRampEphemerals(observedAt); + const entry = existing[rampId]; + if (!entry || entry.terminalObservedAt !== undefined) { + return; + } + entry.terminalObservedAt = observedAt; + writeRampEphemerals(existing); + } catch { + // localStorage may be full or unavailable — non-critical backup + } +} + +export function removeRampEphemeral(rampId: string): void { + try { + const existing = readRampEphemerals(); + delete existing[rampId]; + writeRampEphemerals(existing); + } catch { + // non-critical + } +} diff --git a/apps/frontend/src/stories/ErrorStep.stories.tsx b/apps/frontend/src/stories/ErrorStep.stories.tsx index a1cca81ba..3e05faa12 100644 --- a/apps/frontend/src/stories/ErrorStep.stories.tsx +++ b/apps/frontend/src/stories/ErrorStep.stories.tsx @@ -17,7 +17,7 @@ const createErrorSnapshot = (params: { apiKey?: string; errorMessage?: string }) getMessageSignature: undefined, initializeFailedMessage: undefined, isQuoteExpired: false, - isSep24Redo: false, + isQuoteRedo: false, partnerId: undefined, paymentData: undefined, quote: undefined, diff --git a/apps/frontend/src/stories/InitialQuoteFailedStep.stories.tsx b/apps/frontend/src/stories/InitialQuoteFailedStep.stories.tsx index 5ce0c2f4f..651d33af4 100644 --- a/apps/frontend/src/stories/InitialQuoteFailedStep.stories.tsx +++ b/apps/frontend/src/stories/InitialQuoteFailedStep.stories.tsx @@ -17,7 +17,7 @@ const createSnapshot = (params: { callbackUrl?: string; apiKey?: string; partner getMessageSignature: undefined, initializeFailedMessage: undefined, isQuoteExpired: false, - isSep24Redo: false, + isQuoteRedo: false, partnerId: params.partnerId, paymentData: undefined, quote: undefined, diff --git a/apps/frontend/src/stories/Menu.stories.tsx b/apps/frontend/src/stories/Menu.stories.tsx index e2ff8ae46..07b843a9e 100644 --- a/apps/frontend/src/stories/Menu.stories.tsx +++ b/apps/frontend/src/stories/Menu.stories.tsx @@ -100,7 +100,7 @@ const TokenSelectionDemo = () => { const tokens = [ { balance: "1,234.56", name: "USDC", network: "Polkadot" }, { balance: "567.89", name: "USDT", network: "Ethereum" }, - { balance: "100.00", name: "BRZ", network: "Stellar" } + { balance: "100.00", name: "BRZ", network: "Base" } ]; return ( diff --git a/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx b/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx index 96fe25b4e..f4983dcd0 100644 --- a/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx +++ b/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx @@ -8,7 +8,7 @@ import { SelectionDropdownMotion } from "../components/TokenSelection/NetworkSel const networks = [ { icon: "polkadot.svg", id: "polkadot", name: "Polkadot" }, { icon: "ethereum.svg", id: "ethereum", name: "Ethereum" }, - { icon: "stellar.svg", id: "stellar", name: "Stellar" }, + { icon: "base.svg", id: "base", name: "Base" }, { icon: "moonbeam.svg", id: "moonbeam", name: "Moonbeam" } ]; diff --git a/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx b/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx index b698aa76e..2e41a03e2 100644 --- a/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx +++ b/apps/frontend/src/stories/RampFollowUpRedirectStep.stories.tsx @@ -20,7 +20,7 @@ const createSnapshot = (callbackUrl = "https://example.com/callback") => ({ getMessageSignature: undefined, initializeFailedMessage: undefined, isQuoteExpired: false, - isSep24Redo: false, + isQuoteRedo: false, partnerId: undefined, paymentData: undefined, quote: undefined, diff --git a/apps/frontend/src/stories/SearchInput.stories.tsx b/apps/frontend/src/stories/SearchInput.stories.tsx index a50d58afc..8a30841c1 100644 --- a/apps/frontend/src/stories/SearchInput.stories.tsx +++ b/apps/frontend/src/stories/SearchInput.stories.tsx @@ -127,7 +127,7 @@ export const WithFilteredList: Story = { "Bitcoin (BTC)", "Ethereum (ETH)", "Polkadot (DOT)", - "Stellar (XLM)", + "Polygon (POL)", "USDC", "USDT", "DAI", diff --git a/apps/frontend/src/test/fakeRampActor.ts b/apps/frontend/src/test/fakeRampActor.ts index 16cbb27dd..a6ccdb875 100644 --- a/apps/frontend/src/test/fakeRampActor.ts +++ b/apps/frontend/src/test/fakeRampActor.ts @@ -2,7 +2,7 @@ import { RampState } from "../types/phases"; interface FakeRampContext { initializeFailedMessage?: string; - isSep24Redo?: boolean; + isQuoteRedo?: boolean; rampState?: RampState; walletLocked?: string; } diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index a8046c17d..0ba943eff 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1498,11 +1498,9 @@ "bridgingEVM": "Bridging {{assetSymbol}} from {{network}} --> Moonbeam", "brlaOnrampMint": "Your payment is being processed. This can take up to 5 minutes.", "closeProgressScreenText": "You’re all set! You can now close this tab or grab a coffee while we finish up in the background.", - "createStellarAccount": "Creating Stellar account", "destinationTransfer": "Transferring {{assetSymbol}} to your wallet", "estimatedTimeAssetHub": "This usually takes 4-6 minutes.", "estimatedTimeEVM": "This usually takes 6-8 minutes.", - "executeSpacewalkRedeem": "Bridging {{assetSymbol}} to Stellar via Spacewalk", "fundEphemeral": "Funding ephemeral accounts", "hydrationSwap": "Swapping {{inputAssetSymbol}} to {{outputAssetSymbol}} on Hydration DEX", "hydrationToAssethubXcm": "Transferring {{assetSymbol}} from Hydration --> AssetHub", @@ -1513,12 +1511,13 @@ "pendulumToAssethubXcm": "Transferring {{assetSymbol}} from Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferring {{assetSymbol}} from Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferring {{assetSymbol}} from Pendulum --> Moonbeam", + "squidRouterApprove": "Approving funds for transfer on {{network}}", "squidRouterNoPermitApprove": "Approving {{assetSymbol}} for cross-chain transfer", "squidRouterNoPermitSwap": "Transferring {{assetSymbol}} from {{fromNetwork}} to {{toNetwork}}", "squidRouterNoPermitTransfer": "Transferring {{assetSymbol}} to Vortex", "squidRouterPermitExecute": "Initializing the transfer of {{assetSymbol}} from {{fromNetwork}}", + "squidRouterSameChainSwap": "Preparing {{assetSymbol}} on {{network}}", "squidRouterSwap": "Transferring {{assetSymbol}} from {{fromNetwork}} to {{toNetwork}}", - "stellarPayment": "Transferring {{assetSymbol}} from Stellar --> local partner", "success": "Transaction completed successfully!", "swappingTo": "Swapping to {{assetSymbol}} on Vortex DEX", "transactionInProgress": "Your transaction is in progress.", @@ -1746,7 +1745,7 @@ "title": "Trusted by" }, "whyVortex": { - "description": "Vortex is a non-custodial exchange that allows you to swap between different cryptocurrencies. It is built on top of the Stellar blockchain and uses the Stellar SDK to interact with the Stellar network.", + "description": "Vortex is a non-custodial exchange that allows you to swap between different cryptocurrencies. It is built on top of the Pendulum blockchain and uses cross-chain swaps to move funds between networks.", "features": { "easyToUse": { "description": "Buy & Sell your crypto easily without the need for a centralized exchange.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 588b357b9..1a29211ad 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1502,11 +1502,9 @@ "bridgingEVM": "Transferindo {{assetSymbol}} de {{network}} --> Moonbeam", "brlaOnrampMint": "Seu pagamento está sendo processado. Isso pode levar até 5 minutos.", "closeProgressScreenText": "Tudo pronto! Você já pode fechar esta aba ou pegar um café enquanto finalizamos o processo em segundo plano.", - "createStellarAccount": "Criando conta Stellar", "destinationTransfer": "Transferindo {{assetSymbol}} para sua carteira", "estimatedTimeAssetHub": "Isso geralmente leva de 4 a 6 minutos.", "estimatedTimeEVM": "Isso geralmente leva de 6 a 8 minutos.", - "executeSpacewalkRedeem": "Transferindo {{assetSymbol}} para Stellar via Spacewalk", "fundEphemeral": "Financiando contas efêmeras", "hydrationSwap": "Trocando {{inputAssetSymbol}} por {{outputAssetSymbol}} no Hydration DEX", "hydrationToAssethubXcm": "Transferindo {{assetSymbol}} de Hydration --> AssetHub", @@ -1517,12 +1515,13 @@ "pendulumToAssethubXcm": "Transferindo {{assetSymbol}} de Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferindo {{assetSymbol}} de Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferindo {{assetSymbol}} de Pendulum --> Moonbeam", + "squidRouterApprove": "Aprovando fundos para transferência na {{network}}", "squidRouterNoPermitApprove": "Aprovando {{assetSymbol}} para transferência cross-chain", "squidRouterNoPermitSwap": "Transferindo {{assetSymbol}} de {{fromNetwork}} para {{toNetwork}}", "squidRouterNoPermitTransfer": "Transferindo {{assetSymbol}} para Vortex", "squidRouterPermitExecute": "Autorizando transferência de {{assetSymbol}} de {{fromNetwork}}", + "squidRouterSameChainSwap": "Preparando {{assetSymbol}} na {{network}}", "squidRouterSwap": "Transferindo {{assetSymbol}} de {{fromNetwork}} para {{toNetwork}}", - "stellarPayment": "Transferindo {{assetSymbol}} de Stellar --> parceiro local", "success": "Transação concluída com sucesso!", "swappingTo": "Trocando para {{assetSymbol}} no Vortex DEX", "transactionInProgress": "Sua transação está em andamento.", diff --git a/apps/rebalancer/CLAUDE.md b/apps/rebalancer/CLAUDE.md index 4643aa79f..db2ad644b 100644 --- a/apps/rebalancer/CLAUDE.md +++ b/apps/rebalancer/CLAUDE.md @@ -14,3 +14,9 @@ Lint from root with `bun lint:fix`, or `bunx @biomejs/biome check apps/rebalance Depends on `@vortexfi/shared` — after changing `packages/shared`, run `bun build:shared` (from root) before running this service. + +## Documentation + +Follow [`docs/README.md`](../../docs/README.md). Keep setup that operators need in the +local README and maintain security/behavioral requirements in +`docs/security-spec/07-operations/rebalancer.md`. Do not add session plans or progress logs. diff --git a/bun.lock b/bun.lock index df1d623b1..5f8a2d744 100644 --- a/bun.lock +++ b/bun.lock @@ -305,7 +305,7 @@ "devDependencies": { "@nomicfoundation/hardhat-ignition": "^0.15.9", "@nomicfoundation/hardhat-toolbox": "^5.0.0", - "@openzeppelin/contracts": "^5.2.0", + "@openzeppelin/contracts": "5.6.1", "@types/node": "catalog:", "hardhat": "^2.22.17", "ts-node": "^10.9.2", diff --git a/contracts/relayer/SECURITY_AUDIT.md b/contracts/relayer/SECURITY_AUDIT.md deleted file mode 100644 index 00bb0fca9..000000000 --- a/contracts/relayer/SECURITY_AUDIT.md +++ /dev/null @@ -1,332 +0,0 @@ -# Security Audit Report — `TokenRelayer.sol` - -**Date:** 2026-03-04 -**Auditor:** AI Security Review -**Contract:** `TokenRelayer.sol` (175 lines, Solidity ^0.8.20) -**Scope:** Full contract review including signature verification, token handling, access control, and reentrancy vectors. - ---- - -## Summary - -The `TokenRelayer` contract acts as a meta-transaction relayer. It accepts an ERC-20 `permit` signature and a custom EIP-712 "Payload" signature, then: -1. Calls `permit()` to set a token allowance from `owner → relayer` -2. Calls `transferFrom()` to pull tokens into the relayer -3. Forwards an arbitrary `call` to a fixed `destinationContract` - -The contract has **several findings** ranging from critical to informational severity. - -| Severity | Count | -|---|---| -| 🔴 Critical | 2 | -| 🟠 High | 2 | -| 🟡 Medium | 3 | -| 🔵 Low | 2 | -| ⚪ Informational | 3 | - ---- - -## 🔴 Critical Findings - -### C-1: Reentrancy in `execute()` — State Changes After External Calls - -**Location:** Lines 62–97 (`execute` function) - -**Description:** -The function performs external calls (`permit`, `transferFrom`, `approve`, and the forwarded `destinationContract.call`) **before** marking the execution as completed on line 93: - -```solidity -executedCalls[keccak256(abi.encodePacked(owner, nonce))] = true; // line 93 -``` - -While the nonce is marked as used on line 69 (before external calls), the `executedCalls` mapping is updated after all external calls. More critically, the `_forwardCall` on line 89 makes a low-level `.call()` to an external contract with arbitrary `data`, which can trigger a reentrant call back into the relayer. - -**Impact:** If the `destinationContract` is malicious or compromised, it could reenter `execute()` with different parameters. The nonce check mitigates replay of the *same* nonce, but reentrancy could interact with other state in unexpected ways (e.g., draining residual token balances held by the contract). - -**Recommendation:** -- Add OpenZeppelin's `ReentrancyGuard` and apply the `nonReentrant` modifier to `execute()`. -- Move all state changes before external calls (Checks-Effects-Interactions pattern). - -```solidity -import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; - -contract TokenRelayer is ReentrancyGuard { - function execute(ExecuteParams calldata params) external payable nonReentrant returns (bool) { - // ... nonce + signature checks ... - - // Effects BEFORE interactions - executedCalls[keccak256(abi.encodePacked(owner, nonce))] = true; - - // Interactions - _executePermitAndSelfTransfer(...); - bool callSuccess = _forwardCall(params.payloadData, msg.value); - // ... - } -} -``` - ---- - -### C-2: Signature Malleability — Missing `s` Value Validation in `ecrecover` - -**Location:** Lines 123–127 (`_recoverSigner`) - -**Description:** -The `ecrecover` precompile is susceptible to **signature malleability**. For any valid signature `(v, r, s)`, there exists a second valid signature `(v', r, s')` where `s' = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s`. The contract does not validate that `s` is in the lower half of the curve order. - -An attacker who observes a valid signature in the mempool can compute the malleable counterpart. Although the nonce prevents **replay** of the exact same parameters, the malleable signature could be used in a front-running scenario — an attacker submits the transaction with the alternate signature before the legitimate relayer, potentially causing the relayer's transaction to revert (griefing). - -**Impact:** Signature griefing / front-running. The relayer's legitimate transaction can be front-run and replaced by an attacker using the malleable signature. - -**Recommendation:** -Use OpenZeppelin's `ECDSA.recover()` which enforces `s` to be in the lower half of the secp256k1 curve: - -```solidity -import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; - -function _recoverSigner(bytes32 digest, uint8 v, bytes32 r, bytes32 s) private pure returns (address) { - return ECDSA.recover(digest, v, r, s); -} -``` - ---- - -## 🟠 High Findings - -### H-1: Unlimited Token Approval to `destinationContract` - -**Location:** Lines 148–152 (`_executePermitAndSelfTransfer`) - -```solidity -if (!tokenApproved[token]) { - IERC20(token).approve(destinationContract, type(uint256).max); - tokenApproved[token] = true; -} -``` - -**Description:** -The relayer approves `type(uint256).max` tokens to the `destinationContract` the first time any token is used. This is a **permanent, unlimited approval**. If the `destinationContract` is upgradeable (proxy pattern), compromised, or has any vulnerability, it can drain **all tokens of every approved type** held by the relayer at any point in the future. - -**Impact:** Total loss of all tokens held by the relayer contract if `destinationContract` is ever compromised. - -**Recommendation:** -Approve only the exact amount needed per transaction instead of `type(uint256).max`: - -```solidity -IERC20(token).approve(destinationContract, 0); // reset first (for tokens like USDT) -IERC20(token).approve(destinationContract, value); -``` - -Or revoke the approval after the forwarded call completes: - -```solidity -IERC20(token).approve(destinationContract, value); -_forwardCall(params.payloadData, msg.value); -IERC20(token).approve(destinationContract, 0); // revoke -``` - ---- - -### H-2: Arbitrary Call Execution — No Payload Data Validation - -**Location:** Lines 156–158 (`_forwardCall`) - -```solidity -function _forwardCall(bytes memory data, uint256 value) internal returns (bool) { - (bool success, ) = destinationContract.call{value: value}(data); - return success; -} -``` - -**Description:** -The forwarded call sends **arbitrary calldata** to the `destinationContract`. The only constraint is that the `owner` signed the payload, but there is no validation of *what* the payload does. The signed payload includes `destination` in the EIP-712 struct, but **this `destination` field is never checked against `destinationContract`** — it is only hashed into the digest for signature verification. - -This means: -- The user signs a payload specifying `destination: 0xABC`, but the contract always forwards to the immutable `destinationContract`, regardless of what was signed. -- If the user believes their signed payload targets a specific contract but the relayer's `destinationContract` is different, the signed data would be executed on an unintended target. - -**Impact:** User intent mismatch. The signed `destination` field provides no actual routing guarantee. Users may be misled about which contract will execute their data. - -**Recommendation:** -Either: -1. Verify that the signed `destination` matches `destinationContract`: -```solidity -// In _computeDigest or execute: -require(signedDestination == destinationContract, "Destination mismatch"); -``` -2. Or remove `destination` from the signed payload struct if it's always `destinationContract`. - ---- - -## 🟡 Medium Findings - -### M-1: No `receive()` or `fallback()` — ETH Can Be Trapped - -**Location:** Contract-wide - -**Description:** -The `execute()` function is `payable` and forwards `msg.value` via `_forwardCall`. However, if the forwarded call returns **less ETH than was sent** (partial refund) or if ETH is sent to the contract by any other means, there is **no mechanism to recover native ETH**. The contract has no `receive()` function, no `fallback()`, and the `withdrawToken()` function only handles ERC-20 tokens. - -**Impact:** Native ETH sent to or trapped in the contract is permanently lost. - -**Recommendation:** -Add an ETH withdrawal function for the deployer: - -```solidity -function withdrawETH(uint256 amount) external { - require(msg.sender == deployer, "Only deployer"); - (bool success, ) = deployer.call{value: amount}(""); - require(success, "ETH transfer failed"); -} - -receive() external payable {} -``` - ---- - -### M-2: `permit()` Front-Running / DoS Vector - -**Location:** Line 143 (`_executePermitAndSelfTransfer`) - -```solidity -IERC20Permit(token).permit(owner, address(this), value, deadline, v, r, s); -``` - -**Description:** -The `permit()` call can be front-run. An attacker who sees the transaction in the mempool can extract the permit signature and call `permit()` directly on the token contract before the relayer's transaction executes. When the relayer's `execute()` then calls `permit()`, it will **revert** because the nonce has already been consumed. - -This is a known issue with ERC-2612 permit. The actual allowance is still set correctly (the front-runner sets it), but the relayer's transaction reverts, causing a DoS. - -**Impact:** Griefing / DoS — legitimate relayer transactions can be blocked. - -**Recommendation:** -Wrap the `permit()` call in a try-catch so that if it reverts (because someone front-ran it), the function can still proceed if the allowance is already sufficient: - -```solidity -try IERC20Permit(token).permit(owner, address(this), value, deadline, v, r, s) { - // permit succeeded -} catch { - // permit was front-run, check allowance is sufficient - require( - IERC20(token).allowance(owner, address(this)) >= value, - "Permit failed and insufficient allowance" - ); -} -``` - ---- - -### M-3: Missing `payloadValue` in Test ABI — Potential Integration Bug - -**Location:** Test file `relayer-execution.ts`, line 73–101 - -**Description:** -The test ABI for `tokenRelayerAbi` is **missing the `payloadValue` field** in the `ExecuteParams` struct. The actual contract expects a `payloadValue` field (line 42 in the contract), but the test ABI omits it. This means the test is constructing transactions with an incorrect ABI, which would either fail at runtime or encode data incorrectly. - -**Impact:** Tests may not accurately validate the contract's behavior, masking bugs. - -**Recommendation:** -Update the test ABI to include `{ name: "payloadValue", type: "uint256" }` in the struct components, between `payloadData` and `payloadNonce`. - ---- - -## 🔵 Low Findings - -### L-1: Redundant `executedCalls` Mapping - -**Location:** Lines 30 and 93 - -**Description:** -The `executedCalls` mapping tracks `keccak256(owner, nonce) → bool`, but the `usedPayloadNonces` mapping on line 29 already tracks `owner → nonce → bool` and is checked first (line 67). Both store essentially the same information — whether a given `(owner, nonce)` pair has been used. - -**Impact:** Unnecessary gas cost (~20,000 gas for SSTORE) on every `execute()` call. No security impact, but adds code complexity and gas overhead. - -**Recommendation:** -Remove `executedCalls` and use `usedPayloadNonces` for the `isExecutionCompleted` query: - -```solidity -function isExecutionCompleted(address signer, uint256 nonce) external view returns (bool) { - return usedPayloadNonces[signer][nonce]; -} -``` - ---- - -### L-2: No Event for `withdrawToken` - -**Location:** Lines 166–169 - -**Description:** -The `withdrawToken()` function transfers tokens to the deployer but emits no event. This makes it harder to monitor and audit token movements from the contract. - -**Recommendation:** -Add an event: - -```solidity -event TokenWithdrawn(address indexed token, uint256 amount, address indexed to); - -function withdrawToken(address token, uint256 amount) external { - require(msg.sender == deployer, "Only deployer"); - require(IERC20(token).transfer(deployer, amount), "Transfer failed"); - emit TokenWithdrawn(token, amount, deployer); -} -``` - ---- - -## ⚪ Informational Findings - -### I-1: No Access Control Library Used - -The contract rolls its own deployer-based access control (`deployer` + manual `require` checks) instead of using OpenZeppelin's `Ownable` or `AccessControl`. While functionally correct for a single-admin pattern, using a battle-tested library reduces risk of mistakes and provides standard interfaces (e.g., ownership transfer). - ---- - -### I-2: `execute()` Returns Redundant Value - -The `execute()` function returns `callSuccess` (line 96), but line 90 already `require(callSuccess, ...)`. If the call fails, the function reverts, so it can only ever return `true`. The return value is misleading. - -**Recommendation:** Either remove the return value or remove the require and let callers handle failures. - ---- - -### I-3: Consider Using EIP-712 Helpers from OpenZeppelin - -The contract manually constructs the EIP-712 domain separator and digest. OpenZeppelin provides `EIP712` abstract contract that handles domain separator caching, chain ID changes on forks, and proper hashing — reducing the surface area for bugs. - -```solidity -import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; - -contract TokenRelayer is EIP712 { - constructor(address _dest) EIP712("TokenRelayer", "1") { - // ... - } -} -``` - ---- - -## Recommendations Summary - -| # | Finding | Severity | Fix Effort | -|---|---|---|---| -| C-1 | Add `ReentrancyGuard` + CEI pattern | 🔴 Critical | Low | -| C-2 | Use OZ `ECDSA.recover()` for malleability protection | 🔴 Critical | Low | -| H-1 | Replace `type(uint256).max` approval with exact amounts | 🟠 High | Low | -| H-2 | Validate signed `destination` matches `destinationContract` | 🟠 High | Low | -| M-1 | Add ETH recovery mechanism | 🟡 Medium | Low | -| M-2 | Wrap `permit()` in try-catch for front-run resilience | 🟡 Medium | Low | -| M-3 | Fix test ABI to include `payloadValue` | 🟡 Medium | Low | -| L-1 | Remove redundant `executedCalls` mapping | 🔵 Low | Low | -| L-2 | Add event to `withdrawToken` | 🔵 Low | Low | -| I-1 | Use OpenZeppelin `Ownable` | ⚪ Info | Low | -| I-2 | Remove redundant return from `execute()` | ⚪ Info | Low | -| I-3 | Use OpenZeppelin `EIP712` helper | ⚪ Info | Medium | - ---- - -> [!CAUTION] -> **C-1 (Reentrancy)** and **C-2 (Signature Malleability)** should be addressed before any mainnet deployment. Both have low fix effort and high impact. - -> [!WARNING] -> **H-1 (Unlimited Approval)** is particularly dangerous if `destinationContract` is upgradeable or could be compromised in the future. diff --git a/contracts/relayer/contracts/TokenRelayer.sol b/contracts/relayer/contracts/TokenRelayer.sol index 828417656..09ef18e12 100644 --- a/contracts/relayer/contracts/TokenRelayer.sol +++ b/contracts/relayer/contracts/TokenRelayer.sol @@ -30,6 +30,19 @@ contract TokenRelayer is Ownable, ReentrancyGuard, EIP712 { "Payload(address destination,address owner,address token,uint256 value,bytes data,uint256 ethValue,uint256 nonce,uint256 deadline)" ); + error InvalidDestination(address destination); + error NativeRefundFailed(address recipient, uint256 amount); + error TokenBalanceNotRestored( + address token, + uint256 balanceBefore, + uint256 balanceAfter + ); + error TokenReceiptMismatch( + address token, + uint256 requested, + uint256 received + ); + address public immutable destinationContract; mapping(address => mapping(uint256 => bool)) public usedPayloadNonces; @@ -57,6 +70,14 @@ contract TokenRelayer is Ownable, ReentrancyGuard, EIP712 { address indexed token, uint256 amount ); + event RelayerTransferObserved( + address indexed signer, + address indexed token, + uint256 requested, + uint256 received, + uint256 consumed + ); + event NativeRefunded(address indexed executor, uint256 amount); // Events for withdrawal operations event TokenWithdrawn(address indexed token, uint256 amount, address indexed to); @@ -67,7 +88,12 @@ contract TokenRelayer is Ownable, ReentrancyGuard, EIP712 { Ownable(msg.sender) EIP712("TokenRelayer", "1") { - require(_destinationContract != address(0), "Invalid destination"); + if ( + _destinationContract == address(0) || + _destinationContract.code.length == 0 + ) { + revert InvalidDestination(_destinationContract); + } destinationContract = _destinationContract; } @@ -79,6 +105,7 @@ contract TokenRelayer is Ownable, ReentrancyGuard, EIP712 { function execute(ExecuteParams calldata params) external payable nonReentrant { address owner = params.owner; uint256 nonce = params.payloadNonce; + IERC20 token = IERC20(params.token); // --- Checks --- require(owner != address(0), "Invalid owner"); @@ -100,6 +127,8 @@ contract TokenRelayer is Ownable, ReentrancyGuard, EIP712 { require(ECDSA.recover(digest, params.payloadV, params.payloadR, params.payloadS) == owner, "Invalid sig"); require(msg.value == params.payloadValue, "Incorrect ETH value provided"); + uint256 tokenBalanceBefore = token.balanceOf(address(this)); + uint256 ethBalanceBefore = address(this).balance - msg.value; // --- Effects (before interactions per CEI pattern) --- // State changes before any external calls @@ -107,7 +136,7 @@ contract TokenRelayer is Ownable, ReentrancyGuard, EIP712 { // --- Interactions --- // permit wrapped in try-catch for front-run resilience - _executePermitAndTransfer( + uint256 received = _executePermitAndTransfer( params.token, owner, params.value, @@ -116,16 +145,46 @@ contract TokenRelayer is Ownable, ReentrancyGuard, EIP712 { params.permitR, params.permitS ); + if (received != params.value) { + revert TokenReceiptMismatch(params.token, params.value, received); + } - // Approve exact amount, forward call, then revoke - IERC20(params.token).forceApprove(destinationContract, params.value); + // Approve no more than this execution actually contributed, forward the signed + // call, then revoke. The post-call balance check prevents both cross-execution + // subsidy and successful partial consumption from contaminating the shared balance. + token.forceApprove(destinationContract, received); bool callSuccess = _forwardCall(params.payloadData, msg.value); require(callSuccess, "Call failed"); // Revoke approval after the call to prevent residual allowance - IERC20(params.token).forceApprove(destinationContract, 0); + token.forceApprove(destinationContract, 0); + uint256 tokenBalanceAfter = token.balanceOf(address(this)); + if (tokenBalanceAfter != tokenBalanceBefore) { + revert TokenBalanceNotRestored( + params.token, + tokenBalanceBefore, + tokenBalanceAfter + ); + } + + uint256 nativeRefund = address(this).balance - ethBalanceBefore; + if (nativeRefund > 0) { + (bool refundSuccess, ) = msg.sender.call{value: nativeRefund}(""); + if (!refundSuccess) { + revert NativeRefundFailed(msg.sender, nativeRefund); + } + emit NativeRefunded(msg.sender, nativeRefund); + } + + emit RelayerTransferObserved( + owner, + params.token, + params.value, + received, + received + ); emit RelayerExecuted(owner, params.token, params.value); } @@ -167,7 +226,7 @@ contract TokenRelayer is Ownable, ReentrancyGuard, EIP712 { uint8 v, bytes32 r, bytes32 s - ) internal { + ) internal returns (uint256 received) { // Wrap permit in try-catch for front-run resilience try IERC20Permit(token).permit(owner, address(this), value, deadline, v, r, s) { // permit succeeded @@ -179,11 +238,21 @@ contract TokenRelayer is Ownable, ReentrancyGuard, EIP712 { ); } - // Transfer tokens from owner to this contract + // Attribute only the balance increase from this execution. A nominal ERC-20 + // transfer amount is not proof of receipt for fee-on-transfer or rebasing tokens. + uint256 balanceBefore = IERC20(token).balanceOf(address(this)); IERC20(token).safeTransferFrom(owner, address(this), value); + uint256 balanceAfter = IERC20(token).balanceOf(address(this)); + if (balanceAfter < balanceBefore) { + revert TokenReceiptMismatch(token, value, 0); + } + return balanceAfter - balanceBefore; } function _forwardCall(bytes memory data, uint256 value) internal returns (bool) { + if (destinationContract.code.length == 0) { + return false; + } (bool success, ) = destinationContract.call{value: value}(data); return success; } diff --git a/contracts/relayer/contracts/mocks/MockERC20Permit.sol b/contracts/relayer/contracts/mocks/MockERC20Permit.sol new file mode 100644 index 000000000..c435d2208 --- /dev/null +++ b/contracts/relayer/contracts/mocks/MockERC20Permit.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; + +contract MockERC20Permit is ERC20, ERC20Permit { + uint256 public feeBps; + + constructor() + ERC20("Mock Permit Token", "MPT") + ERC20Permit("Mock Permit Token") + {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setFeeBps(uint256 newFeeBps) external { + require(newFeeBps <= 10_000, "fee too large"); + feeBps = newFeeBps; + } + + function _update( + address from, + address to, + uint256 value + ) internal override { + if ( + feeBps > 0 && + from != address(0) && + to != address(0) + ) { + uint256 fee = (value * feeBps) / 10_000; + super._update(from, address(0), fee); + super._update(from, to, value - fee); + return; + } + super._update(from, to, value); + } +} diff --git a/contracts/relayer/contracts/mocks/MockRelayerDestination.sol b/contracts/relayer/contracts/mocks/MockRelayerDestination.sol new file mode 100644 index 000000000..47d0dcbaa --- /dev/null +++ b/contracts/relayer/contracts/mocks/MockRelayerDestination.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract MockRelayerDestination { + using SafeERC20 for IERC20; + + function pull( + address token, + address recipient, + uint256 amount + ) external { + _pull(token, recipient, amount); + } + + function pullAndRefund( + address token, + address recipient, + uint256 amount, + uint256 refund + ) external payable { + _pull(token, recipient, amount); + (bool success, ) = msg.sender.call{value: refund}(""); + require(success, "refund failed"); + } + + function _pull( + address token, + address recipient, + uint256 amount + ) private { + IERC20(token).safeTransferFrom(msg.sender, recipient, amount); + } +} diff --git a/contracts/relayer/package.json b/contracts/relayer/package.json index 5981ec161..21119013b 100644 --- a/contracts/relayer/package.json +++ b/contracts/relayer/package.json @@ -7,7 +7,7 @@ "devDependencies": { "@nomicfoundation/hardhat-ignition": "^0.15.9", "@nomicfoundation/hardhat-toolbox": "^5.0.0", - "@openzeppelin/contracts": "^5.2.0", + "@openzeppelin/contracts": "5.6.1", "@types/node": "catalog:", "hardhat": "^2.22.17", "ts-node": "^10.9.2", diff --git a/contracts/relayer/test/TokenRelayer.test.ts b/contracts/relayer/test/TokenRelayer.test.ts new file mode 100644 index 000000000..a3bf95751 --- /dev/null +++ b/contracts/relayer/test/TokenRelayer.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { ethers } from "hardhat"; + +describe("TokenRelayer", () => { + async function deployFixture() { + const [deployer, owner, executor, recipient] = await ethers.getSigners(); + const destination = await ethers.deployContract("MockRelayerDestination"); + const relayer = await ethers.deployContract("TokenRelayer", [await destination.getAddress()]); + const token = await ethers.deployContract("MockERC20Permit"); + return { deployer, destination, executor, owner, recipient, relayer, token }; + } + + async function signedExecution( + fixture: Awaited>, + options: { payloadData: string; payloadNonce?: bigint; payloadValue?: bigint; value: bigint } + ) { + const { owner, relayer, token } = fixture; + const chainId = (await ethers.provider.getNetwork()).chainId; + const tokenAddress = await token.getAddress(); + const relayerAddress = await relayer.getAddress(); + const deadline = ethers.MaxUint256; + const payloadNonce = options.payloadNonce ?? 1n; + const payloadValue = options.payloadValue ?? 0n; + + const permitSignature = ethers.Signature.from( + await owner.signTypedData( + { + chainId, + name: "Mock Permit Token", + verifyingContract: tokenAddress, + version: "1" + }, + { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + }, + { + deadline, + nonce: await token.nonces(owner.address), + owner: owner.address, + spender: relayerAddress, + value: options.value + } + ) + ); + + const payloadSignature = ethers.Signature.from( + await owner.signTypedData( + { + chainId, + name: "TokenRelayer", + verifyingContract: relayerAddress, + version: "1" + }, + { + Payload: [ + { name: "destination", type: "address" }, + { name: "owner", type: "address" }, + { name: "token", type: "address" }, + { name: "value", type: "uint256" }, + { name: "data", type: "bytes" }, + { name: "ethValue", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + }, + { + data: options.payloadData, + deadline, + destination: await fixture.destination.getAddress(), + ethValue: payloadValue, + nonce: payloadNonce, + owner: owner.address, + token: tokenAddress, + value: options.value + } + ) + ); + + return { + deadline, + owner: owner.address, + payloadData: options.payloadData, + payloadDeadline: deadline, + payloadNonce, + payloadR: payloadSignature.r, + payloadS: payloadSignature.s, + payloadV: payloadSignature.v, + payloadValue, + permitR: permitSignature.r, + permitS: permitSignature.s, + permitV: permitSignature.v, + token: tokenAddress, + value: options.value + }; + } + + async function findEvent(transaction: Promise, contract: Awaited>, name: string) { + const response = (await transaction) as { wait(): Promise<{ logs: Array<{ data: string; topics: string[] }> }> }; + const receipt = await response.wait(); + for (const log of receipt.logs) { + try { + const parsed = contract.interface.parseLog(log); + if (parsed?.name === name) { + return parsed; + } + } catch { + // A transaction receipt contains logs from every participating contract. + } + } + assert.fail(`Event ${name} was not emitted`); + } + + it("rejects a codeless immutable destination", async () => { + const [, codeless] = await ethers.getSigners(); + const factory = await ethers.getContractFactory("TokenRelayer"); + await assert.rejects(factory.deploy(codeless.address), /InvalidDestination/); + }); + + it("records measured receipt and consumption for an exact transfer", async () => { + const fixture = await deployFixture(); + const amount = 100n; + await fixture.token.mint(fixture.owner.address, amount); + const payloadData = fixture.destination.interface.encodeFunctionData("pull", [ + await fixture.token.getAddress(), + fixture.recipient.address, + amount + ]); + const params = await signedExecution(fixture, { payloadData, value: amount }); + + const event = await findEvent( + fixture.relayer.connect(fixture.executor).execute(params), + fixture.relayer, + "RelayerTransferObserved" + ); + assert.deepEqual([...event.args], [ + fixture.owner.address, + await fixture.token.getAddress(), + amount, + amount, + amount + ]); + + assert.equal(await fixture.token.balanceOf(fixture.recipient.address), amount); + assert.equal(await fixture.token.balanceOf(await fixture.relayer.getAddress()), 0n); + }); + + it("rejects a fee-on-transfer shortfall without consuming a pre-existing balance", async () => { + const fixture = await deployFixture(); + const amount = 100n; + await fixture.token.mint(fixture.owner.address, amount); + await fixture.token.mint(await fixture.relayer.getAddress(), 10n); + await fixture.token.setFeeBps(1_000); + const payloadData = fixture.destination.interface.encodeFunctionData("pull", [ + await fixture.token.getAddress(), + fixture.recipient.address, + amount + ]); + const params = await signedExecution(fixture, { payloadData, value: amount }); + + await assert.rejects(fixture.relayer.connect(fixture.executor).execute(params), /TokenReceiptMismatch/); + + assert.equal(await fixture.token.balanceOf(await fixture.relayer.getAddress()), 10n); + assert.equal(await fixture.token.balanceOf(fixture.recipient.address), 0n); + assert.equal(await fixture.relayer.usedPayloadNonces(fixture.owner.address, params.payloadNonce), false); + }); + + it("rolls back a successful destination call that consumes only part of the execution balance", async () => { + const fixture = await deployFixture(); + const amount = 100n; + await fixture.token.mint(fixture.owner.address, amount); + const payloadData = fixture.destination.interface.encodeFunctionData("pull", [ + await fixture.token.getAddress(), + fixture.recipient.address, + 60n + ]); + const params = await signedExecution(fixture, { payloadData, value: amount }); + + await assert.rejects(fixture.relayer.connect(fixture.executor).execute(params), /TokenBalanceNotRestored/); + + assert.equal(await fixture.token.balanceOf(fixture.owner.address), amount); + assert.equal(await fixture.token.balanceOf(fixture.recipient.address), 0n); + }); + + it("returns native refunds to the executor that supplied msg.value", async () => { + const fixture = await deployFixture(); + const amount = 100n; + const supplied = ethers.parseEther("1"); + const refund = ethers.parseEther("0.4"); + await fixture.token.mint(fixture.owner.address, amount); + const payloadData = fixture.destination.interface.encodeFunctionData("pullAndRefund", [ + await fixture.token.getAddress(), + fixture.recipient.address, + amount, + refund + ]); + const params = await signedExecution(fixture, { payloadData, payloadNonce: 2n, payloadValue: supplied, value: amount }); + + const event = await findEvent( + fixture.relayer.connect(fixture.executor).execute(params, { value: supplied }), + fixture.relayer, + "NativeRefunded" + ); + assert.deepEqual([...event.args], [fixture.executor.address, refund]); + + assert.equal(await ethers.provider.getBalance(await fixture.relayer.getAddress()), 0n); + assert.equal(await ethers.provider.getBalance(await fixture.destination.getAddress()), supplied - refund); + }); +}); diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/access/Ownable.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/access/Ownable.ts index 19fbecbf7..c5421105f 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/access/Ownable.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/access/Ownable.ts @@ -2,38 +2,52 @@ /* tslint:disable */ /* eslint-disable */ import type { - AddressLike, BaseContract, BytesLike, - ContractMethod, - ContractRunner, - EventFragment, FunctionFragment, + Result, Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, + TypedLogDescription, TypedListener, - TypedLogDescription + TypedContractMethod, } from "../../../common"; export interface OwnableInterface extends Interface { - getFunction(nameOrSignature: "owner" | "renounceOwnership" | "transferOwnership"): FunctionFragment; + getFunction( + nameOrSignature: "owner" | "renounceOwnership" | "transferOwnership" + ): FunctionFragment; getEvent(nameOrSignatureOrTopic: "OwnershipTransferred"): EventFragment; encodeFunctionData(functionFragment: "owner", values?: undefined): string; - encodeFunctionData(functionFragment: "renounceOwnership", values?: undefined): string; - encodeFunctionData(functionFragment: "transferOwnership", values: [AddressLike]): string; + encodeFunctionData( + functionFragment: "renounceOwnership", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "transferOwnership", + values: [AddressLike] + ): string; decodeFunctionResult(functionFragment: "owner", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "renounceOwnership", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferOwnership", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "renounceOwnership", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "transferOwnership", + data: BytesLike + ): Result; } export namespace OwnershipTransferredEvent { @@ -66,33 +80,55 @@ export interface Ownable extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; owner: TypedContractMethod<[], [string], "view">; renounceOwnership: TypedContractMethod<[], [void], "nonpayable">; - transferOwnership: TypedContractMethod<[newOwner: AddressLike], [void], "nonpayable">; - - getFunction(key: string | FunctionFragment): T; + transferOwnership: TypedContractMethod< + [newOwner: AddressLike], + [void], + "nonpayable" + >; - getFunction(nameOrSignature: "owner"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "renounceOwnership"): TypedContractMethod<[], [void], "nonpayable">; - getFunction(nameOrSignature: "transferOwnership"): TypedContractMethod<[newOwner: AddressLike], [void], "nonpayable">; + getFunction( + key: string | FunctionFragment + ): T; + + getFunction( + nameOrSignature: "owner" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "renounceOwnership" + ): TypedContractMethod<[], [void], "nonpayable">; + getFunction( + nameOrSignature: "transferOwnership" + ): TypedContractMethod<[newOwner: AddressLike], [void], "nonpayable">; getEvent( key: "OwnershipTransferred" diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/index.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/index.ts index 7a4f3e442..07c466dab 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/index.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/index.ts @@ -2,11 +2,10 @@ /* tslint:disable */ /* eslint-disable */ import type * as access from "./access"; -import type * as interfaces from "./interfaces"; -import type * as token from "./token"; -import type * as utils from "./utils"; - export type { access }; +import type * as interfaces from "./interfaces"; export type { interfaces }; +import type * as token from "./token"; export type { token }; +import type * as utils from "./utils"; export type { utils }; diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/IERC1363.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/IERC1363.ts index 0f86fcb39..9f620e7b1 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/IERC1363.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/IERC1363.ts @@ -2,25 +2,25 @@ /* tslint:disable */ /* eslint-disable */ import type { - AddressLike, BaseContract, BigNumberish, BytesLike, - ContractMethod, - ContractRunner, - EventFragment, FunctionFragment, + Result, Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, + TypedLogDescription, TypedListener, - TypedLogDescription + TypedContractMethod, } from "../../../common"; export interface IERC1363Interface extends Interface { @@ -43,23 +43,50 @@ export interface IERC1363Interface extends Interface { getEvent(nameOrSignatureOrTopic: "Approval" | "Transfer"): EventFragment; - encodeFunctionData(functionFragment: "allowance", values: [AddressLike, AddressLike]): string; - encodeFunctionData(functionFragment: "approve", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "approveAndCall(address,uint256)", values: [AddressLike, BigNumberish]): string; + encodeFunctionData( + functionFragment: "allowance", + values: [AddressLike, AddressLike] + ): string; + encodeFunctionData( + functionFragment: "approve", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "approveAndCall(address,uint256)", + values: [AddressLike, BigNumberish] + ): string; encodeFunctionData( functionFragment: "approveAndCall(address,uint256,bytes)", values: [AddressLike, BigNumberish, BytesLike] ): string; - encodeFunctionData(functionFragment: "balanceOf", values: [AddressLike]): string; - encodeFunctionData(functionFragment: "supportsInterface", values: [BytesLike]): string; - encodeFunctionData(functionFragment: "totalSupply", values?: undefined): string; - encodeFunctionData(functionFragment: "transfer", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "transferAndCall(address,uint256)", values: [AddressLike, BigNumberish]): string; + encodeFunctionData( + functionFragment: "balanceOf", + values: [AddressLike] + ): string; + encodeFunctionData( + functionFragment: "supportsInterface", + values: [BytesLike] + ): string; + encodeFunctionData( + functionFragment: "totalSupply", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "transfer", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "transferAndCall(address,uint256)", + values: [AddressLike, BigNumberish] + ): string; encodeFunctionData( functionFragment: "transferAndCall(address,uint256,bytes)", values: [AddressLike, BigNumberish, BytesLike] ): string; - encodeFunctionData(functionFragment: "transferFrom", values: [AddressLike, AddressLike, BigNumberish]): string; + encodeFunctionData( + functionFragment: "transferFrom", + values: [AddressLike, AddressLike, BigNumberish] + ): string; encodeFunctionData( functionFragment: "transferFromAndCall(address,address,uint256,bytes)", values: [AddressLike, AddressLike, BigNumberish, BytesLike] @@ -71,21 +98,52 @@ export interface IERC1363Interface extends Interface { decodeFunctionResult(functionFragment: "allowance", data: BytesLike): Result; decodeFunctionResult(functionFragment: "approve", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "approveAndCall(address,uint256)", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "approveAndCall(address,uint256,bytes)", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "approveAndCall(address,uint256)", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "approveAndCall(address,uint256,bytes)", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "balanceOf", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "supportsInterface", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "totalSupply", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "supportsInterface", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "totalSupply", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "transfer", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferAndCall(address,uint256)", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferAndCall(address,uint256,bytes)", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferFrom", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferFromAndCall(address,address,uint256,bytes)", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferFromAndCall(address,address,uint256)", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "transferAndCall(address,uint256)", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "transferAndCall(address,uint256,bytes)", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "transferFrom", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "transferFromAndCall(address,address,uint256,bytes)", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "transferFromAndCall(address,address,uint256)", + data: BytesLike + ): Result; } export namespace ApprovalEvent { - export type InputTuple = [owner: AddressLike, spender: AddressLike, value: BigNumberish]; + export type InputTuple = [ + owner: AddressLike, + spender: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [owner: string, spender: string, value: bigint]; export interface OutputObject { owner: string; @@ -99,7 +157,11 @@ export namespace ApprovalEvent { } export namespace TransferEvent { - export type InputTuple = [from: AddressLike, to: AddressLike, value: BigNumberish]; + export type InputTuple = [ + from: AddressLike, + to: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [from: string, to: string, value: bigint]; export interface OutputObject { from: string; @@ -129,27 +191,49 @@ export interface IERC1363 extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - allowance: TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + allowance: TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; - approve: TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + approve: TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - "approveAndCall(address,uint256)": TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + "approveAndCall(address,uint256)": TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; "approveAndCall(address,uint256,bytes)": TypedContractMethod< [spender: AddressLike, value: BigNumberish, data: BytesLike], @@ -159,13 +243,25 @@ export interface IERC1363 extends BaseContract { balanceOf: TypedContractMethod<[account: AddressLike], [bigint], "view">; - supportsInterface: TypedContractMethod<[interfaceId: BytesLike], [boolean], "view">; + supportsInterface: TypedContractMethod< + [interfaceId: BytesLike], + [boolean], + "view" + >; totalSupply: TypedContractMethod<[], [bigint], "view">; - transfer: TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transfer: TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - "transferAndCall(address,uint256)": TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + "transferAndCall(address,uint256)": TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; "transferAndCall(address,uint256,bytes)": TypedContractMethod< [to: AddressLike, value: BigNumberish, data: BytesLike], @@ -173,7 +269,11 @@ export interface IERC1363 extends BaseContract { "nonpayable" >; - transferFrom: TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transferFrom: TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; "transferFromAndCall(address,address,uint256,bytes)": TypedContractMethod< [from: AddressLike, to: AddressLike, value: BigNumberish, data: BytesLike], @@ -187,46 +287,104 @@ export interface IERC1363 extends BaseContract { "nonpayable" >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "allowance"): TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "allowance" + ): TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; getFunction( nameOrSignature: "approve" - ): TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "approveAndCall(address,uint256)" - ): TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "approveAndCall(address,uint256,bytes)" - ): TypedContractMethod<[spender: AddressLike, value: BigNumberish, data: BytesLike], [boolean], "nonpayable">; - getFunction(nameOrSignature: "balanceOf"): TypedContractMethod<[account: AddressLike], [bigint], "view">; - getFunction(nameOrSignature: "supportsInterface"): TypedContractMethod<[interfaceId: BytesLike], [boolean], "view">; - getFunction(nameOrSignature: "totalSupply"): TypedContractMethod<[], [bigint], "view">; + ): TypedContractMethod< + [spender: AddressLike, value: BigNumberish, data: BytesLike], + [boolean], + "nonpayable" + >; + getFunction( + nameOrSignature: "balanceOf" + ): TypedContractMethod<[account: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "supportsInterface" + ): TypedContractMethod<[interfaceId: BytesLike], [boolean], "view">; + getFunction( + nameOrSignature: "totalSupply" + ): TypedContractMethod<[], [bigint], "view">; getFunction( nameOrSignature: "transfer" - ): TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "transferAndCall(address,uint256)" - ): TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "transferAndCall(address,uint256,bytes)" - ): TypedContractMethod<[to: AddressLike, value: BigNumberish, data: BytesLike], [boolean], "nonpayable">; + ): TypedContractMethod< + [to: AddressLike, value: BigNumberish, data: BytesLike], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "transferFrom" - ): TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "transferFromAndCall(address,address,uint256,bytes)" - ): TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish, data: BytesLike], [boolean], "nonpayable">; + ): TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish, data: BytesLike], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "transferFromAndCall(address,address,uint256)" - ): TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getEvent( key: "Approval" - ): TypedContractEvent; + ): TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; getEvent( key: "Transfer" - ): TypedContractEvent; + ): TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; filters: { "Approval(address,address,uint256)": TypedContractEvent< @@ -234,13 +392,21 @@ export interface IERC1363 extends BaseContract { ApprovalEvent.OutputTuple, ApprovalEvent.OutputObject >; - Approval: TypedContractEvent; + Approval: TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; "Transfer(address,address,uint256)": TypedContractEvent< TransferEvent.InputTuple, TransferEvent.OutputTuple, TransferEvent.OutputObject >; - Transfer: TypedContractEvent; + Transfer: TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; }; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/IERC5267.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/IERC5267.ts index 1bb69a5af..f92fc9097 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/IERC5267.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/IERC5267.ts @@ -4,21 +4,21 @@ import type { BaseContract, BytesLike, - ContractMethod, - ContractRunner, - EventFragment, FunctionFragment, + Result, Interface, + EventFragment, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, + TypedLogDescription, TypedListener, - TypedLogDescription + TypedContractMethod, } from "../../../common"; export interface IERC5267Interface extends Interface { @@ -26,15 +26,21 @@ export interface IERC5267Interface extends Interface { getEvent(nameOrSignatureOrTopic: "EIP712DomainChanged"): EventFragment; - encodeFunctionData(functionFragment: "eip712Domain", values?: undefined): string; + encodeFunctionData( + functionFragment: "eip712Domain", + values?: undefined + ): string; - decodeFunctionResult(functionFragment: "eip712Domain", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "eip712Domain", + data: BytesLike + ): Result; } export namespace EIP712DomainChangedEvent { export type InputTuple = []; export type OutputTuple = []; - export type OutputObject = {}; + export interface OutputObject {} export type Event = TypedContractEvent; export type Filter = TypedDeferredTopicFilter; export type Log = TypedEventLog; @@ -58,21 +64,31 @@ export interface IERC5267 extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; eip712Domain: TypedContractMethod< [], @@ -90,9 +106,13 @@ export interface IERC5267 extends BaseContract { "view" >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "eip712Domain"): TypedContractMethod< + getFunction( + nameOrSignature: "eip712Domain" + ): TypedContractMethod< [], [ [string, string, string, bigint, string, string, bigint[]] & { diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors.ts index 63f64d232..959e42d83 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../../common"; export interface IERC1155ErrorsInterface extends Interface {} @@ -23,23 +35,35 @@ export interface IERC1155Errors extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors.ts index 8363ff134..04699221f 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../../common"; export interface IERC20ErrorsInterface extends Interface {} @@ -23,23 +35,35 @@ export interface IERC20Errors extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors.ts index b5320fa4f..39b0d2b51 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../../common"; export interface IERC721ErrorsInterface extends Interface {} @@ -23,23 +35,35 @@ export interface IERC721Errors extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts index 2b5699344..9415fdf59 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts @@ -1,7 +1,6 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ - +export type { IERC1155Errors } from "./IERC1155Errors"; export type { IERC20Errors } from "./IERC20Errors"; export type { IERC721Errors } from "./IERC721Errors"; -export type { IERC1155Errors } from "./IERC1155Errors"; diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/index.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/index.ts index 3a553d663..4948b47d6 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/index.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/interfaces/index.ts @@ -1,5 +1,7 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ +import type * as draftIerc6093Sol from "./draft-IERC6093.sol"; +export type { draftIerc6093Sol }; export type { IERC1363 } from "./IERC1363"; export type { IERC5267 } from "./IERC5267"; diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/ERC20.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/ERC20.ts index efdb1bac6..46736897d 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/ERC20.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/ERC20.ts @@ -2,25 +2,25 @@ /* tslint:disable */ /* eslint-disable */ import type { - AddressLike, BaseContract, BigNumberish, BytesLike, - ContractMethod, - ContractRunner, - EventFragment, FunctionFragment, + Result, Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, + TypedLogDescription, TypedListener, - TypedLogDescription + TypedContractMethod, } from "../../../../common"; export interface ERC20Interface extends Interface { @@ -39,15 +39,33 @@ export interface ERC20Interface extends Interface { getEvent(nameOrSignatureOrTopic: "Approval" | "Transfer"): EventFragment; - encodeFunctionData(functionFragment: "allowance", values: [AddressLike, AddressLike]): string; - encodeFunctionData(functionFragment: "approve", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "balanceOf", values: [AddressLike]): string; + encodeFunctionData( + functionFragment: "allowance", + values: [AddressLike, AddressLike] + ): string; + encodeFunctionData( + functionFragment: "approve", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "balanceOf", + values: [AddressLike] + ): string; encodeFunctionData(functionFragment: "decimals", values?: undefined): string; encodeFunctionData(functionFragment: "name", values?: undefined): string; encodeFunctionData(functionFragment: "symbol", values?: undefined): string; - encodeFunctionData(functionFragment: "totalSupply", values?: undefined): string; - encodeFunctionData(functionFragment: "transfer", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "transferFrom", values: [AddressLike, AddressLike, BigNumberish]): string; + encodeFunctionData( + functionFragment: "totalSupply", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "transfer", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "transferFrom", + values: [AddressLike, AddressLike, BigNumberish] + ): string; decodeFunctionResult(functionFragment: "allowance", data: BytesLike): Result; decodeFunctionResult(functionFragment: "approve", data: BytesLike): Result; @@ -55,13 +73,23 @@ export interface ERC20Interface extends Interface { decodeFunctionResult(functionFragment: "decimals", data: BytesLike): Result; decodeFunctionResult(functionFragment: "name", data: BytesLike): Result; decodeFunctionResult(functionFragment: "symbol", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "totalSupply", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "totalSupply", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "transfer", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferFrom", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "transferFrom", + data: BytesLike + ): Result; } export namespace ApprovalEvent { - export type InputTuple = [owner: AddressLike, spender: AddressLike, value: BigNumberish]; + export type InputTuple = [ + owner: AddressLike, + spender: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [owner: string, spender: string, value: bigint]; export interface OutputObject { owner: string; @@ -75,7 +103,11 @@ export namespace ApprovalEvent { } export namespace TransferEvent { - export type InputTuple = [from: AddressLike, to: AddressLike, value: BigNumberish]; + export type InputTuple = [ + from: AddressLike, + to: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [from: string, to: string, value: bigint]; export interface OutputObject { from: string; @@ -105,25 +137,43 @@ export interface ERC20 extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - allowance: TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + allowance: TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; - approve: TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + approve: TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; balanceOf: TypedContractMethod<[account: AddressLike], [bigint], "view">; @@ -135,34 +185,80 @@ export interface ERC20 extends BaseContract { totalSupply: TypedContractMethod<[], [bigint], "view">; - transfer: TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transfer: TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - transferFrom: TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transferFrom: TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "allowance"): TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "allowance" + ): TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; getFunction( nameOrSignature: "approve" - ): TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; - getFunction(nameOrSignature: "balanceOf"): TypedContractMethod<[account: AddressLike], [bigint], "view">; - getFunction(nameOrSignature: "decimals"): TypedContractMethod<[], [bigint], "view">; - getFunction(nameOrSignature: "name"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "symbol"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "totalSupply"): TypedContractMethod<[], [bigint], "view">; + ): TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + getFunction( + nameOrSignature: "balanceOf" + ): TypedContractMethod<[account: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "decimals" + ): TypedContractMethod<[], [bigint], "view">; + getFunction( + nameOrSignature: "name" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "symbol" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "totalSupply" + ): TypedContractMethod<[], [bigint], "view">; getFunction( nameOrSignature: "transfer" - ): TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "transferFrom" - ): TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getEvent( key: "Approval" - ): TypedContractEvent; + ): TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; getEvent( key: "Transfer" - ): TypedContractEvent; + ): TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; filters: { "Approval(address,address,uint256)": TypedContractEvent< @@ -170,13 +266,21 @@ export interface ERC20 extends BaseContract { ApprovalEvent.OutputTuple, ApprovalEvent.OutputObject >; - Approval: TypedContractEvent; + Approval: TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; "Transfer(address,address,uint256)": TypedContractEvent< TransferEvent.InputTuple, TransferEvent.OutputTuple, TransferEvent.OutputObject >; - Transfer: TypedContractEvent; + Transfer: TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; }; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/IERC20.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/IERC20.ts index 6f53c32fe..d800ff34b 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/IERC20.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/IERC20.ts @@ -2,51 +2,85 @@ /* tslint:disable */ /* eslint-disable */ import type { - AddressLike, BaseContract, BigNumberish, BytesLike, - ContractMethod, - ContractRunner, - EventFragment, FunctionFragment, + Result, Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, + TypedLogDescription, TypedListener, - TypedLogDescription + TypedContractMethod, } from "../../../../common"; export interface IERC20Interface extends Interface { getFunction( - nameOrSignature: "allowance" | "approve" | "balanceOf" | "totalSupply" | "transfer" | "transferFrom" + nameOrSignature: + | "allowance" + | "approve" + | "balanceOf" + | "totalSupply" + | "transfer" + | "transferFrom" ): FunctionFragment; getEvent(nameOrSignatureOrTopic: "Approval" | "Transfer"): EventFragment; - encodeFunctionData(functionFragment: "allowance", values: [AddressLike, AddressLike]): string; - encodeFunctionData(functionFragment: "approve", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "balanceOf", values: [AddressLike]): string; - encodeFunctionData(functionFragment: "totalSupply", values?: undefined): string; - encodeFunctionData(functionFragment: "transfer", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "transferFrom", values: [AddressLike, AddressLike, BigNumberish]): string; + encodeFunctionData( + functionFragment: "allowance", + values: [AddressLike, AddressLike] + ): string; + encodeFunctionData( + functionFragment: "approve", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "balanceOf", + values: [AddressLike] + ): string; + encodeFunctionData( + functionFragment: "totalSupply", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "transfer", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "transferFrom", + values: [AddressLike, AddressLike, BigNumberish] + ): string; decodeFunctionResult(functionFragment: "allowance", data: BytesLike): Result; decodeFunctionResult(functionFragment: "approve", data: BytesLike): Result; decodeFunctionResult(functionFragment: "balanceOf", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "totalSupply", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "totalSupply", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "transfer", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferFrom", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "transferFrom", + data: BytesLike + ): Result; } export namespace ApprovalEvent { - export type InputTuple = [owner: AddressLike, spender: AddressLike, value: BigNumberish]; + export type InputTuple = [ + owner: AddressLike, + spender: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [owner: string, spender: string, value: bigint]; export interface OutputObject { owner: string; @@ -60,7 +94,11 @@ export namespace ApprovalEvent { } export namespace TransferEvent { - export type InputTuple = [from: AddressLike, to: AddressLike, value: BigNumberish]; + export type InputTuple = [ + from: AddressLike, + to: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [from: string, to: string, value: bigint]; export interface OutputObject { from: string; @@ -90,55 +128,113 @@ export interface IERC20 extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - allowance: TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + allowance: TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; - approve: TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + approve: TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; balanceOf: TypedContractMethod<[account: AddressLike], [bigint], "view">; totalSupply: TypedContractMethod<[], [bigint], "view">; - transfer: TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transfer: TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - transferFrom: TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transferFrom: TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "allowance"): TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "allowance" + ): TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; getFunction( nameOrSignature: "approve" - ): TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; - getFunction(nameOrSignature: "balanceOf"): TypedContractMethod<[account: AddressLike], [bigint], "view">; - getFunction(nameOrSignature: "totalSupply"): TypedContractMethod<[], [bigint], "view">; + ): TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + getFunction( + nameOrSignature: "balanceOf" + ): TypedContractMethod<[account: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "totalSupply" + ): TypedContractMethod<[], [bigint], "view">; getFunction( nameOrSignature: "transfer" - ): TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "transferFrom" - ): TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getEvent( key: "Approval" - ): TypedContractEvent; + ): TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; getEvent( key: "Transfer" - ): TypedContractEvent; + ): TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; filters: { "Approval(address,address,uint256)": TypedContractEvent< @@ -146,13 +242,21 @@ export interface IERC20 extends BaseContract { ApprovalEvent.OutputTuple, ApprovalEvent.OutputObject >; - Approval: TypedContractEvent; + Approval: TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; "Transfer(address,address,uint256)": TypedContractEvent< TransferEvent.InputTuple, TransferEvent.OutputTuple, TransferEvent.OutputObject >; - Transfer: TypedContractEvent; + Transfer: TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; }; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.ts index 965ff39e5..019802597 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.ts @@ -2,25 +2,25 @@ /* tslint:disable */ /* eslint-disable */ import type { - AddressLike, BaseContract, BigNumberish, BytesLike, - ContractMethod, - ContractRunner, - EventFragment, FunctionFragment, + Result, Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, + TypedLogDescription, TypedListener, - TypedLogDescription + TypedContractMethod, } from "../../../../../common"; export interface ERC20PermitInterface extends Interface { @@ -41,42 +41,92 @@ export interface ERC20PermitInterface extends Interface { | "transferFrom" ): FunctionFragment; - getEvent(nameOrSignatureOrTopic: "Approval" | "EIP712DomainChanged" | "Transfer"): EventFragment; + getEvent( + nameOrSignatureOrTopic: "Approval" | "EIP712DomainChanged" | "Transfer" + ): EventFragment; - encodeFunctionData(functionFragment: "DOMAIN_SEPARATOR", values?: undefined): string; - encodeFunctionData(functionFragment: "allowance", values: [AddressLike, AddressLike]): string; - encodeFunctionData(functionFragment: "approve", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "balanceOf", values: [AddressLike]): string; + encodeFunctionData( + functionFragment: "DOMAIN_SEPARATOR", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "allowance", + values: [AddressLike, AddressLike] + ): string; + encodeFunctionData( + functionFragment: "approve", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "balanceOf", + values: [AddressLike] + ): string; encodeFunctionData(functionFragment: "decimals", values?: undefined): string; - encodeFunctionData(functionFragment: "eip712Domain", values?: undefined): string; + encodeFunctionData( + functionFragment: "eip712Domain", + values?: undefined + ): string; encodeFunctionData(functionFragment: "name", values?: undefined): string; encodeFunctionData(functionFragment: "nonces", values: [AddressLike]): string; encodeFunctionData( functionFragment: "permit", - values: [AddressLike, AddressLike, BigNumberish, BigNumberish, BigNumberish, BytesLike, BytesLike] + values: [ + AddressLike, + AddressLike, + BigNumberish, + BigNumberish, + BigNumberish, + BytesLike, + BytesLike + ] ): string; encodeFunctionData(functionFragment: "symbol", values?: undefined): string; - encodeFunctionData(functionFragment: "totalSupply", values?: undefined): string; - encodeFunctionData(functionFragment: "transfer", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "transferFrom", values: [AddressLike, AddressLike, BigNumberish]): string; + encodeFunctionData( + functionFragment: "totalSupply", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "transfer", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "transferFrom", + values: [AddressLike, AddressLike, BigNumberish] + ): string; - decodeFunctionResult(functionFragment: "DOMAIN_SEPARATOR", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "DOMAIN_SEPARATOR", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "allowance", data: BytesLike): Result; decodeFunctionResult(functionFragment: "approve", data: BytesLike): Result; decodeFunctionResult(functionFragment: "balanceOf", data: BytesLike): Result; decodeFunctionResult(functionFragment: "decimals", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "eip712Domain", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "eip712Domain", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "name", data: BytesLike): Result; decodeFunctionResult(functionFragment: "nonces", data: BytesLike): Result; decodeFunctionResult(functionFragment: "permit", data: BytesLike): Result; decodeFunctionResult(functionFragment: "symbol", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "totalSupply", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "totalSupply", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "transfer", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferFrom", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "transferFrom", + data: BytesLike + ): Result; } export namespace ApprovalEvent { - export type InputTuple = [owner: AddressLike, spender: AddressLike, value: BigNumberish]; + export type InputTuple = [ + owner: AddressLike, + spender: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [owner: string, spender: string, value: bigint]; export interface OutputObject { owner: string; @@ -92,7 +142,7 @@ export namespace ApprovalEvent { export namespace EIP712DomainChangedEvent { export type InputTuple = []; export type OutputTuple = []; - export type OutputObject = {}; + export interface OutputObject {} export type Event = TypedContractEvent; export type Filter = TypedDeferredTopicFilter; export type Log = TypedEventLog; @@ -100,7 +150,11 @@ export namespace EIP712DomainChangedEvent { } export namespace TransferEvent { - export type InputTuple = [from: AddressLike, to: AddressLike, value: BigNumberish]; + export type InputTuple = [ + from: AddressLike, + to: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [from: string, to: string, value: bigint]; export interface OutputObject { from: string; @@ -130,27 +184,45 @@ export interface ERC20Permit extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; DOMAIN_SEPARATOR: TypedContractMethod<[], [string], "view">; - allowance: TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + allowance: TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; - approve: TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + approve: TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; balanceOf: TypedContractMethod<[account: AddressLike], [bigint], "view">; @@ -194,20 +266,48 @@ export interface ERC20Permit extends BaseContract { totalSupply: TypedContractMethod<[], [bigint], "view">; - transfer: TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transfer: TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - transferFrom: TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transferFrom: TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "DOMAIN_SEPARATOR"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "allowance"): TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "DOMAIN_SEPARATOR" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "allowance" + ): TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; getFunction( nameOrSignature: "approve" - ): TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; - getFunction(nameOrSignature: "balanceOf"): TypedContractMethod<[account: AddressLike], [bigint], "view">; - getFunction(nameOrSignature: "decimals"): TypedContractMethod<[], [bigint], "view">; - getFunction(nameOrSignature: "eip712Domain"): TypedContractMethod< + ): TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + getFunction( + nameOrSignature: "balanceOf" + ): TypedContractMethod<[account: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "decimals" + ): TypedContractMethod<[], [bigint], "view">; + getFunction( + nameOrSignature: "eip712Domain" + ): TypedContractMethod< [], [ [string, string, string, bigint, string, string, bigint[]] & { @@ -222,8 +322,12 @@ export interface ERC20Permit extends BaseContract { ], "view" >; - getFunction(nameOrSignature: "name"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "nonces"): TypedContractMethod<[owner: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "name" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "nonces" + ): TypedContractMethod<[owner: AddressLike], [bigint], "view">; getFunction( nameOrSignature: "permit" ): TypedContractMethod< @@ -239,18 +343,34 @@ export interface ERC20Permit extends BaseContract { [void], "nonpayable" >; - getFunction(nameOrSignature: "symbol"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "totalSupply"): TypedContractMethod<[], [bigint], "view">; + getFunction( + nameOrSignature: "symbol" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "totalSupply" + ): TypedContractMethod<[], [bigint], "view">; getFunction( nameOrSignature: "transfer" - ): TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "transferFrom" - ): TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getEvent( key: "Approval" - ): TypedContractEvent; + ): TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; getEvent( key: "EIP712DomainChanged" ): TypedContractEvent< @@ -260,7 +380,11 @@ export interface ERC20Permit extends BaseContract { >; getEvent( key: "Transfer" - ): TypedContractEvent; + ): TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; filters: { "Approval(address,address,uint256)": TypedContractEvent< @@ -268,7 +392,11 @@ export interface ERC20Permit extends BaseContract { ApprovalEvent.OutputTuple, ApprovalEvent.OutputObject >; - Approval: TypedContractEvent; + Approval: TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; "EIP712DomainChanged()": TypedContractEvent< EIP712DomainChangedEvent.InputTuple, @@ -286,6 +414,10 @@ export interface ERC20Permit extends BaseContract { TransferEvent.OutputTuple, TransferEvent.OutputObject >; - Transfer: TypedContractEvent; + Transfer: TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; }; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.ts index ed2386180..6b5093537 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.ts @@ -2,25 +2,25 @@ /* tslint:disable */ /* eslint-disable */ import type { - AddressLike, BaseContract, BigNumberish, BytesLike, - ContractMethod, - ContractRunner, - EventFragment, FunctionFragment, + Result, Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, + TypedLogDescription, TypedListener, - TypedLogDescription + TypedContractMethod, } from "../../../../../common"; export interface IERC20MetadataInterface extends Interface { @@ -39,15 +39,33 @@ export interface IERC20MetadataInterface extends Interface { getEvent(nameOrSignatureOrTopic: "Approval" | "Transfer"): EventFragment; - encodeFunctionData(functionFragment: "allowance", values: [AddressLike, AddressLike]): string; - encodeFunctionData(functionFragment: "approve", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "balanceOf", values: [AddressLike]): string; + encodeFunctionData( + functionFragment: "allowance", + values: [AddressLike, AddressLike] + ): string; + encodeFunctionData( + functionFragment: "approve", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "balanceOf", + values: [AddressLike] + ): string; encodeFunctionData(functionFragment: "decimals", values?: undefined): string; encodeFunctionData(functionFragment: "name", values?: undefined): string; encodeFunctionData(functionFragment: "symbol", values?: undefined): string; - encodeFunctionData(functionFragment: "totalSupply", values?: undefined): string; - encodeFunctionData(functionFragment: "transfer", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "transferFrom", values: [AddressLike, AddressLike, BigNumberish]): string; + encodeFunctionData( + functionFragment: "totalSupply", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "transfer", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "transferFrom", + values: [AddressLike, AddressLike, BigNumberish] + ): string; decodeFunctionResult(functionFragment: "allowance", data: BytesLike): Result; decodeFunctionResult(functionFragment: "approve", data: BytesLike): Result; @@ -55,13 +73,23 @@ export interface IERC20MetadataInterface extends Interface { decodeFunctionResult(functionFragment: "decimals", data: BytesLike): Result; decodeFunctionResult(functionFragment: "name", data: BytesLike): Result; decodeFunctionResult(functionFragment: "symbol", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "totalSupply", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "totalSupply", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "transfer", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferFrom", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "transferFrom", + data: BytesLike + ): Result; } export namespace ApprovalEvent { - export type InputTuple = [owner: AddressLike, spender: AddressLike, value: BigNumberish]; + export type InputTuple = [ + owner: AddressLike, + spender: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [owner: string, spender: string, value: bigint]; export interface OutputObject { owner: string; @@ -75,7 +103,11 @@ export namespace ApprovalEvent { } export namespace TransferEvent { - export type InputTuple = [from: AddressLike, to: AddressLike, value: BigNumberish]; + export type InputTuple = [ + from: AddressLike, + to: AddressLike, + value: BigNumberish + ]; export type OutputTuple = [from: string, to: string, value: bigint]; export interface OutputObject { from: string; @@ -105,25 +137,43 @@ export interface IERC20Metadata extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - allowance: TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + allowance: TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; - approve: TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + approve: TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; balanceOf: TypedContractMethod<[account: AddressLike], [bigint], "view">; @@ -135,34 +185,80 @@ export interface IERC20Metadata extends BaseContract { totalSupply: TypedContractMethod<[], [bigint], "view">; - transfer: TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transfer: TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - transferFrom: TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + transferFrom: TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "allowance"): TypedContractMethod<[owner: AddressLike, spender: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "allowance" + ): TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; getFunction( nameOrSignature: "approve" - ): TypedContractMethod<[spender: AddressLike, value: BigNumberish], [boolean], "nonpayable">; - getFunction(nameOrSignature: "balanceOf"): TypedContractMethod<[account: AddressLike], [bigint], "view">; - getFunction(nameOrSignature: "decimals"): TypedContractMethod<[], [bigint], "view">; - getFunction(nameOrSignature: "name"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "symbol"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "totalSupply"): TypedContractMethod<[], [bigint], "view">; + ): TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + getFunction( + nameOrSignature: "balanceOf" + ): TypedContractMethod<[account: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "decimals" + ): TypedContractMethod<[], [bigint], "view">; + getFunction( + nameOrSignature: "name" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "symbol" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "totalSupply" + ): TypedContractMethod<[], [bigint], "view">; getFunction( nameOrSignature: "transfer" - ): TypedContractMethod<[to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getFunction( nameOrSignature: "transferFrom" - ): TypedContractMethod<[from: AddressLike, to: AddressLike, value: BigNumberish], [boolean], "nonpayable">; + ): TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; getEvent( key: "Approval" - ): TypedContractEvent; + ): TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; getEvent( key: "Transfer" - ): TypedContractEvent; + ): TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; filters: { "Approval(address,address,uint256)": TypedContractEvent< @@ -170,13 +266,21 @@ export interface IERC20Metadata extends BaseContract { ApprovalEvent.OutputTuple, ApprovalEvent.OutputObject >; - Approval: TypedContractEvent; + Approval: TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; "Transfer(address,address,uint256)": TypedContractEvent< TransferEvent.InputTuple, TransferEvent.OutputTuple, TransferEvent.OutputObject >; - Transfer: TypedContractEvent; + Transfer: TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; }; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.ts index c8700e2cd..e661a7f90 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.ts @@ -2,36 +2,52 @@ /* tslint:disable */ /* eslint-disable */ import type { - AddressLike, BaseContract, BigNumberish, BytesLike, - ContractMethod, - ContractRunner, FunctionFragment, + Result, Interface, + AddressLike, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, - TypedListener + TypedListener, + TypedContractMethod, } from "../../../../../common"; export interface IERC20PermitInterface extends Interface { - getFunction(nameOrSignature: "DOMAIN_SEPARATOR" | "nonces" | "permit"): FunctionFragment; + getFunction( + nameOrSignature: "DOMAIN_SEPARATOR" | "nonces" | "permit" + ): FunctionFragment; - encodeFunctionData(functionFragment: "DOMAIN_SEPARATOR", values?: undefined): string; + encodeFunctionData( + functionFragment: "DOMAIN_SEPARATOR", + values?: undefined + ): string; encodeFunctionData(functionFragment: "nonces", values: [AddressLike]): string; encodeFunctionData( functionFragment: "permit", - values: [AddressLike, AddressLike, BigNumberish, BigNumberish, BigNumberish, BytesLike, BytesLike] + values: [ + AddressLike, + AddressLike, + BigNumberish, + BigNumberish, + BigNumberish, + BytesLike, + BytesLike + ] ): string; - decodeFunctionResult(functionFragment: "DOMAIN_SEPARATOR", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "DOMAIN_SEPARATOR", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "nonces", data: BytesLike): Result; decodeFunctionResult(functionFragment: "permit", data: BytesLike): Result; } @@ -53,21 +69,31 @@ export interface IERC20Permit extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; DOMAIN_SEPARATOR: TypedContractMethod<[], [string], "view">; @@ -87,10 +113,16 @@ export interface IERC20Permit extends BaseContract { "nonpayable" >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "DOMAIN_SEPARATOR"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "nonces"): TypedContractMethod<[owner: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "DOMAIN_SEPARATOR" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "nonces" + ): TypedContractMethod<[owner: AddressLike], [bigint], "view">; getFunction( nameOrSignature: "permit" ): TypedContractMethod< diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/index.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/index.ts index 6673dc7da..ccc7ab8bf 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/index.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/extensions/index.ts @@ -1,4 +1,6 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ +export type { ERC20Permit } from "./ERC20Permit"; +export type { IERC20Metadata } from "./IERC20Metadata"; export type { IERC20Permit } from "./IERC20Permit"; diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/index.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/index.ts index 2daf53d3e..588dd9bfa 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/index.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/index.ts @@ -2,8 +2,8 @@ /* tslint:disable */ /* eslint-disable */ import type * as extensions from "./extensions"; -import type * as utils from "./utils"; - export type { extensions }; +import type * as utils from "./utils"; export type { utils }; +export type { ERC20 } from "./ERC20"; export type { IERC20 } from "./IERC20"; diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.ts index 02baa780d..90d8f8c84 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../../../common"; export interface SafeERC20Interface extends Interface {} @@ -23,23 +35,35 @@ export interface SafeERC20 extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/Nonces.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/Nonces.ts index 42e8d0339..113f3a22f 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/Nonces.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/Nonces.ts @@ -2,22 +2,22 @@ /* tslint:disable */ /* eslint-disable */ import type { - AddressLike, BaseContract, BytesLike, - ContractMethod, - ContractRunner, FunctionFragment, + Result, Interface, + AddressLike, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, - TypedListener + TypedListener, + TypedContractMethod, } from "../../../common"; export interface NoncesInterface extends Interface { @@ -45,27 +45,41 @@ export interface Nonces extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; nonces: TypedContractMethod<[owner: AddressLike], [bigint], "view">; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "nonces"): TypedContractMethod<[owner: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "nonces" + ): TypedContractMethod<[owner: AddressLike], [bigint], "view">; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/ReentrancyGuard.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/ReentrancyGuard.ts index ad8b3d58a..9d3372bd8 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/ReentrancyGuard.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/ReentrancyGuard.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../common"; export interface ReentrancyGuardInterface extends Interface {} @@ -23,23 +35,35 @@ export interface ReentrancyGuard extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/ShortStrings.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/ShortStrings.ts index 43a3b06b5..c4de23980 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/ShortStrings.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/ShortStrings.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../common"; export interface ShortStringsInterface extends Interface {} @@ -23,23 +35,35 @@ export interface ShortStrings extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/Strings.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/Strings.ts index e0ee6a564..08a73eb05 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/Strings.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/Strings.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../common"; export interface StringsInterface extends Interface {} @@ -23,23 +35,35 @@ export interface Strings extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/ECDSA.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/ECDSA.ts index 0b45a677e..433b59f51 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/ECDSA.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/ECDSA.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../../common"; export interface ECDSAInterface extends Interface {} @@ -23,23 +35,35 @@ export interface ECDSA extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/EIP712.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/EIP712.ts index 946bec79a..744f8078d 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/EIP712.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/EIP712.ts @@ -4,21 +4,21 @@ import type { BaseContract, BytesLike, - ContractMethod, - ContractRunner, - EventFragment, FunctionFragment, + Result, Interface, + EventFragment, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, + TypedLogDescription, TypedListener, - TypedLogDescription + TypedContractMethod, } from "../../../../common"; export interface EIP712Interface extends Interface { @@ -26,15 +26,21 @@ export interface EIP712Interface extends Interface { getEvent(nameOrSignatureOrTopic: "EIP712DomainChanged"): EventFragment; - encodeFunctionData(functionFragment: "eip712Domain", values?: undefined): string; + encodeFunctionData( + functionFragment: "eip712Domain", + values?: undefined + ): string; - decodeFunctionResult(functionFragment: "eip712Domain", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "eip712Domain", + data: BytesLike + ): Result; } export namespace EIP712DomainChangedEvent { export type InputTuple = []; export type OutputTuple = []; - export type OutputObject = {}; + export interface OutputObject {} export type Event = TypedContractEvent; export type Filter = TypedDeferredTopicFilter; export type Log = TypedEventLog; @@ -58,21 +64,31 @@ export interface EIP712 extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; eip712Domain: TypedContractMethod< [], @@ -90,9 +106,13 @@ export interface EIP712 extends BaseContract { "view" >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "eip712Domain"): TypedContractMethod< + getFunction( + nameOrSignature: "eip712Domain" + ): TypedContractMethod< [], [ [string, string, string, bigint, string, string, bigint[]] & { diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/MessageHashUtils.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/MessageHashUtils.ts index f6da58531..0de4f628f 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/MessageHashUtils.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/cryptography/MessageHashUtils.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../../common"; export interface MessageHashUtilsInterface extends Interface {} @@ -23,23 +35,35 @@ export interface MessageHashUtils extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/index.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/index.ts index abbef63cf..3bafaa677 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/index.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/index.ts @@ -2,12 +2,12 @@ /* tslint:disable */ /* eslint-disable */ import type * as cryptography from "./cryptography"; -import type * as introspection from "./introspection"; -import type * as math from "./math"; - export type { cryptography }; +import type * as introspection from "./introspection"; export type { introspection }; +import type * as math from "./math"; export type { math }; +export type { Nonces } from "./Nonces"; export type { ReentrancyGuard } from "./ReentrancyGuard"; export type { ShortStrings } from "./ShortStrings"; export type { Strings } from "./Strings"; diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/introspection/IERC165.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/introspection/IERC165.ts index 33c6a6c25..c943112ce 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/introspection/IERC165.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/introspection/IERC165.ts @@ -4,27 +4,33 @@ import type { BaseContract, BytesLike, - ContractMethod, - ContractRunner, FunctionFragment, + Result, Interface, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, - TypedListener + TypedListener, + TypedContractMethod, } from "../../../../common"; export interface IERC165Interface extends Interface { getFunction(nameOrSignature: "supportsInterface"): FunctionFragment; - encodeFunctionData(functionFragment: "supportsInterface", values: [BytesLike]): string; + encodeFunctionData( + functionFragment: "supportsInterface", + values: [BytesLike] + ): string; - decodeFunctionResult(functionFragment: "supportsInterface", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "supportsInterface", + data: BytesLike + ): Result; } export interface IERC165 extends BaseContract { @@ -44,27 +50,45 @@ export interface IERC165 extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - supportsInterface: TypedContractMethod<[interfaceId: BytesLike], [boolean], "view">; + supportsInterface: TypedContractMethod< + [interfaceId: BytesLike], + [boolean], + "view" + >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "supportsInterface"): TypedContractMethod<[interfaceId: BytesLike], [boolean], "view">; + getFunction( + nameOrSignature: "supportsInterface" + ): TypedContractMethod<[interfaceId: BytesLike], [boolean], "view">; filters: {}; } diff --git a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/math/SafeCast.ts b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/math/SafeCast.ts index 598df769e..53ebdc0ba 100644 --- a/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/math/SafeCast.ts +++ b/contracts/relayer/typechain-types/@openzeppelin/contracts/utils/math/SafeCast.ts @@ -1,8 +1,20 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ -import type { BaseContract, ContractMethod, ContractRunner, FunctionFragment, Interface, Listener } from "ethers"; -import type { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog, TypedListener } from "../../../../common"; +import type { + BaseContract, + FunctionFragment, + Interface, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, +} from "../../../../common"; export interface SafeCastInterface extends Interface {} @@ -23,23 +35,35 @@ export interface SafeCast extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; filters: {}; } diff --git a/contracts/relayer/typechain-types/common.ts b/contracts/relayer/typechain-types/common.ts index 696576a19..56b5f21e9 100644 --- a/contracts/relayer/typechain-types/common.ts +++ b/contracts/relayer/typechain-types/common.ts @@ -2,75 +2,116 @@ /* tslint:disable */ /* eslint-disable */ import type { + FunctionFragment, + Typed, + EventFragment, ContractTransaction, ContractTransactionResponse, DeferredTopicFilter, - EventFragment, EventLog, - FunctionFragment, - LogDescription, TransactionRequest, - Typed + LogDescription, } from "ethers"; -export interface TypedDeferredTopicFilter<_TCEvent extends TypedContractEvent> extends DeferredTopicFilter {} +export interface TypedDeferredTopicFilter<_TCEvent extends TypedContractEvent> + extends DeferredTopicFilter {} export interface TypedContractEvent< InputTuple extends Array = any, OutputTuple extends Array = any, OutputObject = any > { - (...args: Partial): TypedDeferredTopicFilter>; + (...args: Partial): TypedDeferredTopicFilter< + TypedContractEvent + >; name: string; fragment: EventFragment; getFragment(...args: Partial): EventFragment; } -type __TypechainAOutputTuple = T extends TypedContractEvent ? W : never; -type __TypechainOutputObject = T extends TypedContractEvent ? V : never; - -export interface TypedEventLog extends Omit { +type __TypechainAOutputTuple = T extends TypedContractEvent< + infer _U, + infer W +> + ? W + : never; +type __TypechainOutputObject = T extends TypedContractEvent< + infer _U, + infer _W, + infer V +> + ? V + : never; + +export interface TypedEventLog + extends Omit { args: __TypechainAOutputTuple & __TypechainOutputObject; } -export interface TypedLogDescription extends Omit { +export interface TypedLogDescription + extends Omit { args: __TypechainAOutputTuple & __TypechainOutputObject; } export type TypedListener = ( - ...listenerArg: [...__TypechainAOutputTuple, TypedEventLog, ...undefined[]] + ...listenerArg: [ + ...__TypechainAOutputTuple, + TypedEventLog, + ...undefined[] + ] ) => void; export type MinEthersFactory = { deploy(...a: ARGS[]): Promise; }; -export type GetContractTypeFromFactory = F extends MinEthersFactory ? C : never; -export type GetARGsTypeFromFactory = F extends MinEthersFactory ? Parameters : never; +export type GetContractTypeFromFactory = F extends MinEthersFactory< + infer C, + any +> + ? C + : never; +export type GetARGsTypeFromFactory = F extends MinEthersFactory + ? Parameters + : never; export type StateMutability = "nonpayable" | "payable" | "view"; export type BaseOverrides = Omit; -export type NonPayableOverrides = Omit; -export type PayableOverrides = Omit; +export type NonPayableOverrides = Omit< + BaseOverrides, + "value" | "blockTag" | "enableCcipRead" +>; +export type PayableOverrides = Omit< + BaseOverrides, + "blockTag" | "enableCcipRead" +>; export type ViewOverrides = Omit; export type Overrides = S extends "nonpayable" ? NonPayableOverrides : S extends "payable" - ? PayableOverrides - : ViewOverrides; + ? PayableOverrides + : ViewOverrides; -export type PostfixOverrides, S extends StateMutability> = A | [...A, Overrides]; -export type ContractMethodArgs, S extends StateMutability> = PostfixOverrides< - { [I in keyof A]-?: A[I] | Typed }, - S ->; +export type PostfixOverrides, S extends StateMutability> = + | A + | [...A, Overrides]; +export type ContractMethodArgs< + A extends Array, + S extends StateMutability +> = PostfixOverrides<{ [I in keyof A]-?: A[I] | Typed }, S>; export type DefaultReturnType = R extends Array ? R[0] : R; // export interface ContractMethod = Array, R = any, D extends R | ContractTransactionResponse = R | ContractTransactionResponse> { -export interface TypedContractMethod = Array, R = any, S extends StateMutability = "payable"> { - (...args: ContractMethodArgs): S extends "view" ? Promise> : Promise; +export interface TypedContractMethod< + A extends Array = Array, + R = any, + S extends StateMutability = "payable" +> { + (...args: ContractMethodArgs): S extends "view" + ? Promise> + : Promise; name: string; @@ -78,8 +119,12 @@ export interface TypedContractMethod = Array, R = any, getFragment(...args: ContractMethodArgs): FunctionFragment; - populateTransaction(...args: ContractMethodArgs): Promise; - staticCall(...args: ContractMethodArgs): Promise>; + populateTransaction( + ...args: ContractMethodArgs + ): Promise; + staticCall( + ...args: ContractMethodArgs + ): Promise>; send(...args: ContractMethodArgs): Promise; estimateGas(...args: ContractMethodArgs): Promise; staticCallResult(...args: ContractMethodArgs): Promise; diff --git a/contracts/relayer/typechain-types/contracts/TokenRelayer.ts b/contracts/relayer/typechain-types/contracts/TokenRelayer.ts index c7019692c..c15b65195 100644 --- a/contracts/relayer/typechain-types/contracts/TokenRelayer.ts +++ b/contracts/relayer/typechain-types/contracts/TokenRelayer.ts @@ -2,25 +2,25 @@ /* tslint:disable */ /* eslint-disable */ import type { - AddressLike, BaseContract, BigNumberish, BytesLike, - ContractMethod, - ContractRunner, - EventFragment, FunctionFragment, + Result, Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, Listener, - Result } from "ethers"; import type { TypedContractEvent, - TypedContractMethod, TypedDeferredTopicFilter, TypedEventLog, + TypedLogDescription, TypedListener, - TypedLogDescription + TypedContractMethod, } from "../common"; export declare namespace TokenRelayer { @@ -93,38 +93,91 @@ export interface TokenRelayerInterface extends Interface { nameOrSignatureOrTopic: | "EIP712DomainChanged" | "ETHWithdrawn" + | "NativeRefunded" | "OwnershipTransferred" | "RelayerExecuted" + | "RelayerTransferObserved" | "TokenWithdrawn" ): EventFragment; - encodeFunctionData(functionFragment: "destinationContract", values?: undefined): string; - encodeFunctionData(functionFragment: "eip712Domain", values?: undefined): string; - encodeFunctionData(functionFragment: "execute", values: [TokenRelayer.ExecuteParamsStruct]): string; - encodeFunctionData(functionFragment: "isExecutionCompleted", values: [AddressLike, BigNumberish]): string; + encodeFunctionData( + functionFragment: "destinationContract", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "eip712Domain", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "execute", + values: [TokenRelayer.ExecuteParamsStruct] + ): string; + encodeFunctionData( + functionFragment: "isExecutionCompleted", + values: [AddressLike, BigNumberish] + ): string; encodeFunctionData(functionFragment: "owner", values?: undefined): string; - encodeFunctionData(functionFragment: "renounceOwnership", values?: undefined): string; - encodeFunctionData(functionFragment: "transferOwnership", values: [AddressLike]): string; - encodeFunctionData(functionFragment: "usedPayloadNonces", values: [AddressLike, BigNumberish]): string; - encodeFunctionData(functionFragment: "withdrawETH", values: [BigNumberish]): string; - encodeFunctionData(functionFragment: "withdrawToken", values: [AddressLike, BigNumberish]): string; - - decodeFunctionResult(functionFragment: "destinationContract", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "eip712Domain", data: BytesLike): Result; + encodeFunctionData( + functionFragment: "renounceOwnership", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "transferOwnership", + values: [AddressLike] + ): string; + encodeFunctionData( + functionFragment: "usedPayloadNonces", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "withdrawETH", + values: [BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "withdrawToken", + values: [AddressLike, BigNumberish] + ): string; + + decodeFunctionResult( + functionFragment: "destinationContract", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "eip712Domain", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "execute", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "isExecutionCompleted", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "isExecutionCompleted", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "owner", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "renounceOwnership", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "transferOwnership", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "usedPayloadNonces", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "withdrawETH", data: BytesLike): Result; - decodeFunctionResult(functionFragment: "withdrawToken", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "renounceOwnership", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "transferOwnership", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "usedPayloadNonces", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "withdrawETH", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "withdrawToken", + data: BytesLike + ): Result; } export namespace EIP712DomainChangedEvent { export type InputTuple = []; export type OutputTuple = []; - export type OutputObject = {}; + export interface OutputObject {} export type Event = TypedContractEvent; export type Filter = TypedDeferredTopicFilter; export type Log = TypedEventLog; @@ -144,6 +197,19 @@ export namespace ETHWithdrawnEvent { export type LogDescription = TypedLogDescription; } +export namespace NativeRefundedEvent { + export type InputTuple = [executor: AddressLike, amount: BigNumberish]; + export type OutputTuple = [executor: string, amount: bigint]; + export interface OutputObject { + executor: string; + amount: bigint; + } + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + export namespace OwnershipTransferredEvent { export type InputTuple = [previousOwner: AddressLike, newOwner: AddressLike]; export type OutputTuple = [previousOwner: string, newOwner: string]; @@ -158,7 +224,11 @@ export namespace OwnershipTransferredEvent { } export namespace RelayerExecutedEvent { - export type InputTuple = [signer: AddressLike, token: AddressLike, amount: BigNumberish]; + export type InputTuple = [ + signer: AddressLike, + token: AddressLike, + amount: BigNumberish + ]; export type OutputTuple = [signer: string, token: string, amount: bigint]; export interface OutputObject { signer: string; @@ -171,8 +241,40 @@ export namespace RelayerExecutedEvent { export type LogDescription = TypedLogDescription; } +export namespace RelayerTransferObservedEvent { + export type InputTuple = [ + signer: AddressLike, + token: AddressLike, + requested: BigNumberish, + received: BigNumberish, + consumed: BigNumberish + ]; + export type OutputTuple = [ + signer: string, + token: string, + requested: bigint, + received: bigint, + consumed: bigint + ]; + export interface OutputObject { + signer: string; + token: string; + requested: bigint; + received: bigint; + consumed: bigint; + } + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + export namespace TokenWithdrawnEvent { - export type InputTuple = [token: AddressLike, amount: BigNumberish, to: AddressLike]; + export type InputTuple = [ + token: AddressLike, + amount: BigNumberish, + to: AddressLike + ]; export type OutputTuple = [token: string, amount: bigint, to: string]; export interface OutputObject { token: string; @@ -202,21 +304,31 @@ export interface TokenRelayer extends BaseContract { toBlock?: string | number | undefined ): Promise>>; - on(event: TCEvent, listener: TypedListener): Promise; + on( + event: TCEvent, + listener: TypedListener + ): Promise; on( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - once(event: TCEvent, listener: TypedListener): Promise; + once( + event: TCEvent, + listener: TypedListener + ): Promise; once( filter: TypedDeferredTopicFilter, listener: TypedListener ): Promise; - listeners(event: TCEvent): Promise>>; + listeners( + event: TCEvent + ): Promise>>; listeners(eventName?: string): Promise>; - removeAllListeners(event?: TCEvent): Promise; + removeAllListeners( + event?: TCEvent + ): Promise; destinationContract: TypedContractMethod<[], [string], "view">; @@ -236,26 +348,56 @@ export interface TokenRelayer extends BaseContract { "view" >; - execute: TypedContractMethod<[params: TokenRelayer.ExecuteParamsStruct], [void], "payable">; + execute: TypedContractMethod< + [params: TokenRelayer.ExecuteParamsStruct], + [void], + "payable" + >; - isExecutionCompleted: TypedContractMethod<[signer: AddressLike, nonce: BigNumberish], [boolean], "view">; + isExecutionCompleted: TypedContractMethod< + [signer: AddressLike, nonce: BigNumberish], + [boolean], + "view" + >; owner: TypedContractMethod<[], [string], "view">; renounceOwnership: TypedContractMethod<[], [void], "nonpayable">; - transferOwnership: TypedContractMethod<[newOwner: AddressLike], [void], "nonpayable">; + transferOwnership: TypedContractMethod< + [newOwner: AddressLike], + [void], + "nonpayable" + >; - usedPayloadNonces: TypedContractMethod<[arg0: AddressLike, arg1: BigNumberish], [boolean], "view">; + usedPayloadNonces: TypedContractMethod< + [arg0: AddressLike, arg1: BigNumberish], + [boolean], + "view" + >; - withdrawETH: TypedContractMethod<[amount: BigNumberish], [void], "nonpayable">; + withdrawETH: TypedContractMethod< + [amount: BigNumberish], + [void], + "nonpayable" + >; - withdrawToken: TypedContractMethod<[token: AddressLike, amount: BigNumberish], [void], "nonpayable">; + withdrawToken: TypedContractMethod< + [token: AddressLike, amount: BigNumberish], + [void], + "nonpayable" + >; - getFunction(key: string | FunctionFragment): T; + getFunction( + key: string | FunctionFragment + ): T; - getFunction(nameOrSignature: "destinationContract"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "eip712Domain"): TypedContractMethod< + getFunction( + nameOrSignature: "destinationContract" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "eip712Domain" + ): TypedContractMethod< [], [ [string, string, string, bigint, string, string, bigint[]] & { @@ -270,20 +412,46 @@ export interface TokenRelayer extends BaseContract { ], "view" >; - getFunction(nameOrSignature: "execute"): TypedContractMethod<[params: TokenRelayer.ExecuteParamsStruct], [void], "payable">; + getFunction( + nameOrSignature: "execute" + ): TypedContractMethod< + [params: TokenRelayer.ExecuteParamsStruct], + [void], + "payable" + >; getFunction( nameOrSignature: "isExecutionCompleted" - ): TypedContractMethod<[signer: AddressLike, nonce: BigNumberish], [boolean], "view">; - getFunction(nameOrSignature: "owner"): TypedContractMethod<[], [string], "view">; - getFunction(nameOrSignature: "renounceOwnership"): TypedContractMethod<[], [void], "nonpayable">; - getFunction(nameOrSignature: "transferOwnership"): TypedContractMethod<[newOwner: AddressLike], [void], "nonpayable">; + ): TypedContractMethod< + [signer: AddressLike, nonce: BigNumberish], + [boolean], + "view" + >; + getFunction( + nameOrSignature: "owner" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "renounceOwnership" + ): TypedContractMethod<[], [void], "nonpayable">; + getFunction( + nameOrSignature: "transferOwnership" + ): TypedContractMethod<[newOwner: AddressLike], [void], "nonpayable">; getFunction( nameOrSignature: "usedPayloadNonces" - ): TypedContractMethod<[arg0: AddressLike, arg1: BigNumberish], [boolean], "view">; - getFunction(nameOrSignature: "withdrawETH"): TypedContractMethod<[amount: BigNumberish], [void], "nonpayable">; + ): TypedContractMethod< + [arg0: AddressLike, arg1: BigNumberish], + [boolean], + "view" + >; + getFunction( + nameOrSignature: "withdrawETH" + ): TypedContractMethod<[amount: BigNumberish], [void], "nonpayable">; getFunction( nameOrSignature: "withdrawToken" - ): TypedContractMethod<[token: AddressLike, amount: BigNumberish], [void], "nonpayable">; + ): TypedContractMethod< + [token: AddressLike, amount: BigNumberish], + [void], + "nonpayable" + >; getEvent( key: "EIP712DomainChanged" @@ -294,7 +462,18 @@ export interface TokenRelayer extends BaseContract { >; getEvent( key: "ETHWithdrawn" - ): TypedContractEvent; + ): TypedContractEvent< + ETHWithdrawnEvent.InputTuple, + ETHWithdrawnEvent.OutputTuple, + ETHWithdrawnEvent.OutputObject + >; + getEvent( + key: "NativeRefunded" + ): TypedContractEvent< + NativeRefundedEvent.InputTuple, + NativeRefundedEvent.OutputTuple, + NativeRefundedEvent.OutputObject + >; getEvent( key: "OwnershipTransferred" ): TypedContractEvent< @@ -304,10 +483,25 @@ export interface TokenRelayer extends BaseContract { >; getEvent( key: "RelayerExecuted" - ): TypedContractEvent; + ): TypedContractEvent< + RelayerExecutedEvent.InputTuple, + RelayerExecutedEvent.OutputTuple, + RelayerExecutedEvent.OutputObject + >; + getEvent( + key: "RelayerTransferObserved" + ): TypedContractEvent< + RelayerTransferObservedEvent.InputTuple, + RelayerTransferObservedEvent.OutputTuple, + RelayerTransferObservedEvent.OutputObject + >; getEvent( key: "TokenWithdrawn" - ): TypedContractEvent; + ): TypedContractEvent< + TokenWithdrawnEvent.InputTuple, + TokenWithdrawnEvent.OutputTuple, + TokenWithdrawnEvent.OutputObject + >; filters: { "EIP712DomainChanged()": TypedContractEvent< @@ -332,6 +526,17 @@ export interface TokenRelayer extends BaseContract { ETHWithdrawnEvent.OutputObject >; + "NativeRefunded(address,uint256)": TypedContractEvent< + NativeRefundedEvent.InputTuple, + NativeRefundedEvent.OutputTuple, + NativeRefundedEvent.OutputObject + >; + NativeRefunded: TypedContractEvent< + NativeRefundedEvent.InputTuple, + NativeRefundedEvent.OutputTuple, + NativeRefundedEvent.OutputObject + >; + "OwnershipTransferred(address,address)": TypedContractEvent< OwnershipTransferredEvent.InputTuple, OwnershipTransferredEvent.OutputTuple, @@ -354,6 +559,17 @@ export interface TokenRelayer extends BaseContract { RelayerExecutedEvent.OutputObject >; + "RelayerTransferObserved(address,address,uint256,uint256,uint256)": TypedContractEvent< + RelayerTransferObservedEvent.InputTuple, + RelayerTransferObservedEvent.OutputTuple, + RelayerTransferObservedEvent.OutputObject + >; + RelayerTransferObserved: TypedContractEvent< + RelayerTransferObservedEvent.InputTuple, + RelayerTransferObservedEvent.OutputTuple, + RelayerTransferObservedEvent.OutputObject + >; + "TokenWithdrawn(address,uint256,address)": TypedContractEvent< TokenWithdrawnEvent.InputTuple, TokenWithdrawnEvent.OutputTuple, diff --git a/contracts/relayer/typechain-types/contracts/index.ts b/contracts/relayer/typechain-types/contracts/index.ts index b54fc377c..72cc1c9a4 100644 --- a/contracts/relayer/typechain-types/contracts/index.ts +++ b/contracts/relayer/typechain-types/contracts/index.ts @@ -1,4 +1,6 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ +import type * as mocks from "./mocks"; +export type { mocks }; export type { TokenRelayer } from "./TokenRelayer"; diff --git a/contracts/relayer/typechain-types/contracts/mocks/MockERC20Permit.ts b/contracts/relayer/typechain-types/contracts/mocks/MockERC20Permit.ts new file mode 100644 index 000000000..36294269c --- /dev/null +++ b/contracts/relayer/typechain-types/contracts/mocks/MockERC20Permit.ts @@ -0,0 +1,465 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ +import type { + BaseContract, + BigNumberish, + BytesLike, + FunctionFragment, + Result, + Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedLogDescription, + TypedListener, + TypedContractMethod, +} from "../../common"; + +export interface MockERC20PermitInterface extends Interface { + getFunction( + nameOrSignature: + | "DOMAIN_SEPARATOR" + | "allowance" + | "approve" + | "balanceOf" + | "decimals" + | "eip712Domain" + | "feeBps" + | "mint" + | "name" + | "nonces" + | "permit" + | "setFeeBps" + | "symbol" + | "totalSupply" + | "transfer" + | "transferFrom" + ): FunctionFragment; + + getEvent( + nameOrSignatureOrTopic: "Approval" | "EIP712DomainChanged" | "Transfer" + ): EventFragment; + + encodeFunctionData( + functionFragment: "DOMAIN_SEPARATOR", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "allowance", + values: [AddressLike, AddressLike] + ): string; + encodeFunctionData( + functionFragment: "approve", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "balanceOf", + values: [AddressLike] + ): string; + encodeFunctionData(functionFragment: "decimals", values?: undefined): string; + encodeFunctionData( + functionFragment: "eip712Domain", + values?: undefined + ): string; + encodeFunctionData(functionFragment: "feeBps", values?: undefined): string; + encodeFunctionData( + functionFragment: "mint", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData(functionFragment: "name", values?: undefined): string; + encodeFunctionData(functionFragment: "nonces", values: [AddressLike]): string; + encodeFunctionData( + functionFragment: "permit", + values: [ + AddressLike, + AddressLike, + BigNumberish, + BigNumberish, + BigNumberish, + BytesLike, + BytesLike + ] + ): string; + encodeFunctionData( + functionFragment: "setFeeBps", + values: [BigNumberish] + ): string; + encodeFunctionData(functionFragment: "symbol", values?: undefined): string; + encodeFunctionData( + functionFragment: "totalSupply", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "transfer", + values: [AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "transferFrom", + values: [AddressLike, AddressLike, BigNumberish] + ): string; + + decodeFunctionResult( + functionFragment: "DOMAIN_SEPARATOR", + data: BytesLike + ): Result; + decodeFunctionResult(functionFragment: "allowance", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "approve", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "balanceOf", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "decimals", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "eip712Domain", + data: BytesLike + ): Result; + decodeFunctionResult(functionFragment: "feeBps", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "mint", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "name", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "nonces", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "permit", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "setFeeBps", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "symbol", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "totalSupply", + data: BytesLike + ): Result; + decodeFunctionResult(functionFragment: "transfer", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "transferFrom", + data: BytesLike + ): Result; +} + +export namespace ApprovalEvent { + export type InputTuple = [ + owner: AddressLike, + spender: AddressLike, + value: BigNumberish + ]; + export type OutputTuple = [owner: string, spender: string, value: bigint]; + export interface OutputObject { + owner: string; + spender: string; + value: bigint; + } + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + +export namespace EIP712DomainChangedEvent { + export type InputTuple = []; + export type OutputTuple = []; + export interface OutputObject {} + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + +export namespace TransferEvent { + export type InputTuple = [ + from: AddressLike, + to: AddressLike, + value: BigNumberish + ]; + export type OutputTuple = [from: string, to: string, value: bigint]; + export interface OutputObject { + from: string; + to: string; + value: bigint; + } + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + +export interface MockERC20Permit extends BaseContract { + connect(runner?: ContractRunner | null): MockERC20Permit; + waitForDeployment(): Promise; + + interface: MockERC20PermitInterface; + + queryFilter( + event: TCEvent, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>>; + queryFilter( + filter: TypedDeferredTopicFilter, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>>; + + on( + event: TCEvent, + listener: TypedListener + ): Promise; + on( + filter: TypedDeferredTopicFilter, + listener: TypedListener + ): Promise; + + once( + event: TCEvent, + listener: TypedListener + ): Promise; + once( + filter: TypedDeferredTopicFilter, + listener: TypedListener + ): Promise; + + listeners( + event: TCEvent + ): Promise>>; + listeners(eventName?: string): Promise>; + removeAllListeners( + event?: TCEvent + ): Promise; + + DOMAIN_SEPARATOR: TypedContractMethod<[], [string], "view">; + + allowance: TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; + + approve: TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + + balanceOf: TypedContractMethod<[account: AddressLike], [bigint], "view">; + + decimals: TypedContractMethod<[], [bigint], "view">; + + eip712Domain: TypedContractMethod< + [], + [ + [string, string, string, bigint, string, string, bigint[]] & { + fields: string; + name: string; + version: string; + chainId: bigint; + verifyingContract: string; + salt: string; + extensions: bigint[]; + } + ], + "view" + >; + + feeBps: TypedContractMethod<[], [bigint], "view">; + + mint: TypedContractMethod< + [to: AddressLike, amount: BigNumberish], + [void], + "nonpayable" + >; + + name: TypedContractMethod<[], [string], "view">; + + nonces: TypedContractMethod<[owner: AddressLike], [bigint], "view">; + + permit: TypedContractMethod< + [ + owner: AddressLike, + spender: AddressLike, + value: BigNumberish, + deadline: BigNumberish, + v: BigNumberish, + r: BytesLike, + s: BytesLike + ], + [void], + "nonpayable" + >; + + setFeeBps: TypedContractMethod< + [newFeeBps: BigNumberish], + [void], + "nonpayable" + >; + + symbol: TypedContractMethod<[], [string], "view">; + + totalSupply: TypedContractMethod<[], [bigint], "view">; + + transfer: TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + + transferFrom: TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + + getFunction( + key: string | FunctionFragment + ): T; + + getFunction( + nameOrSignature: "DOMAIN_SEPARATOR" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "allowance" + ): TypedContractMethod< + [owner: AddressLike, spender: AddressLike], + [bigint], + "view" + >; + getFunction( + nameOrSignature: "approve" + ): TypedContractMethod< + [spender: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + getFunction( + nameOrSignature: "balanceOf" + ): TypedContractMethod<[account: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "decimals" + ): TypedContractMethod<[], [bigint], "view">; + getFunction( + nameOrSignature: "eip712Domain" + ): TypedContractMethod< + [], + [ + [string, string, string, bigint, string, string, bigint[]] & { + fields: string; + name: string; + version: string; + chainId: bigint; + verifyingContract: string; + salt: string; + extensions: bigint[]; + } + ], + "view" + >; + getFunction( + nameOrSignature: "feeBps" + ): TypedContractMethod<[], [bigint], "view">; + getFunction( + nameOrSignature: "mint" + ): TypedContractMethod< + [to: AddressLike, amount: BigNumberish], + [void], + "nonpayable" + >; + getFunction( + nameOrSignature: "name" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "nonces" + ): TypedContractMethod<[owner: AddressLike], [bigint], "view">; + getFunction( + nameOrSignature: "permit" + ): TypedContractMethod< + [ + owner: AddressLike, + spender: AddressLike, + value: BigNumberish, + deadline: BigNumberish, + v: BigNumberish, + r: BytesLike, + s: BytesLike + ], + [void], + "nonpayable" + >; + getFunction( + nameOrSignature: "setFeeBps" + ): TypedContractMethod<[newFeeBps: BigNumberish], [void], "nonpayable">; + getFunction( + nameOrSignature: "symbol" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "totalSupply" + ): TypedContractMethod<[], [bigint], "view">; + getFunction( + nameOrSignature: "transfer" + ): TypedContractMethod< + [to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + getFunction( + nameOrSignature: "transferFrom" + ): TypedContractMethod< + [from: AddressLike, to: AddressLike, value: BigNumberish], + [boolean], + "nonpayable" + >; + + getEvent( + key: "Approval" + ): TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; + getEvent( + key: "EIP712DomainChanged" + ): TypedContractEvent< + EIP712DomainChangedEvent.InputTuple, + EIP712DomainChangedEvent.OutputTuple, + EIP712DomainChangedEvent.OutputObject + >; + getEvent( + key: "Transfer" + ): TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; + + filters: { + "Approval(address,address,uint256)": TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; + Approval: TypedContractEvent< + ApprovalEvent.InputTuple, + ApprovalEvent.OutputTuple, + ApprovalEvent.OutputObject + >; + + "EIP712DomainChanged()": TypedContractEvent< + EIP712DomainChangedEvent.InputTuple, + EIP712DomainChangedEvent.OutputTuple, + EIP712DomainChangedEvent.OutputObject + >; + EIP712DomainChanged: TypedContractEvent< + EIP712DomainChangedEvent.InputTuple, + EIP712DomainChangedEvent.OutputTuple, + EIP712DomainChangedEvent.OutputObject + >; + + "Transfer(address,address,uint256)": TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; + Transfer: TypedContractEvent< + TransferEvent.InputTuple, + TransferEvent.OutputTuple, + TransferEvent.OutputObject + >; + }; +} diff --git a/contracts/relayer/typechain-types/contracts/mocks/MockRelayerDestination.ts b/contracts/relayer/typechain-types/contracts/mocks/MockRelayerDestination.ts new file mode 100644 index 000000000..8aeb58646 --- /dev/null +++ b/contracts/relayer/typechain-types/contracts/mocks/MockRelayerDestination.ts @@ -0,0 +1,128 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ +import type { + BaseContract, + BigNumberish, + BytesLike, + FunctionFragment, + Result, + Interface, + AddressLike, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedListener, + TypedContractMethod, +} from "../../common"; + +export interface MockRelayerDestinationInterface extends Interface { + getFunction(nameOrSignature: "pull" | "pullAndRefund"): FunctionFragment; + + encodeFunctionData( + functionFragment: "pull", + values: [AddressLike, AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "pullAndRefund", + values: [AddressLike, AddressLike, BigNumberish, BigNumberish] + ): string; + + decodeFunctionResult(functionFragment: "pull", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "pullAndRefund", + data: BytesLike + ): Result; +} + +export interface MockRelayerDestination extends BaseContract { + connect(runner?: ContractRunner | null): MockRelayerDestination; + waitForDeployment(): Promise; + + interface: MockRelayerDestinationInterface; + + queryFilter( + event: TCEvent, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>>; + queryFilter( + filter: TypedDeferredTopicFilter, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>>; + + on( + event: TCEvent, + listener: TypedListener + ): Promise; + on( + filter: TypedDeferredTopicFilter, + listener: TypedListener + ): Promise; + + once( + event: TCEvent, + listener: TypedListener + ): Promise; + once( + filter: TypedDeferredTopicFilter, + listener: TypedListener + ): Promise; + + listeners( + event: TCEvent + ): Promise>>; + listeners(eventName?: string): Promise>; + removeAllListeners( + event?: TCEvent + ): Promise; + + pull: TypedContractMethod< + [token: AddressLike, recipient: AddressLike, amount: BigNumberish], + [void], + "nonpayable" + >; + + pullAndRefund: TypedContractMethod< + [ + token: AddressLike, + recipient: AddressLike, + amount: BigNumberish, + refund: BigNumberish + ], + [void], + "payable" + >; + + getFunction( + key: string | FunctionFragment + ): T; + + getFunction( + nameOrSignature: "pull" + ): TypedContractMethod< + [token: AddressLike, recipient: AddressLike, amount: BigNumberish], + [void], + "nonpayable" + >; + getFunction( + nameOrSignature: "pullAndRefund" + ): TypedContractMethod< + [ + token: AddressLike, + recipient: AddressLike, + amount: BigNumberish, + refund: BigNumberish + ], + [void], + "payable" + >; + + filters: {}; +} diff --git a/contracts/relayer/typechain-types/contracts/mocks/index.ts b/contracts/relayer/typechain-types/contracts/mocks/index.ts new file mode 100644 index 000000000..21521870e --- /dev/null +++ b/contracts/relayer/typechain-types/contracts/mocks/index.ts @@ -0,0 +1,5 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ +export type { MockERC20Permit } from "./MockERC20Permit"; +export type { MockRelayerDestination } from "./MockRelayerDestination"; diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/access/Ownable__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/access/Ownable__factory.ts index 6bfa80b69..d4b057582 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/access/Ownable__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/access/Ownable__factory.ts @@ -2,8 +2,11 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; -import type { Ownable, OwnableInterface } from "../../../../@openzeppelin/contracts/access/Ownable"; +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + Ownable, + OwnableInterface, +} from "../../../../@openzeppelin/contracts/access/Ownable"; const _abi = [ { @@ -11,22 +14,22 @@ const _abi = [ { internalType: "address", name: "owner", - type: "address" - } + type: "address", + }, ], name: "OwnableInvalidOwner", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "account", - type: "address" - } + type: "address", + }, ], name: "OwnableUnauthorizedAccount", - type: "error" + type: "error", }, { anonymous: false, @@ -35,17 +38,17 @@ const _abi = [ indexed: true, internalType: "address", name: "previousOwner", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "newOwner", - type: "address" - } + type: "address", + }, ], name: "OwnershipTransferred", - type: "event" + type: "event", }, { inputs: [], @@ -54,32 +57,32 @@ const _abi = [ { internalType: "address", name: "", - type: "address" - } + type: "address", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], name: "renounceOwnership", outputs: [], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "newOwner", - type: "address" - } + type: "address", + }, ], name: "transferOwnership", outputs: [], stateMutability: "nonpayable", - type: "function" - } + type: "function", + }, ] as const; export class Ownable__factory { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/IERC1363__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/IERC1363__factory.ts index 7fbda55e3..6a0a0d475 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/IERC1363__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/IERC1363__factory.ts @@ -2,8 +2,11 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; -import type { IERC1363, IERC1363Interface } from "../../../../@openzeppelin/contracts/interfaces/IERC1363"; +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + IERC1363, + IERC1363Interface, +} from "../../../../@openzeppelin/contracts/interfaces/IERC1363"; const _abi = [ { @@ -13,23 +16,23 @@ const _abi = [ indexed: true, internalType: "address", name: "owner", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "spender", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Approval", - type: "event" + type: "event", }, { anonymous: false, @@ -38,162 +41,162 @@ const _abi = [ indexed: true, internalType: "address", name: "from", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "to", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Transfer", - type: "event" + type: "event", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" + type: "address", }, { internalType: "address", name: "spender", - type: "address" - } + type: "address", + }, ], name: "allowance", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "approve", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "approveAndCall", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" + type: "uint256", }, { internalType: "bytes", name: "data", - type: "bytes" - } + type: "bytes", + }, ], name: "approveAndCall", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "account", - type: "address" - } + type: "address", + }, ], name: "balanceOf", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "bytes4", name: "interfaceId", - type: "bytes4" - } + type: "bytes4", + }, ], name: "supportsInterface", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -202,181 +205,181 @@ const _abi = [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transfer", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transferAndCall", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" + type: "uint256", }, { internalType: "bytes", name: "data", - type: "bytes" - } + type: "bytes", + }, ], name: "transferAndCall", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "from", - type: "address" + type: "address", }, { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transferFrom", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "from", - type: "address" + type: "address", }, { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" + type: "uint256", }, { internalType: "bytes", name: "data", - type: "bytes" - } + type: "bytes", + }, ], name: "transferFromAndCall", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "from", - type: "address" + type: "address", }, { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transferFromAndCall", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" - } + type: "function", + }, ] as const; export class IERC1363__factory { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/IERC5267__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/IERC5267__factory.ts index f52c9e6b9..0054c0a10 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/IERC5267__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/IERC5267__factory.ts @@ -2,15 +2,18 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; -import type { IERC5267, IERC5267Interface } from "../../../../@openzeppelin/contracts/interfaces/IERC5267"; +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + IERC5267, + IERC5267Interface, +} from "../../../../@openzeppelin/contracts/interfaces/IERC5267"; const _abi = [ { anonymous: false, inputs: [], name: "EIP712DomainChanged", - type: "event" + type: "event", }, { inputs: [], @@ -19,42 +22,42 @@ const _abi = [ { internalType: "bytes1", name: "fields", - type: "bytes1" + type: "bytes1", }, { internalType: "string", name: "name", - type: "string" + type: "string", }, { internalType: "string", name: "version", - type: "string" + type: "string", }, { internalType: "uint256", name: "chainId", - type: "uint256" + type: "uint256", }, { internalType: "address", name: "verifyingContract", - type: "address" + type: "address", }, { internalType: "bytes32", name: "salt", - type: "bytes32" + type: "bytes32", }, { internalType: "uint256[]", name: "extensions", - type: "uint256[]" - } + type: "uint256[]", + }, ], stateMutability: "view", - type: "function" - } + type: "function", + }, ] as const; export class IERC5267__factory { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors__factory.ts index 79689bee4..0413f8c17 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors__factory.ts @@ -2,10 +2,10 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; +import { Contract, Interface, type ContractRunner } from "ethers"; import type { IERC1155Errors, - IERC1155ErrorsInterface + IERC1155ErrorsInterface, } from "../../../../../@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors"; const _abi = [ @@ -14,103 +14,103 @@ const _abi = [ { internalType: "address", name: "sender", - type: "address" + type: "address", }, { internalType: "uint256", name: "balance", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "needed", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "tokenId", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC1155InsufficientBalance", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "approver", - type: "address" - } + type: "address", + }, ], name: "ERC1155InvalidApprover", - type: "error" + type: "error", }, { inputs: [ { internalType: "uint256", name: "idsLength", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "valuesLength", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC1155InvalidArrayLength", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "operator", - type: "address" - } + type: "address", + }, ], name: "ERC1155InvalidOperator", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "receiver", - type: "address" - } + type: "address", + }, ], name: "ERC1155InvalidReceiver", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "sender", - type: "address" - } + type: "address", + }, ], name: "ERC1155InvalidSender", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "operator", - type: "address" + type: "address", }, { internalType: "address", name: "owner", - type: "address" - } + type: "address", + }, ], name: "ERC1155MissingApprovalForAll", - type: "error" - } + type: "error", + }, ] as const; export class IERC1155Errors__factory { @@ -118,7 +118,10 @@ export class IERC1155Errors__factory { static createInterface(): IERC1155ErrorsInterface { return new Interface(_abi) as IERC1155ErrorsInterface; } - static connect(address: string, runner?: ContractRunner | null): IERC1155Errors { + static connect( + address: string, + runner?: ContractRunner | null + ): IERC1155Errors { return new Contract(address, _abi, runner) as unknown as IERC1155Errors; } } diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors__factory.ts index 33a3ffc99..695f3f0f4 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors__factory.ts @@ -2,10 +2,10 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; +import { Contract, Interface, type ContractRunner } from "ethers"; import type { IERC20Errors, - IERC20ErrorsInterface + IERC20ErrorsInterface, } from "../../../../../@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors"; const _abi = [ @@ -14,87 +14,87 @@ const _abi = [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "allowance", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "needed", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC20InsufficientAllowance", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "sender", - type: "address" + type: "address", }, { internalType: "uint256", name: "balance", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "needed", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC20InsufficientBalance", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "approver", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidApprover", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "receiver", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidReceiver", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "sender", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidSender", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidSpender", - type: "error" - } + type: "error", + }, ] as const; export class IERC20Errors__factory { @@ -102,7 +102,10 @@ export class IERC20Errors__factory { static createInterface(): IERC20ErrorsInterface { return new Interface(_abi) as IERC20ErrorsInterface; } - static connect(address: string, runner?: ContractRunner | null): IERC20Errors { + static connect( + address: string, + runner?: ContractRunner | null + ): IERC20Errors { return new Contract(address, _abi, runner) as unknown as IERC20Errors; } } diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors__factory.ts index e3267306c..8615d4ddd 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors__factory.ts @@ -2,10 +2,10 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; +import { Contract, Interface, type ContractRunner } from "ethers"; import type { IERC721Errors, - IERC721ErrorsInterface + IERC721ErrorsInterface, } from "../../../../../@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors"; const _abi = [ @@ -14,104 +14,104 @@ const _abi = [ { internalType: "address", name: "sender", - type: "address" + type: "address", }, { internalType: "uint256", name: "tokenId", - type: "uint256" + type: "uint256", }, { internalType: "address", name: "owner", - type: "address" - } + type: "address", + }, ], name: "ERC721IncorrectOwner", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "operator", - type: "address" + type: "address", }, { internalType: "uint256", name: "tokenId", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC721InsufficientApproval", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "approver", - type: "address" - } + type: "address", + }, ], name: "ERC721InvalidApprover", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "operator", - type: "address" - } + type: "address", + }, ], name: "ERC721InvalidOperator", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" - } + type: "address", + }, ], name: "ERC721InvalidOwner", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "receiver", - type: "address" - } + type: "address", + }, ], name: "ERC721InvalidReceiver", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "sender", - type: "address" - } + type: "address", + }, ], name: "ERC721InvalidSender", - type: "error" + type: "error", }, { inputs: [ { internalType: "uint256", name: "tokenId", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC721NonexistentToken", - type: "error" - } + type: "error", + }, ] as const; export class IERC721Errors__factory { @@ -119,7 +119,10 @@ export class IERC721Errors__factory { static createInterface(): IERC721ErrorsInterface { return new Interface(_abi) as IERC721ErrorsInterface; } - static connect(address: string, runner?: ContractRunner | null): IERC721Errors { + static connect( + address: string, + runner?: ContractRunner | null + ): IERC721Errors { return new Contract(address, _abi, runner) as unknown as IERC721Errors; } } diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts index d630107c7..571330ea3 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/index.ts @@ -1,7 +1,6 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ - +export { IERC1155Errors__factory } from "./IERC1155Errors__factory"; export { IERC20Errors__factory } from "./IERC20Errors__factory"; export { IERC721Errors__factory } from "./IERC721Errors__factory"; -export { IERC1155Errors__factory } from "./IERC1155Errors__factory"; diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/index.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/index.ts index 361bab7f9..18e7c5c53 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/index.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/interfaces/index.ts @@ -1,5 +1,6 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ +export * as draftIerc6093Sol from "./draft-IERC6093.sol"; export { IERC1363__factory } from "./IERC1363__factory"; export { IERC5267__factory } from "./IERC5267__factory"; diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/ERC20__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/ERC20__factory.ts index f93b6d3e8..5d8981a68 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/ERC20__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/ERC20__factory.ts @@ -2,8 +2,11 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; -import type { ERC20, ERC20Interface } from "../../../../../@openzeppelin/contracts/token/ERC20/ERC20"; +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + ERC20, + ERC20Interface, +} from "../../../../../@openzeppelin/contracts/token/ERC20/ERC20"; const _abi = [ { @@ -11,86 +14,86 @@ const _abi = [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "allowance", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "needed", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC20InsufficientAllowance", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "sender", - type: "address" + type: "address", }, { internalType: "uint256", name: "balance", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "needed", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC20InsufficientBalance", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "approver", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidApprover", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "receiver", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidReceiver", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "sender", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidSender", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidSpender", - type: "error" + type: "error", }, { anonymous: false, @@ -99,23 +102,23 @@ const _abi = [ indexed: true, internalType: "address", name: "owner", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "spender", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Approval", - type: "event" + type: "event", }, { anonymous: false, @@ -124,90 +127,90 @@ const _abi = [ indexed: true, internalType: "address", name: "from", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "to", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Transfer", - type: "event" + type: "event", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" + type: "address", }, { internalType: "address", name: "spender", - type: "address" - } + type: "address", + }, ], name: "allowance", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "approve", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "account", - type: "address" - } + type: "address", + }, ], name: "balanceOf", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -216,11 +219,11 @@ const _abi = [ { internalType: "uint8", name: "", - type: "uint8" - } + type: "uint8", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -229,11 +232,11 @@ const _abi = [ { internalType: "string", name: "", - type: "string" - } + type: "string", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -242,11 +245,11 @@ const _abi = [ { internalType: "string", name: "", - type: "string" - } + type: "string", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -255,65 +258,65 @@ const _abi = [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transfer", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "from", - type: "address" + type: "address", }, { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transferFrom", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" - } + type: "function", + }, ] as const; export class ERC20__factory { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/IERC20__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/IERC20__factory.ts index 659e4dadf..6768448dc 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/IERC20__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/IERC20__factory.ts @@ -2,8 +2,11 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; -import type { IERC20, IERC20Interface } from "../../../../../@openzeppelin/contracts/token/ERC20/IERC20"; +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + IERC20, + IERC20Interface, +} from "../../../../../@openzeppelin/contracts/token/ERC20/IERC20"; const _abi = [ { @@ -13,23 +16,23 @@ const _abi = [ indexed: true, internalType: "address", name: "owner", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "spender", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Approval", - type: "event" + type: "event", }, { anonymous: false, @@ -38,90 +41,90 @@ const _abi = [ indexed: true, internalType: "address", name: "from", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "to", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Transfer", - type: "event" + type: "event", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" + type: "address", }, { internalType: "address", name: "spender", - type: "address" - } + type: "address", + }, ], name: "allowance", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "approve", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "account", - type: "address" - } + type: "address", + }, ], name: "balanceOf", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -130,65 +133,65 @@ const _abi = [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transfer", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "from", - type: "address" + type: "address", }, { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transferFrom", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" - } + type: "function", + }, ] as const; export class IERC20__factory { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit__factory.ts index b7d5bb5fc..27af08a84 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit__factory.ts @@ -2,184 +2,184 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; +import { Contract, Interface, type ContractRunner } from "ethers"; import type { ERC20Permit, - ERC20PermitInterface + ERC20PermitInterface, } from "../../../../../../@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit"; const _abi = [ { inputs: [], name: "ECDSAInvalidSignature", - type: "error" + type: "error", }, { inputs: [ { internalType: "uint256", name: "length", - type: "uint256" - } + type: "uint256", + }, ], name: "ECDSAInvalidSignatureLength", - type: "error" + type: "error", }, { inputs: [ { internalType: "bytes32", name: "s", - type: "bytes32" - } + type: "bytes32", + }, ], name: "ECDSAInvalidSignatureS", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "allowance", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "needed", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC20InsufficientAllowance", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "sender", - type: "address" + type: "address", }, { internalType: "uint256", name: "balance", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "needed", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC20InsufficientBalance", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "approver", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidApprover", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "receiver", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidReceiver", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "sender", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidSender", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" - } + type: "address", + }, ], name: "ERC20InvalidSpender", - type: "error" + type: "error", }, { inputs: [ { internalType: "uint256", name: "deadline", - type: "uint256" - } + type: "uint256", + }, ], name: "ERC2612ExpiredSignature", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "signer", - type: "address" + type: "address", }, { internalType: "address", name: "owner", - type: "address" - } + type: "address", + }, ], name: "ERC2612InvalidSigner", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "account", - type: "address" + type: "address", }, { internalType: "uint256", name: "currentNonce", - type: "uint256" - } + type: "uint256", + }, ], name: "InvalidAccountNonce", - type: "error" + type: "error", }, { inputs: [], name: "InvalidShortString", - type: "error" + type: "error", }, { inputs: [ { internalType: "string", name: "str", - type: "string" - } + type: "string", + }, ], name: "StringTooLong", - type: "error" + type: "error", }, { anonymous: false, @@ -188,29 +188,29 @@ const _abi = [ indexed: true, internalType: "address", name: "owner", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "spender", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Approval", - type: "event" + type: "event", }, { anonymous: false, inputs: [], name: "EIP712DomainChanged", - type: "event" + type: "event", }, { anonymous: false, @@ -219,23 +219,23 @@ const _abi = [ indexed: true, internalType: "address", name: "from", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "to", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Transfer", - type: "event" + type: "event", }, { inputs: [], @@ -244,78 +244,78 @@ const _abi = [ { internalType: "bytes32", name: "", - type: "bytes32" - } + type: "bytes32", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" + type: "address", }, { internalType: "address", name: "spender", - type: "address" - } + type: "address", + }, ], name: "allowance", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "approve", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "account", - type: "address" - } + type: "address", + }, ], name: "balanceOf", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -324,11 +324,11 @@ const _abi = [ { internalType: "uint8", name: "", - type: "uint8" - } + type: "uint8", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -337,41 +337,41 @@ const _abi = [ { internalType: "bytes1", name: "fields", - type: "bytes1" + type: "bytes1", }, { internalType: "string", name: "name", - type: "string" + type: "string", }, { internalType: "string", name: "version", - type: "string" + type: "string", }, { internalType: "uint256", name: "chainId", - type: "uint256" + type: "uint256", }, { internalType: "address", name: "verifyingContract", - type: "address" + type: "address", }, { internalType: "bytes32", name: "salt", - type: "bytes32" + type: "bytes32", }, { internalType: "uint256[]", name: "extensions", - type: "uint256[]" - } + type: "uint256[]", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -380,73 +380,73 @@ const _abi = [ { internalType: "string", name: "", - type: "string" - } + type: "string", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" - } + type: "address", + }, ], name: "nonces", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" + type: "address", }, { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "deadline", - type: "uint256" + type: "uint256", }, { internalType: "uint8", name: "v", - type: "uint8" + type: "uint8", }, { internalType: "bytes32", name: "r", - type: "bytes32" + type: "bytes32", }, { internalType: "bytes32", name: "s", - type: "bytes32" - } + type: "bytes32", + }, ], name: "permit", outputs: [], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [], @@ -455,11 +455,11 @@ const _abi = [ { internalType: "string", name: "", - type: "string" - } + type: "string", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -468,65 +468,65 @@ const _abi = [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transfer", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "from", - type: "address" + type: "address", }, { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transferFrom", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" - } + type: "function", + }, ] as const; export class ERC20Permit__factory { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata__factory.ts index 1679c8574..80abf9696 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata__factory.ts @@ -2,10 +2,10 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; +import { Contract, Interface, type ContractRunner } from "ethers"; import type { IERC20Metadata, - IERC20MetadataInterface + IERC20MetadataInterface, } from "../../../../../../@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata"; const _abi = [ @@ -16,23 +16,23 @@ const _abi = [ indexed: true, internalType: "address", name: "owner", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "spender", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Approval", - type: "event" + type: "event", }, { anonymous: false, @@ -41,90 +41,90 @@ const _abi = [ indexed: true, internalType: "address", name: "from", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "to", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "Transfer", - type: "event" + type: "event", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" + type: "address", }, { internalType: "address", name: "spender", - type: "address" - } + type: "address", + }, ], name: "allowance", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "approve", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "account", - type: "address" - } + type: "address", + }, ], name: "balanceOf", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -133,11 +133,11 @@ const _abi = [ { internalType: "uint8", name: "", - type: "uint8" - } + type: "uint8", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -146,11 +146,11 @@ const _abi = [ { internalType: "string", name: "", - type: "string" - } + type: "string", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -159,11 +159,11 @@ const _abi = [ { internalType: "string", name: "", - type: "string" - } + type: "string", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -172,65 +172,65 @@ const _abi = [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transfer", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "from", - type: "address" + type: "address", }, { internalType: "address", name: "to", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "transferFrom", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "nonpayable", - type: "function" - } + type: "function", + }, ] as const; export class IERC20Metadata__factory { @@ -238,7 +238,10 @@ export class IERC20Metadata__factory { static createInterface(): IERC20MetadataInterface { return new Interface(_abi) as IERC20MetadataInterface; } - static connect(address: string, runner?: ContractRunner | null): IERC20Metadata { + static connect( + address: string, + runner?: ContractRunner | null + ): IERC20Metadata { return new Contract(address, _abi, runner) as unknown as IERC20Metadata; } } diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit__factory.ts index b4a22db5a..395f0a7ff 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit__factory.ts @@ -2,10 +2,10 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; +import { Contract, Interface, type ContractRunner } from "ethers"; import type { IERC20Permit, - IERC20PermitInterface + IERC20PermitInterface, } from "../../../../../../@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit"; const _abi = [ @@ -16,74 +16,74 @@ const _abi = [ { internalType: "bytes32", name: "", - type: "bytes32" - } + type: "bytes32", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" - } + type: "address", + }, ], name: "nonces", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" + type: "address", }, { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "deadline", - type: "uint256" + type: "uint256", }, { internalType: "uint8", name: "v", - type: "uint8" + type: "uint8", }, { internalType: "bytes32", name: "r", - type: "bytes32" + type: "bytes32", }, { internalType: "bytes32", name: "s", - type: "bytes32" - } + type: "bytes32", + }, ], name: "permit", outputs: [], stateMutability: "nonpayable", - type: "function" - } + type: "function", + }, ] as const; export class IERC20Permit__factory { @@ -91,7 +91,10 @@ export class IERC20Permit__factory { static createInterface(): IERC20PermitInterface { return new Interface(_abi) as IERC20PermitInterface; } - static connect(address: string, runner?: ContractRunner | null): IERC20Permit { + static connect( + address: string, + runner?: ContractRunner | null + ): IERC20Permit { return new Contract(address, _abi, runner) as unknown as IERC20Permit; } } diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/index.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/index.ts index 5f347d4a7..d83aa5556 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/index.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/extensions/index.ts @@ -1,4 +1,6 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ +export { ERC20Permit__factory } from "./ERC20Permit__factory"; +export { IERC20Metadata__factory } from "./IERC20Metadata__factory"; export { IERC20Permit__factory } from "./IERC20Permit__factory"; diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/index.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/index.ts index d2963177e..d187f9662 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/index.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/index.ts @@ -2,5 +2,6 @@ /* tslint:disable */ /* eslint-disable */ export * as extensions from "./extensions"; -export { IERC20__factory } from "./IERC20__factory"; export * as utils from "./utils"; +export { ERC20__factory } from "./ERC20__factory"; +export { IERC20__factory } from "./IERC20__factory"; diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/utils/SafeERC20__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/utils/SafeERC20__factory.ts index 2c14f6c58..9f26baad4 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/utils/SafeERC20__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/token/ERC20/utils/SafeERC20__factory.ts @@ -1,11 +1,18 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ - -import type { ContractDeployTransaction, ContractRunner, Signer } from "ethers"; -import { Contract, ContractFactory, ContractTransactionResponse, Interface } from "ethers"; -import type { SafeERC20, SafeERC20Interface } from "../../../../../../@openzeppelin/contracts/token/ERC20/utils/SafeERC20"; +import { + Contract, + ContractFactory, + ContractTransactionResponse, + Interface, +} from "ethers"; +import type { Signer, ContractDeployTransaction, ContractRunner } from "ethers"; import type { NonPayableOverrides } from "../../../../../../common"; +import type { + SafeERC20, + SafeERC20Interface, +} from "../../../../../../@openzeppelin/contracts/token/ERC20/utils/SafeERC20"; const _abi = [ { @@ -13,41 +20,45 @@ const _abi = [ { internalType: "address", name: "spender", - type: "address" + type: "address", }, { internalType: "uint256", name: "currentAllowance", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "requestedDecrease", - type: "uint256" - } + type: "uint256", + }, ], name: "SafeERC20FailedDecreaseAllowance", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "token", - type: "address" - } + type: "address", + }, ], name: "SafeERC20FailedOperation", - type: "error" - } + type: "error", + }, ] as const; const _bytecode = "0x60556032600b8282823980515f1a607314602657634e487b7160e01b5f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212205c28a953d80cbde5965c90d7d69e4200c2946ffa7d85a8c75c36f5291a6d6e6464736f6c634300081c0033"; -type SafeERC20ConstructorParams = [signer?: Signer] | ConstructorParameters; +type SafeERC20ConstructorParams = + | [signer?: Signer] + | ConstructorParameters; -const isSuperArgs = (xs: SafeERC20ConstructorParams): xs is ConstructorParameters => xs.length > 1; +const isSuperArgs = ( + xs: SafeERC20ConstructorParams +): xs is ConstructorParameters => xs.length > 1; export class SafeERC20__factory extends ContractFactory { constructor(...args: SafeERC20ConstructorParams) { @@ -58,7 +69,9 @@ export class SafeERC20__factory extends ContractFactory { } } - override getDeployTransaction(overrides?: NonPayableOverrides & { from?: string }): Promise { + override getDeployTransaction( + overrides?: NonPayableOverrides & { from?: string } + ): Promise { return super.getDeployTransaction(overrides || {}); } override deploy(overrides?: NonPayableOverrides & { from?: string }) { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/Nonces__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/Nonces__factory.ts index 3e54bacc4..f3f05310d 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/Nonces__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/Nonces__factory.ts @@ -2,8 +2,11 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; -import type { Nonces, NoncesInterface } from "../../../../@openzeppelin/contracts/utils/Nonces"; +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + Nonces, + NoncesInterface, +} from "../../../../@openzeppelin/contracts/utils/Nonces"; const _abi = [ { @@ -11,36 +14,36 @@ const _abi = [ { internalType: "address", name: "account", - type: "address" + type: "address", }, { internalType: "uint256", name: "currentNonce", - type: "uint256" - } + type: "uint256", + }, ], name: "InvalidAccountNonce", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" - } + type: "address", + }, ], name: "nonces", outputs: [ { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], stateMutability: "view", - type: "function" - } + type: "function", + }, ] as const; export class Nonces__factory { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/ReentrancyGuard__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/ReentrancyGuard__factory.ts index b57fa9bc8..154a22871 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/ReentrancyGuard__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/ReentrancyGuard__factory.ts @@ -2,15 +2,18 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; -import type { ReentrancyGuard, ReentrancyGuardInterface } from "../../../../@openzeppelin/contracts/utils/ReentrancyGuard"; +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + ReentrancyGuard, + ReentrancyGuardInterface, +} from "../../../../@openzeppelin/contracts/utils/ReentrancyGuard"; const _abi = [ { inputs: [], name: "ReentrancyGuardReentrantCall", - type: "error" - } + type: "error", + }, ] as const; export class ReentrancyGuard__factory { @@ -18,7 +21,10 @@ export class ReentrancyGuard__factory { static createInterface(): ReentrancyGuardInterface { return new Interface(_abi) as ReentrancyGuardInterface; } - static connect(address: string, runner?: ContractRunner | null): ReentrancyGuard { + static connect( + address: string, + runner?: ContractRunner | null + ): ReentrancyGuard { return new Contract(address, _abi, runner) as unknown as ReentrancyGuard; } } diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/ShortStrings__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/ShortStrings__factory.ts index e6ae322ac..6759e751f 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/ShortStrings__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/ShortStrings__factory.ts @@ -1,37 +1,48 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ - -import type { ContractDeployTransaction, ContractRunner, Signer } from "ethers"; -import { Contract, ContractFactory, ContractTransactionResponse, Interface } from "ethers"; -import type { ShortStrings, ShortStringsInterface } from "../../../../@openzeppelin/contracts/utils/ShortStrings"; +import { + Contract, + ContractFactory, + ContractTransactionResponse, + Interface, +} from "ethers"; +import type { Signer, ContractDeployTransaction, ContractRunner } from "ethers"; import type { NonPayableOverrides } from "../../../../common"; +import type { + ShortStrings, + ShortStringsInterface, +} from "../../../../@openzeppelin/contracts/utils/ShortStrings"; const _abi = [ { inputs: [], name: "InvalidShortString", - type: "error" + type: "error", }, { inputs: [ { internalType: "string", name: "str", - type: "string" - } + type: "string", + }, ], name: "StringTooLong", - type: "error" - } + type: "error", + }, ] as const; const _bytecode = "0x60556032600b8282823980515f1a607314602657634e487b7160e01b5f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212205331629f8f9bf0cbfbabc3d9173056cd00dba4e3cc0330036bc4382e2359a42464736f6c634300081c0033"; -type ShortStringsConstructorParams = [signer?: Signer] | ConstructorParameters; +type ShortStringsConstructorParams = + | [signer?: Signer] + | ConstructorParameters; -const isSuperArgs = (xs: ShortStringsConstructorParams): xs is ConstructorParameters => xs.length > 1; +const isSuperArgs = ( + xs: ShortStringsConstructorParams +): xs is ConstructorParameters => xs.length > 1; export class ShortStrings__factory extends ContractFactory { constructor(...args: ShortStringsConstructorParams) { @@ -42,7 +53,9 @@ export class ShortStrings__factory extends ContractFactory { } } - override getDeployTransaction(overrides?: NonPayableOverrides & { from?: string }): Promise { + override getDeployTransaction( + overrides?: NonPayableOverrides & { from?: string } + ): Promise { return super.getDeployTransaction(overrides || {}); } override deploy(overrides?: NonPayableOverrides & { from?: string }) { @@ -61,7 +74,10 @@ export class ShortStrings__factory extends ContractFactory { static createInterface(): ShortStringsInterface { return new Interface(_abi) as ShortStringsInterface; } - static connect(address: string, runner?: ContractRunner | null): ShortStrings { + static connect( + address: string, + runner?: ContractRunner | null + ): ShortStrings { return new Contract(address, _abi, runner) as unknown as ShortStrings; } } diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/Strings__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/Strings__factory.ts index 01c8d8407..dc6e70236 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/Strings__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/Strings__factory.ts @@ -1,11 +1,18 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ - -import type { ContractDeployTransaction, ContractRunner, Signer } from "ethers"; -import { Contract, ContractFactory, ContractTransactionResponse, Interface } from "ethers"; -import type { Strings, StringsInterface } from "../../../../@openzeppelin/contracts/utils/Strings"; +import { + Contract, + ContractFactory, + ContractTransactionResponse, + Interface, +} from "ethers"; +import type { Signer, ContractDeployTransaction, ContractRunner } from "ethers"; import type { NonPayableOverrides } from "../../../../common"; +import type { + Strings, + StringsInterface, +} from "../../../../@openzeppelin/contracts/utils/Strings"; const _abi = [ { @@ -13,35 +20,39 @@ const _abi = [ { internalType: "uint256", name: "value", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "length", - type: "uint256" - } + type: "uint256", + }, ], name: "StringsInsufficientHexLength", - type: "error" + type: "error", }, { inputs: [], name: "StringsInvalidAddressFormat", - type: "error" + type: "error", }, { inputs: [], name: "StringsInvalidChar", - type: "error" - } + type: "error", + }, ] as const; const _bytecode = "0x60556032600b8282823980515f1a607314602657634e487b7160e01b5f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220172eadb738f4060c9567e763c4ffa1f0bf958b6bde49f20427622bab52603b4264736f6c634300081c0033"; -type StringsConstructorParams = [signer?: Signer] | ConstructorParameters; +type StringsConstructorParams = + | [signer?: Signer] + | ConstructorParameters; -const isSuperArgs = (xs: StringsConstructorParams): xs is ConstructorParameters => xs.length > 1; +const isSuperArgs = ( + xs: StringsConstructorParams +): xs is ConstructorParameters => xs.length > 1; export class Strings__factory extends ContractFactory { constructor(...args: StringsConstructorParams) { @@ -52,7 +63,9 @@ export class Strings__factory extends ContractFactory { } } - override getDeployTransaction(overrides?: NonPayableOverrides & { from?: string }): Promise { + override getDeployTransaction( + overrides?: NonPayableOverrides & { from?: string } + ): Promise { return super.getDeployTransaction(overrides || {}); } override deploy(overrides?: NonPayableOverrides & { from?: string }) { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/ECDSA__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/ECDSA__factory.ts index 3b1c484cb..63fb3132b 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/ECDSA__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/ECDSA__factory.ts @@ -1,48 +1,59 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ - -import type { ContractDeployTransaction, ContractRunner, Signer } from "ethers"; -import { Contract, ContractFactory, ContractTransactionResponse, Interface } from "ethers"; -import type { ECDSA, ECDSAInterface } from "../../../../../@openzeppelin/contracts/utils/cryptography/ECDSA"; +import { + Contract, + ContractFactory, + ContractTransactionResponse, + Interface, +} from "ethers"; +import type { Signer, ContractDeployTransaction, ContractRunner } from "ethers"; import type { NonPayableOverrides } from "../../../../../common"; +import type { + ECDSA, + ECDSAInterface, +} from "../../../../../@openzeppelin/contracts/utils/cryptography/ECDSA"; const _abi = [ { inputs: [], name: "ECDSAInvalidSignature", - type: "error" + type: "error", }, { inputs: [ { internalType: "uint256", name: "length", - type: "uint256" - } + type: "uint256", + }, ], name: "ECDSAInvalidSignatureLength", - type: "error" + type: "error", }, { inputs: [ { internalType: "bytes32", name: "s", - type: "bytes32" - } + type: "bytes32", + }, ], name: "ECDSAInvalidSignatureS", - type: "error" - } + type: "error", + }, ] as const; const _bytecode = "0x60556032600b8282823980515f1a607314602657634e487b7160e01b5f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea2646970667358221220861fc53c54256420d26202ed953c1a6a70251bfd2cbefbfb0e98c93d2669cfb464736f6c634300081c0033"; -type ECDSAConstructorParams = [signer?: Signer] | ConstructorParameters; +type ECDSAConstructorParams = + | [signer?: Signer] + | ConstructorParameters; -const isSuperArgs = (xs: ECDSAConstructorParams): xs is ConstructorParameters => xs.length > 1; +const isSuperArgs = ( + xs: ECDSAConstructorParams +): xs is ConstructorParameters => xs.length > 1; export class ECDSA__factory extends ContractFactory { constructor(...args: ECDSAConstructorParams) { @@ -53,7 +64,9 @@ export class ECDSA__factory extends ContractFactory { } } - override getDeployTransaction(overrides?: NonPayableOverrides & { from?: string }): Promise { + override getDeployTransaction( + overrides?: NonPayableOverrides & { from?: string } + ): Promise { return super.getDeployTransaction(overrides || {}); } override deploy(overrides?: NonPayableOverrides & { from?: string }) { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/EIP712__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/EIP712__factory.ts index 0f939e795..c7a4f40e3 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/EIP712__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/EIP712__factory.ts @@ -2,31 +2,34 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; -import type { EIP712, EIP712Interface } from "../../../../../@openzeppelin/contracts/utils/cryptography/EIP712"; +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + EIP712, + EIP712Interface, +} from "../../../../../@openzeppelin/contracts/utils/cryptography/EIP712"; const _abi = [ { inputs: [], name: "InvalidShortString", - type: "error" + type: "error", }, { inputs: [ { internalType: "string", name: "str", - type: "string" - } + type: "string", + }, ], name: "StringTooLong", - type: "error" + type: "error", }, { anonymous: false, inputs: [], name: "EIP712DomainChanged", - type: "event" + type: "event", }, { inputs: [], @@ -35,42 +38,42 @@ const _abi = [ { internalType: "bytes1", name: "fields", - type: "bytes1" + type: "bytes1", }, { internalType: "string", name: "name", - type: "string" + type: "string", }, { internalType: "string", name: "version", - type: "string" + type: "string", }, { internalType: "uint256", name: "chainId", - type: "uint256" + type: "uint256", }, { internalType: "address", name: "verifyingContract", - type: "address" + type: "address", }, { internalType: "bytes32", name: "salt", - type: "bytes32" + type: "bytes32", }, { internalType: "uint256[]", name: "extensions", - type: "uint256[]" - } + type: "uint256[]", + }, ], stateMutability: "view", - type: "function" - } + type: "function", + }, ] as const; export class EIP712__factory { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/MessageHashUtils__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/MessageHashUtils__factory.ts index e4298465f..6e05ae971 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/MessageHashUtils__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/cryptography/MessageHashUtils__factory.ts @@ -1,30 +1,37 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ - -import type { ContractDeployTransaction, ContractRunner, Signer } from "ethers"; -import { Contract, ContractFactory, ContractTransactionResponse, Interface } from "ethers"; +import { + Contract, + ContractFactory, + ContractTransactionResponse, + Interface, +} from "ethers"; +import type { Signer, ContractDeployTransaction, ContractRunner } from "ethers"; +import type { NonPayableOverrides } from "../../../../../common"; import type { MessageHashUtils, - MessageHashUtilsInterface + MessageHashUtilsInterface, } from "../../../../../@openzeppelin/contracts/utils/cryptography/MessageHashUtils"; -import type { NonPayableOverrides } from "../../../../../common"; const _abi = [ { inputs: [], name: "ERC5267ExtensionsNotSupported", - type: "error" - } + type: "error", + }, ] as const; const _bytecode = "0x60556032600b8282823980515f1a607314602657634e487b7160e01b5f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212206131ed8ee4dbbce47bcc4c6f88c9616eec0a3b2c76e6ecedd23ff36dd351a5da64736f6c634300081c0033"; -type MessageHashUtilsConstructorParams = [signer?: Signer] | ConstructorParameters; +type MessageHashUtilsConstructorParams = + | [signer?: Signer] + | ConstructorParameters; -const isSuperArgs = (xs: MessageHashUtilsConstructorParams): xs is ConstructorParameters => - xs.length > 1; +const isSuperArgs = ( + xs: MessageHashUtilsConstructorParams +): xs is ConstructorParameters => xs.length > 1; export class MessageHashUtils__factory extends ContractFactory { constructor(...args: MessageHashUtilsConstructorParams) { @@ -35,7 +42,9 @@ export class MessageHashUtils__factory extends ContractFactory { } } - override getDeployTransaction(overrides?: NonPayableOverrides & { from?: string }): Promise { + override getDeployTransaction( + overrides?: NonPayableOverrides & { from?: string } + ): Promise { return super.getDeployTransaction(overrides || {}); } override deploy(overrides?: NonPayableOverrides & { from?: string }) { @@ -54,7 +63,10 @@ export class MessageHashUtils__factory extends ContractFactory { static createInterface(): MessageHashUtilsInterface { return new Interface(_abi) as MessageHashUtilsInterface; } - static connect(address: string, runner?: ContractRunner | null): MessageHashUtils { + static connect( + address: string, + runner?: ContractRunner | null + ): MessageHashUtils { return new Contract(address, _abi, runner) as unknown as MessageHashUtils; } } diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/index.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/index.ts index a931c0f29..67022a6a8 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/index.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/index.ts @@ -4,6 +4,7 @@ export * as cryptography from "./cryptography"; export * as introspection from "./introspection"; export * as math from "./math"; +export { Nonces__factory } from "./Nonces__factory"; export { ReentrancyGuard__factory } from "./ReentrancyGuard__factory"; export { ShortStrings__factory } from "./ShortStrings__factory"; export { Strings__factory } from "./Strings__factory"; diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/introspection/IERC165__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/introspection/IERC165__factory.ts index 1669f20a6..5cc03947d 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/introspection/IERC165__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/introspection/IERC165__factory.ts @@ -2,8 +2,11 @@ /* tslint:disable */ /* eslint-disable */ -import { Contract, type ContractRunner, Interface } from "ethers"; -import type { IERC165, IERC165Interface } from "../../../../../@openzeppelin/contracts/utils/introspection/IERC165"; +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + IERC165, + IERC165Interface, +} from "../../../../../@openzeppelin/contracts/utils/introspection/IERC165"; const _abi = [ { @@ -11,20 +14,20 @@ const _abi = [ { internalType: "bytes4", name: "interfaceId", - type: "bytes4" - } + type: "bytes4", + }, ], name: "supportsInterface", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "view", - type: "function" - } + type: "function", + }, ] as const; export class IERC165__factory { diff --git a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/math/SafeCast__factory.ts b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/math/SafeCast__factory.ts index ffa7614c4..c8a19d068 100644 --- a/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/math/SafeCast__factory.ts +++ b/contracts/relayer/typechain-types/factories/@openzeppelin/contracts/utils/math/SafeCast__factory.ts @@ -1,11 +1,18 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ - -import type { ContractDeployTransaction, ContractRunner, Signer } from "ethers"; -import { Contract, ContractFactory, ContractTransactionResponse, Interface } from "ethers"; -import type { SafeCast, SafeCastInterface } from "../../../../../@openzeppelin/contracts/utils/math/SafeCast"; +import { + Contract, + ContractFactory, + ContractTransactionResponse, + Interface, +} from "ethers"; +import type { Signer, ContractDeployTransaction, ContractRunner } from "ethers"; import type { NonPayableOverrides } from "../../../../../common"; +import type { + SafeCast, + SafeCastInterface, +} from "../../../../../@openzeppelin/contracts/utils/math/SafeCast"; const _abi = [ { @@ -13,63 +20,67 @@ const _abi = [ { internalType: "uint8", name: "bits", - type: "uint8" + type: "uint8", }, { internalType: "int256", name: "value", - type: "int256" - } + type: "int256", + }, ], name: "SafeCastOverflowedIntDowncast", - type: "error" + type: "error", }, { inputs: [ { internalType: "int256", name: "value", - type: "int256" - } + type: "int256", + }, ], name: "SafeCastOverflowedIntToUint", - type: "error" + type: "error", }, { inputs: [ { internalType: "uint8", name: "bits", - type: "uint8" + type: "uint8", }, { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "SafeCastOverflowedUintDowncast", - type: "error" + type: "error", }, { inputs: [ { internalType: "uint256", name: "value", - type: "uint256" - } + type: "uint256", + }, ], name: "SafeCastOverflowedUintToInt", - type: "error" - } + type: "error", + }, ] as const; const _bytecode = "0x60556032600b8282823980515f1a607314602657634e487b7160e01b5f525f60045260245ffd5b305f52607381538281f3fe730000000000000000000000000000000000000000301460806040525f5ffdfea26469706673582212206e23643c396df695b2797f650857ef0eb0576e47d97a4599ed83883f99aad72664736f6c634300081c0033"; -type SafeCastConstructorParams = [signer?: Signer] | ConstructorParameters; +type SafeCastConstructorParams = + | [signer?: Signer] + | ConstructorParameters; -const isSuperArgs = (xs: SafeCastConstructorParams): xs is ConstructorParameters => xs.length > 1; +const isSuperArgs = ( + xs: SafeCastConstructorParams +): xs is ConstructorParameters => xs.length > 1; export class SafeCast__factory extends ContractFactory { constructor(...args: SafeCastConstructorParams) { @@ -80,7 +91,9 @@ export class SafeCast__factory extends ContractFactory { } } - override getDeployTransaction(overrides?: NonPayableOverrides & { from?: string }): Promise { + override getDeployTransaction( + overrides?: NonPayableOverrides & { from?: string } + ): Promise { return super.getDeployTransaction(overrides || {}); } override deploy(overrides?: NonPayableOverrides & { from?: string }) { diff --git a/contracts/relayer/typechain-types/factories/contracts/TokenRelayer__factory.ts b/contracts/relayer/typechain-types/factories/contracts/TokenRelayer__factory.ts index 44a432ee6..5dbb5eef5 100644 --- a/contracts/relayer/typechain-types/factories/contracts/TokenRelayer__factory.ts +++ b/contracts/relayer/typechain-types/factories/contracts/TokenRelayer__factory.ts @@ -1,10 +1,23 @@ /* Autogenerated file. Do not edit manually. */ -import type { AddressLike, ContractDeployTransaction, ContractRunner, Signer } from "ethers"; /* tslint:disable */ /* eslint-disable */ -import { Contract, ContractFactory, ContractTransactionResponse, Interface } from "ethers"; +import { + Contract, + ContractFactory, + ContractTransactionResponse, + Interface, +} from "ethers"; +import type { + Signer, + AddressLike, + ContractDeployTransaction, + ContractRunner, +} from "ethers"; import type { NonPayableOverrides } from "../../common"; -import type { TokenRelayer, TokenRelayerInterface } from "../../contracts/TokenRelayer"; +import type { + TokenRelayer, + TokenRelayerInterface, +} from "../../contracts/TokenRelayer"; const _abi = [ { @@ -12,98 +25,167 @@ const _abi = [ { internalType: "address", name: "_destinationContract", - type: "address" - } + type: "address", + }, ], stateMutability: "nonpayable", - type: "constructor" + type: "constructor", }, { inputs: [], name: "ECDSAInvalidSignature", - type: "error" + type: "error", }, { inputs: [ { internalType: "uint256", name: "length", - type: "uint256" - } + type: "uint256", + }, ], name: "ECDSAInvalidSignatureLength", - type: "error" + type: "error", }, { inputs: [ { internalType: "bytes32", name: "s", - type: "bytes32" - } + type: "bytes32", + }, ], name: "ECDSAInvalidSignatureS", - type: "error" + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "destination", + type: "address", + }, + ], + name: "InvalidDestination", + type: "error", }, { inputs: [], name: "InvalidShortString", - type: "error" + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "recipient", + type: "address", + }, + { + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "NativeRefundFailed", + type: "error", }, { inputs: [ { internalType: "address", name: "owner", - type: "address" - } + type: "address", + }, ], name: "OwnableInvalidOwner", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "account", - type: "address" - } + type: "address", + }, ], name: "OwnableUnauthorizedAccount", - type: "error" + type: "error", }, { inputs: [], name: "ReentrancyGuardReentrantCall", - type: "error" + type: "error", }, { inputs: [ { internalType: "address", name: "token", - type: "address" - } + type: "address", + }, ], name: "SafeERC20FailedOperation", - type: "error" + type: "error", }, { inputs: [ { internalType: "string", name: "str", - type: "string" - } + type: "string", + }, ], name: "StringTooLong", - type: "error" + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "token", + type: "address", + }, + { + internalType: "uint256", + name: "balanceBefore", + type: "uint256", + }, + { + internalType: "uint256", + name: "balanceAfter", + type: "uint256", + }, + ], + name: "TokenBalanceNotRestored", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "token", + type: "address", + }, + { + internalType: "uint256", + name: "requested", + type: "uint256", + }, + { + internalType: "uint256", + name: "received", + type: "uint256", + }, + ], + name: "TokenReceiptMismatch", + type: "error", }, { anonymous: false, inputs: [], name: "EIP712DomainChanged", - type: "event" + type: "event", }, { anonymous: false, @@ -112,17 +194,36 @@ const _abi = [ indexed: false, internalType: "uint256", name: "amount", - type: "uint256" + type: "uint256", }, { indexed: true, internalType: "address", name: "to", - type: "address" - } + type: "address", + }, ], name: "ETHWithdrawn", - type: "event" + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "executor", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "NativeRefunded", + type: "event", }, { anonymous: false, @@ -131,17 +232,17 @@ const _abi = [ indexed: true, internalType: "address", name: "previousOwner", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "newOwner", - type: "address" - } + type: "address", + }, ], name: "OwnershipTransferred", - type: "event" + type: "event", }, { anonymous: false, @@ -150,23 +251,60 @@ const _abi = [ indexed: true, internalType: "address", name: "signer", - type: "address" + type: "address", }, { indexed: true, internalType: "address", name: "token", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "amount", - type: "uint256" - } + type: "uint256", + }, ], name: "RelayerExecuted", - type: "event" + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "signer", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "token", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "requested", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "received", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "consumed", + type: "uint256", + }, + ], + name: "RelayerTransferObserved", + type: "event", }, { anonymous: false, @@ -175,23 +313,23 @@ const _abi = [ indexed: true, internalType: "address", name: "token", - type: "address" + type: "address", }, { indexed: false, internalType: "uint256", name: "amount", - type: "uint256" + type: "uint256", }, { indexed: true, internalType: "address", name: "to", - type: "address" - } + type: "address", + }, ], name: "TokenWithdrawn", - type: "event" + type: "event", }, { inputs: [], @@ -200,11 +338,11 @@ const _abi = [ { internalType: "address", name: "", - type: "address" - } + type: "address", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -213,41 +351,41 @@ const _abi = [ { internalType: "bytes1", name: "fields", - type: "bytes1" + type: "bytes1", }, { internalType: "string", name: "name", - type: "string" + type: "string", }, { internalType: "string", name: "version", - type: "string" + type: "string", }, { internalType: "uint256", name: "chainId", - type: "uint256" + type: "uint256", }, { internalType: "address", name: "verifyingContract", - type: "address" + type: "address", }, { internalType: "bytes32", name: "salt", - type: "bytes32" + type: "bytes32", }, { internalType: "uint256[]", name: "extensions", - type: "uint256[]" - } + type: "uint256[]", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ @@ -256,107 +394,107 @@ const _abi = [ { internalType: "address", name: "token", - type: "address" + type: "address", }, { internalType: "address", name: "owner", - type: "address" + type: "address", }, { internalType: "uint256", name: "value", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "deadline", - type: "uint256" + type: "uint256", }, { internalType: "uint8", name: "permitV", - type: "uint8" + type: "uint8", }, { internalType: "bytes32", name: "permitR", - type: "bytes32" + type: "bytes32", }, { internalType: "bytes32", name: "permitS", - type: "bytes32" + type: "bytes32", }, { internalType: "bytes", name: "payloadData", - type: "bytes" + type: "bytes", }, { internalType: "uint256", name: "payloadValue", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "payloadNonce", - type: "uint256" + type: "uint256", }, { internalType: "uint256", name: "payloadDeadline", - type: "uint256" + type: "uint256", }, { internalType: "uint8", name: "payloadV", - type: "uint8" + type: "uint8", }, { internalType: "bytes32", name: "payloadR", - type: "bytes32" + type: "bytes32", }, { internalType: "bytes32", name: "payloadS", - type: "bytes32" - } + type: "bytes32", + }, ], internalType: "struct TokenRelayer.ExecuteParams", name: "params", - type: "tuple" - } + type: "tuple", + }, ], name: "execute", outputs: [], stateMutability: "payable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "signer", - type: "address" + type: "address", }, { internalType: "uint256", name: "nonce", - type: "uint256" - } + type: "uint256", + }, ], name: "isExecutionCompleted", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], @@ -365,99 +503,103 @@ const _abi = [ { internalType: "address", name: "", - type: "address" - } + type: "address", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [], name: "renounceOwnership", outputs: [], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "newOwner", - type: "address" - } + type: "address", + }, ], name: "transferOwnership", outputs: [], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "", - type: "address" + type: "address", }, { internalType: "uint256", name: "", - type: "uint256" - } + type: "uint256", + }, ], name: "usedPayloadNonces", outputs: [ { internalType: "bool", name: "", - type: "bool" - } + type: "bool", + }, ], stateMutability: "view", - type: "function" + type: "function", }, { inputs: [ { internalType: "uint256", name: "amount", - type: "uint256" - } + type: "uint256", + }, ], name: "withdrawETH", outputs: [], stateMutability: "nonpayable", - type: "function" + type: "function", }, { inputs: [ { internalType: "address", name: "token", - type: "address" + type: "address", }, { internalType: "uint256", name: "amount", - type: "uint256" - } + type: "uint256", + }, ], name: "withdrawToken", outputs: [], stateMutability: "nonpayable", - type: "function" + type: "function", }, { stateMutability: "payable", - type: "receive" - } + type: "receive", + }, ] as const; const _bytecode = - "0x610180604052348015610010575f5ffd5b50604051611a4d380380611a4d83398101604081905261002f91610294565b604080518082018252600c81526b2a37b5b2b72932b630bcb2b960a11b602080830191909152825180840190935260018352603160f81b9083015290338061009157604051631e4fbdf760e01b81525f60048201526024015b60405180910390fd5b61009a816101d6565b5060017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f00556100ca826001610225565b610120526100d9816002610225565b61014052815160208084019190912060e052815190820120610100524660a05261016560e05161010051604080517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f60208201529081019290925260608201524660808201523060a08201525f9060c00160405160208183030381529060405280519060200120905090565b60805250503060c0526001600160a01b0381166101c45760405162461bcd60e51b815260206004820152601360248201527f496e76616c69642064657374696e6174696f6e000000000000000000000000006044820152606401610088565b6001600160a01b03166101605261046b565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b5f6020835110156102405761023983610257565b9050610251565b8161024b8482610359565b5060ff90505b92915050565b5f5f829050601f81511115610281578260405163305a27a960e01b81526004016100889190610413565b805161028c82610448565b179392505050565b5f602082840312156102a4575f5ffd5b81516001600160a01b03811681146102ba575f5ffd5b9392505050565b634e487b7160e01b5f52604160045260245ffd5b600181811c908216806102e957607f821691505b60208210810361030757634e487b7160e01b5f52602260045260245ffd5b50919050565b601f82111561035457805f5260205f20601f840160051c810160208510156103325750805b601f840160051c820191505b81811015610351575f815560010161033e565b50505b505050565b81516001600160401b03811115610372576103726102c1565b6103868161038084546102d5565b8461030d565b6020601f8211600181146103b8575f83156103a15750848201515b5f19600385901b1c1916600184901b178455610351565b5f84815260208120601f198516915b828110156103e757878501518255602094850194600190920191016103c7565b508482101561040457868401515f19600387901b60f8161c191681555b50505050600190811b01905550565b602081525f82518060208401528060208501604085015e5f604082850101526040601f19601f83011684010191505092915050565b80516020808301519190811015610307575f1960209190910360031b1b16919050565b60805160a05160c05160e0516101005161012051610140516101605161156c6104e15f395f818160d701528181610525015281816105f4015281816109500152610bf801525f610d2d01525f610cfb01525f6111bc01525f61119401525f6110ef01525f61111901525f611143015261156c5ff3fe608060405260043610610092575f3560e01c80639e281a98116100575780639e281a9814610159578063d850124e14610178578063dcb79457146101c1578063f14210a6146101e0578063f2fde38b146101ff575f5ffd5b80632af83bfe1461009d578063715018a6146100b257806375bd6863146100c657806384b0196e146101165780638da5cb5b1461013d575f5ffd5b3661009957005b5f5ffd5b6100b06100ab3660046112dd565b61021e565b005b3480156100bd575f5ffd5b506100b06106ae565b3480156100d1575f5ffd5b506100f97f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b348015610121575f5ffd5b5061012a6106c1565b60405161010d979695949392919061134a565b348015610148575f5ffd5b505f546001600160a01b03166100f9565b348015610164575f5ffd5b506100b06101733660046113fb565b610703565b348015610183575f5ffd5b506101b16101923660046113fb565b600360209081525f928352604080842090915290825290205460ff1681565b604051901515815260200161010d565b3480156101cc575f5ffd5b506101b16101db3660046113fb565b61078b565b3480156101eb575f5ffd5b506100b06101fa366004611423565b6107b8565b34801561020a575f5ffd5b506100b061021936600461143a565b6108a7565b6102266108e1565b5f610237604083016020840161143a565b90506101208201356001600160a01b03821661028a5760405162461bcd60e51b815260206004820152600d60248201526c24b73b30b634b21037bbb732b960991b60448201526064015b60405180910390fd5b5f610298602085018561143a565b6001600160a01b0316036102de5760405162461bcd60e51b815260206004820152600d60248201526c24b73b30b634b2103a37b5b2b760991b6044820152606401610281565b6001600160a01b0382165f90815260036020908152604080832084845290915290205460ff161561033e5760405162461bcd60e51b815260206004820152600a602482015269139bdb98d9481d5cd95960b21b6044820152606401610281565b8261014001354211156103855760405162461bcd60e51b815260206004820152600f60248201526e14185e5b1bd85908195e1c1a5c9959608a1b6044820152606401610281565b5f6103ee83610397602087018761143a565b60408701356103a960e0890189611453565b8080601f0160208091040260200160405190810160405280939291908181526020018383808284375f92019190915250505050610100890135876101408b013561090f565b90506001600160a01b038316610421826104106101808801610160890161149d565b876101800135886101a001356109db565b6001600160a01b0316146104655760405162461bcd60e51b815260206004820152600b60248201526a496e76616c69642073696760a81b6044820152606401610281565b83610100013534146104b95760405162461bcd60e51b815260206004820152601c60248201527f496e636f7272656374204554482076616c75652070726f7669646564000000006044820152606401610281565b6001600160a01b0383165f9081526003602090815260408083208584528252909120805460ff19166001179055610520906104f69086018661143a565b846040870135606088013561051160a08a0160808b0161149d565b8960a001358a60c00135610a07565b6105667f00000000000000000000000000000000000000000000000000000000000000006040860135610556602088018861143a565b6001600160a01b03169190610b75565b5f6105b261057760e0870187611453565b8080601f0160208091040260200160405190810160405280939291908181526020018383808284375f92019190915250349250610bf4915050565b9050806105ef5760405162461bcd60e51b815260206004820152600b60248201526a10d85b1b0819985a5b195960aa1b6044820152606401610281565b6106217f00000000000000000000000000000000000000000000000000000000000000005f610556602089018961143a565b61062e602086018661143a565b6001600160a01b0316846001600160a01b03167f78129a649632642d8e9f346c85d9efb70d32d50a36774c4585491a9228bbd350876040013560405161067691815260200190565b60405180910390a3505050506106ab60017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055565b50565b6106b6610c79565b6106bf5f610ca5565b565b5f6060805f5f5f60606106d2610cf4565b6106da610d26565b604080515f80825260208201909252600f60f81b9b939a50919850469750309650945092509050565b61070b610c79565b61073061071f5f546001600160a01b031690565b6001600160a01b0384169083610d53565b5f546001600160a01b03166001600160a01b0316826001600160a01b03167fa0524ee0fd8662d6c046d199da2a6d3dc49445182cec055873a5bb9c2843c8e08360405161077f91815260200190565b60405180910390a35050565b6001600160a01b0382165f90815260036020908152604080832084845290915290205460ff165b92915050565b6107c0610c79565b5f80546040516001600160a01b039091169083908381818185875af1925050503d805f811461080a576040519150601f19603f3d011682016040523d82523d5f602084013e61080f565b606091505b50509050806108565760405162461bcd60e51b8152602060048201526013602482015272115512081d1c985b9cd9995c8819985a5b1959606a1b6044820152606401610281565b5f546001600160a01b03166001600160a01b03167f6148672a948a12b8e0bf92a9338349b9ac890fad62a234abaf0a4da99f62cfcc8360405161089b91815260200190565b60405180910390a25050565b6108af610c79565b6001600160a01b0381166108d857604051631e4fbdf760e01b81525f6004820152602401610281565b6106ab81610ca5565b6108e9610d60565b60027f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055565b8351602080860191909120604080517ff0543e2024fd0ae16ccb842686c2733758ec65acfd69fb599c05b286f8db8844938101939093526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000811691840191909152808a1660608401528816608083015260a0820187905260c082015260e08101849052610100810183905261012081018290525f906109cf906101400160405160208183030381529060405280519060200120610da2565b98975050505050505050565b5f5f5f5f6109eb88888888610dce565b9250925092506109fb8282610e96565b50909695505050505050565b60405163d505accf60e01b81526001600160a01b038781166004830152306024830152604482018790526064820186905260ff8516608483015260a4820184905260c4820183905288169063d505accf9060e4015f604051808303815f87803b158015610a72575f5ffd5b505af1925050508015610a83575060015b610b5757604051636eb1769f60e11b81526001600160a01b03878116600483015230602483015286919089169063dd62ed3e90604401602060405180830381865afa158015610ad4573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610af891906114bd565b1015610b575760405162461bcd60e51b815260206004820152602860248201527f5065726d6974206661696c656420616e6420696e73756666696369656e7420616044820152676c6c6f77616e636560c01b6064820152608401610281565b610b6c6001600160a01b038816873088610f52565b50505050505050565b610b818383835f610f8e565b610bef57610b9283835f6001610f8e565b610bba57604051635274afe760e01b81526001600160a01b0384166004820152602401610281565b610bc78383836001610f8e565b610bef57604051635274afe760e01b81526001600160a01b0384166004820152602401610281565b505050565b5f5f7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168385604051610c2f91906114d4565b5f6040518083038185875af1925050503d805f8114610c69576040519150601f19603f3d011682016040523d82523d5f602084013e610c6e565b606091505b509095945050505050565b5f546001600160a01b031633146106bf5760405163118cdaa760e01b8152336004820152602401610281565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b6060610d217f00000000000000000000000000000000000000000000000000000000000000006001610ff0565b905090565b6060610d217f00000000000000000000000000000000000000000000000000000000000000006002610ff0565b610bc78383836001611099565b7f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f00546002036106bf57604051633ee5aeb560e01b815260040160405180910390fd5b5f6107b2610dae6110e3565b8360405161190160f01b8152600281019290925260228201526042902090565b5f80807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0841115610e0757505f91506003905082610e8c565b604080515f808252602082018084528a905260ff891692820192909252606081018790526080810186905260019060a0016020604051602081039080840390855afa158015610e58573d5f5f3e3d5ffd5b5050604051601f1901519150506001600160a01b038116610e8357505f925060019150829050610e8c565b92505f91508190505b9450945094915050565b5f826003811115610ea957610ea96114ea565b03610eb2575050565b6001826003811115610ec657610ec66114ea565b03610ee45760405163f645eedf60e01b815260040160405180910390fd5b6002826003811115610ef857610ef86114ea565b03610f195760405163fce698f760e01b815260048101829052602401610281565b6003826003811115610f2d57610f2d6114ea565b03610f4e576040516335e2f38360e21b815260048101829052602401610281565b5050565b610f6084848484600161120c565b610f8857604051635274afe760e01b81526001600160a01b0385166004820152602401610281565b50505050565b60405163095ea7b360e01b5f8181526001600160a01b038616600452602485905291602083604481808b5af1925060015f51148316610fe4578383151615610fd8573d5f823e3d81fd5b5f873b113d1516831692505b60405250949350505050565b606060ff831461100a5761100383611279565b90506107b2565b818054611016906114fe565b80601f0160208091040260200160405190810160405280929190818152602001828054611042906114fe565b801561108d5780601f106110645761010080835404028352916020019161108d565b820191905f5260205f20905b81548152906001019060200180831161107057829003601f168201915b505050505090506107b2565b60405163a9059cbb60e01b5f8181526001600160a01b038616600452602485905291602083604481808b5af1925060015f51148316610fe4578383151615610fd8573d5f823e3d81fd5b5f306001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001614801561113b57507f000000000000000000000000000000000000000000000000000000000000000046145b1561116557507f000000000000000000000000000000000000000000000000000000000000000090565b610d21604080517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f60208201527f0000000000000000000000000000000000000000000000000000000000000000918101919091527f000000000000000000000000000000000000000000000000000000000000000060608201524660808201523060a08201525f9060c00160405160208183030381529060405280519060200120905090565b6040516323b872dd60e01b5f8181526001600160a01b038781166004528616602452604485905291602083606481808c5af1925060015f5114831661126857838315161561125c573d5f823e3d81fd5b5f883b113d1516831692505b604052505f60605295945050505050565b60605f611285836112b6565b6040805160208082528183019092529192505f91906020820181803683375050509182525060208101929092525090565b5f60ff8216601f8111156107b257604051632cd44ac360e21b815260040160405180910390fd5b5f602082840312156112ed575f5ffd5b813567ffffffffffffffff811115611303575f5ffd5b82016101c08185031215611315575f5ffd5b9392505050565b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b60ff60f81b8816815260e060208201525f61136860e083018961131c565b828103604084015261137a818961131c565b606084018890526001600160a01b038716608085015260a0840186905283810360c0850152845180825260208087019350909101905f5b818110156113cf5783518352602093840193909201916001016113b1565b50909b9a5050505050505050505050565b80356001600160a01b03811681146113f6575f5ffd5b919050565b5f5f6040838503121561140c575f5ffd5b611415836113e0565b946020939093013593505050565b5f60208284031215611433575f5ffd5b5035919050565b5f6020828403121561144a575f5ffd5b611315826113e0565b5f5f8335601e19843603018112611468575f5ffd5b83018035915067ffffffffffffffff821115611482575f5ffd5b602001915036819003821315611496575f5ffd5b9250929050565b5f602082840312156114ad575f5ffd5b813560ff81168114611315575f5ffd5b5f602082840312156114cd575f5ffd5b5051919050565b5f82518060208501845e5f920191825250919050565b634e487b7160e01b5f52602160045260245ffd5b600181811c9082168061151257607f821691505b60208210810361153057634e487b7160e01b5f52602260045260245ffd5b5091905056fea26469706673582212209c34db2f6f83af136a5340cce7fe8ef554ea8eb633656fbf9bff3bd731aec40f64736f6c634300081c0033"; + "0x610180604052348015610010575f5ffd5b50604051611e52380380611e5283398101604081905261002f91610285565b604080518082018252600c81526b2a37b5b2b72932b630bcb2b960a11b602080830191909152825180840190935260018352603160f81b9083015290338061009157604051631e4fbdf760e01b81525f60048201526024015b60405180910390fd5b61009a816101c7565b5060017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f00556100ca826001610216565b610120526100d9816002610216565b61014052815160208084019190912060e052815190820120610100524660a05261016560e05161010051604080517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f60208201529081019290925260608201524660808201523060a08201525f9060c00160405160208183030381529060405280519060200120905090565b60805250503060c0526001600160a01b038116158061018c57506001600160a01b0381163b155b156101b557604051638eaba6f960e01b81526001600160a01b0382166004820152602401610088565b6001600160a01b03166101605261045c565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b5f6020835110156102315761022a83610248565b9050610242565b8161023c848261034a565b5060ff90505b92915050565b5f5f829050601f81511115610272578260405163305a27a960e01b81526004016100889190610404565b805161027d82610439565b179392505050565b5f60208284031215610295575f5ffd5b81516001600160a01b03811681146102ab575f5ffd5b9392505050565b634e487b7160e01b5f52604160045260245ffd5b600181811c908216806102da57607f821691505b6020821081036102f857634e487b7160e01b5f52602260045260245ffd5b50919050565b601f82111561034557805f5260205f20601f840160051c810160208510156103235750805b601f840160051c820191505b81811015610342575f815560010161032f565b50505b505050565b81516001600160401b03811115610363576103636102b2565b6103778161037184546102c6565b846102fe565b6020601f8211600181146103a9575f83156103925750848201515b5f19600385901b1c1916600184901b178455610342565b5f84815260208120601f198516915b828110156103d857878501518255602094850194600190920191016103b8565b50848210156103f557868401515f19600387901b60f8161c191681555b50505050600190811b01905550565b602081525f82518060208401528060208501604085015e5f604082850101526040601f19601f83011684010191505092915050565b805160208083015191908110156102f8575f1960209190910360031b1b16919050565b60805160a05160c05160e051610100516101205161014051610160516119796104d95f395f818160d701528181610606015281816106c301528181610be701528181610fad0152610fe601525f61111b01525f6110e901525f6115aa01525f61158201525f6114dd01525f61150701525f61153101526119795ff3fe608060405260043610610092575f3560e01c80639e281a98116100575780639e281a9814610159578063d850124e14610178578063dcb79457146101c1578063f14210a6146101e0578063f2fde38b146101ff575f5ffd5b80632af83bfe1461009d578063715018a6146100b257806375bd6863146100c657806384b0196e146101165780638da5cb5b1461013d575f5ffd5b3661009957005b5f5ffd5b6100b06100ab3660046116cb565b61021e565b005b3480156100bd575f5ffd5b506100b0610945565b3480156100d1575f5ffd5b506100f97f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b348015610121575f5ffd5b5061012a610958565b60405161010d9796959493929190611738565b348015610148575f5ffd5b505f546001600160a01b03166100f9565b348015610164575f5ffd5b506100b06101733660046117e9565b61099a565b348015610183575f5ffd5b506101b16101923660046117e9565b600360209081525f928352604080842090915290825290205460ff1681565b604051901515815260200161010d565b3480156101cc575f5ffd5b506101b16101db3660046117e9565b610a22565b3480156101eb575f5ffd5b506100b06101fa366004611811565b610a4f565b34801561020a575f5ffd5b506100b0610219366004611828565b610b3e565b610226610b78565b5f6102376040830160208401611828565b90506101208201355f61024d6020850185611828565b90506001600160a01b03831661029a5760405162461bcd60e51b815260206004820152600d60248201526c24b73b30b634b21037bbb732b960991b60448201526064015b60405180910390fd5b5f6102a86020860186611828565b6001600160a01b0316036102ee5760405162461bcd60e51b815260206004820152600d60248201526c24b73b30b634b2103a37b5b2b760991b6044820152606401610291565b6001600160a01b0383165f90815260036020908152604080832085845290915290205460ff161561034e5760405162461bcd60e51b815260206004820152600a602482015269139bdb98d9481d5cd95960b21b6044820152606401610291565b8361014001354211156103955760405162461bcd60e51b815260206004820152600f60248201526e14185e5b1bd85908195e1c1a5c9959608a1b6044820152606401610291565b5f6103fe846103a76020880188611828565b60408801356103b960e08a018a611841565b8080601f0160208091040260200160405190810160405280939291908181526020018383808284375f920191909152505050506101008a0135886101408c0135610ba6565b90506001600160a01b0384166104318261042061018089016101608a0161188b565b886101800135896101a00135610c72565b6001600160a01b0316146104755760405162461bcd60e51b815260206004820152600b60248201526a496e76616c69642073696760a81b6044820152606401610291565b84610100013534146104c95760405162461bcd60e51b815260206004820152601c60248201527f496e636f7272656374204554482076616c75652070726f7669646564000000006044820152606401610291565b6040516370a0823160e01b81523060048201525f906001600160a01b038416906370a0823190602401602060405180830381865afa15801561050d573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061053191906118ab565b90505f61053e34476118c2565b6001600160a01b0387165f90815260036020908152604080832089845282528220805460ff19166001179055919250906105a89061057e908a018a611828565b8860408b013560608c013561059960a08e0160808f0161188b565b8d60a001358e60c00135610c9e565b9050876040013581146105f7576105c26020890189611828565b60408051632742086f60e11b81526001600160a01b039092166004830152890135602482015260448101829052606401610291565b61062b6001600160a01b0386167f000000000000000000000000000000000000000000000000000000000000000083610f2b565b5f61067761063c60e08b018b611841565b8080601f0160208091040260200160405190810160405280939291908181526020018383808284375f92019190915250349250610faa915050565b9050806106b45760405162461bcd60e51b815260206004820152600b60248201526a10d85b1b0819985a5b195960aa1b6044820152606401610291565b6106e86001600160a01b0387167f00000000000000000000000000000000000000000000000000000000000000005f610f2b565b6040516370a0823160e01b81523060048201525f906001600160a01b038816906370a0823190602401602060405180830381865afa15801561072c573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061075091906118ab565b90508481146107995761076660208b018b611828565b6040516309a92a4160e31b81526001600160a01b0390911660048201526024810186905260448101829052606401610291565b5f6107a485476118c2565b90508015610852576040515f90339083908381818185875af1925050503d805f81146107eb576040519150601f19603f3d011682016040523d82523d5f602084013e6107f0565b606091505b505090508061081b57604051631312898b60e01b815233600482015260248101839052604401610291565b60405182815233907fbbbbf32cb3c4efeaec169b299dbc4352a376f0d12d36ad583f82f10a9a5d53d59060200160405180910390a2505b61085f60208c018c611828565b60408051818e01358152602081018790529081018690526001600160a01b03918216918c16907feb01d539adf5859ae1c225936c59b5701e08a2115745a842d5bfe5dd2be4b8249060600160405180910390a36108bf60208c018c611828565b6001600160a01b03168a6001600160a01b03167f78129a649632642d8e9f346c85d9efb70d32d50a36774c4585491a9228bbd3508d6040013560405161090791815260200190565b60405180910390a35050505050505050505061094260017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055565b50565b61094d611067565b6109565f611093565b565b5f6060805f5f5f60606109696110e2565b610971611114565b604080515f80825260208201909252600f60f81b9b939a50919850469750309650945092509050565b6109a2611067565b6109c76109b65f546001600160a01b031690565b6001600160a01b0384169083611141565b5f546001600160a01b03166001600160a01b0316826001600160a01b03167fa0524ee0fd8662d6c046d199da2a6d3dc49445182cec055873a5bb9c2843c8e083604051610a1691815260200190565b60405180910390a35050565b6001600160a01b0382165f90815260036020908152604080832084845290915290205460ff165b92915050565b610a57611067565b5f80546040516001600160a01b039091169083908381818185875af1925050503d805f8114610aa1576040519150601f19603f3d011682016040523d82523d5f602084013e610aa6565b606091505b5050905080610aed5760405162461bcd60e51b8152602060048201526013602482015272115512081d1c985b9cd9995c8819985a5b1959606a1b6044820152606401610291565b5f546001600160a01b03166001600160a01b03167f6148672a948a12b8e0bf92a9338349b9ac890fad62a234abaf0a4da99f62cfcc83604051610b3291815260200190565b60405180910390a25050565b610b46611067565b6001600160a01b038116610b6f57604051631e4fbdf760e01b81525f6004820152602401610291565b61094281611093565b610b8061114e565b60027f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055565b8351602080860191909120604080517ff0543e2024fd0ae16ccb842686c2733758ec65acfd69fb599c05b286f8db8844938101939093526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000811691840191909152808a1660608401528816608083015260a0820187905260c082015260e08101849052610100810183905261012081018290525f90610c66906101400160405160208183030381529060405280519060200120611190565b98975050505050505050565b5f5f5f5f610c82888888886111bc565b925092509250610c928282611284565b50909695505050505050565b60405163d505accf60e01b81526001600160a01b038781166004830152306024830152604482018790526064820186905260ff8516608483015260a4820184905260c482018390525f919089169063d505accf9060e4015f604051808303815f87803b158015610d0c575f5ffd5b505af1925050508015610d1d575060015b610df157604051636eb1769f60e11b81526001600160a01b0388811660048301523060248301528791908a169063dd62ed3e90604401602060405180830381865afa158015610d6e573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610d9291906118ab565b1015610df15760405162461bcd60e51b815260206004820152602860248201527f5065726d6974206661696c656420616e6420696e73756666696369656e7420616044820152676c6c6f77616e636560c01b6064820152608401610291565b6040516370a0823160e01b81523060048201525f906001600160a01b038a16906370a0823190602401602060405180830381865afa158015610e35573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e5991906118ab565b9050610e706001600160a01b038a1689308a611340565b6040516370a0823160e01b81523060048201525f906001600160a01b038b16906370a0823190602401602060405180830381865afa158015610eb4573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610ed891906118ab565b905081811015610f1357604051632742086f60e11b81526001600160a01b038b166004820152602481018990525f6044820152606401610291565b610f1d82826118c2565b9a9950505050505050505050565b610f378383835f61137c565b610fa557610f4883835f600161137c565b610f7057604051635274afe760e01b81526001600160a01b0384166004820152602401610291565b610f7d838383600161137c565b610fa557604051635274afe760e01b81526001600160a01b0384166004820152602401610291565b505050565b5f7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03163b5f03610fe357505f610a49565b5f7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316838560405161101d91906118e1565b5f6040518083038185875af1925050503d805f8114611057576040519150601f19603f3d011682016040523d82523d5f602084013e61105c565b606091505b509095945050505050565b5f546001600160a01b031633146109565760405163118cdaa760e01b8152336004820152602401610291565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b606061110f7f000000000000000000000000000000000000000000000000000000000000000060016113de565b905090565b606061110f7f000000000000000000000000000000000000000000000000000000000000000060026113de565b610f7d8383836001611487565b7f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005460020361095657604051633ee5aeb560e01b815260040160405180910390fd5b5f610a4961119c6114d1565b8360405161190160f01b8152600281019290925260228201526042902090565b5f80807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a08411156111f557505f9150600390508261127a565b604080515f808252602082018084528a905260ff891692820192909252606081018790526080810186905260019060a0016020604051602081039080840390855afa158015611246573d5f5f3e3d5ffd5b5050604051601f1901519150506001600160a01b03811661127157505f92506001915082905061127a565b92505f91508190505b9450945094915050565b5f826003811115611297576112976118f7565b036112a0575050565b60018260038111156112b4576112b46118f7565b036112d25760405163f645eedf60e01b815260040160405180910390fd5b60028260038111156112e6576112e66118f7565b036113075760405163fce698f760e01b815260048101829052602401610291565b600382600381111561131b5761131b6118f7565b0361133c576040516335e2f38360e21b815260048101829052602401610291565b5050565b61134e8484848460016115fa565b61137657604051635274afe760e01b81526001600160a01b0385166004820152602401610291565b50505050565b60405163095ea7b360e01b5f8181526001600160a01b038616600452602485905291602083604481808b5af1925060015f511483166113d25783831516156113c6573d5f823e3d81fd5b5f873b113d1516831692505b60405250949350505050565b606060ff83146113f8576113f183611667565b9050610a49565b8180546114049061190b565b80601f01602080910402602001604051908101604052809291908181526020018280546114309061190b565b801561147b5780601f106114525761010080835404028352916020019161147b565b820191905f5260205f20905b81548152906001019060200180831161145e57829003601f168201915b50505050509050610a49565b60405163a9059cbb60e01b5f8181526001600160a01b038616600452602485905291602083604481808b5af1925060015f511483166113d25783831516156113c6573d5f823e3d81fd5b5f306001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001614801561152957507f000000000000000000000000000000000000000000000000000000000000000046145b1561155357507f000000000000000000000000000000000000000000000000000000000000000090565b61110f604080517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f60208201527f0000000000000000000000000000000000000000000000000000000000000000918101919091527f000000000000000000000000000000000000000000000000000000000000000060608201524660808201523060a08201525f9060c00160405160208183030381529060405280519060200120905090565b6040516323b872dd60e01b5f8181526001600160a01b038781166004528616602452604485905291602083606481808c5af1925060015f5114831661165657838315161561164a573d5f823e3d81fd5b5f883b113d1516831692505b604052505f60605295945050505050565b60605f611673836116a4565b6040805160208082528183019092529192505f91906020820181803683375050509182525060208101929092525090565b5f60ff8216601f811115610a4957604051632cd44ac360e21b815260040160405180910390fd5b5f602082840312156116db575f5ffd5b813567ffffffffffffffff8111156116f1575f5ffd5b82016101c08185031215611703575f5ffd5b9392505050565b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b60ff60f81b8816815260e060208201525f61175660e083018961170a565b8281036040840152611768818961170a565b606084018890526001600160a01b038716608085015260a0840186905283810360c0850152845180825260208087019350909101905f5b818110156117bd57835183526020938401939092019160010161179f565b50909b9a5050505050505050505050565b80356001600160a01b03811681146117e4575f5ffd5b919050565b5f5f604083850312156117fa575f5ffd5b611803836117ce565b946020939093013593505050565b5f60208284031215611821575f5ffd5b5035919050565b5f60208284031215611838575f5ffd5b611703826117ce565b5f5f8335601e19843603018112611856575f5ffd5b83018035915067ffffffffffffffff821115611870575f5ffd5b602001915036819003821315611884575f5ffd5b9250929050565b5f6020828403121561189b575f5ffd5b813560ff81168114611703575f5ffd5b5f602082840312156118bb575f5ffd5b5051919050565b81810381811115610a4957634e487b7160e01b5f52601160045260245ffd5b5f82518060208501845e5f920191825250919050565b634e487b7160e01b5f52602160045260245ffd5b600181811c9082168061191f57607f821691505b60208210810361193d57634e487b7160e01b5f52602260045260245ffd5b5091905056fea2646970667358221220353028e05cca3f5069d9b72cf7093569fca6695f53c6a3955a01379b84971dc664736f6c634300081c0033"; -type TokenRelayerConstructorParams = [signer?: Signer] | ConstructorParameters; +type TokenRelayerConstructorParams = + | [signer?: Signer] + | ConstructorParameters; -const isSuperArgs = (xs: TokenRelayerConstructorParams): xs is ConstructorParameters => xs.length > 1; +const isSuperArgs = ( + xs: TokenRelayerConstructorParams +): xs is ConstructorParameters => xs.length > 1; export class TokenRelayer__factory extends ContractFactory { constructor(...args: TokenRelayerConstructorParams) { @@ -474,7 +616,10 @@ export class TokenRelayer__factory extends ContractFactory { ): Promise { return super.getDeployTransaction(_destinationContract, overrides || {}); } - override deploy(_destinationContract: AddressLike, overrides?: NonPayableOverrides & { from?: string }) { + override deploy( + _destinationContract: AddressLike, + overrides?: NonPayableOverrides & { from?: string } + ) { return super.deploy(_destinationContract, overrides || {}) as Promise< TokenRelayer & { deploymentTransaction(): ContractTransactionResponse; @@ -490,7 +635,10 @@ export class TokenRelayer__factory extends ContractFactory { static createInterface(): TokenRelayerInterface { return new Interface(_abi) as TokenRelayerInterface; } - static connect(address: string, runner?: ContractRunner | null): TokenRelayer { + static connect( + address: string, + runner?: ContractRunner | null + ): TokenRelayer { return new Contract(address, _abi, runner) as unknown as TokenRelayer; } } diff --git a/contracts/relayer/typechain-types/factories/contracts/index.ts b/contracts/relayer/typechain-types/factories/contracts/index.ts index f067abc51..75d089e41 100644 --- a/contracts/relayer/typechain-types/factories/contracts/index.ts +++ b/contracts/relayer/typechain-types/factories/contracts/index.ts @@ -1,4 +1,5 @@ /* Autogenerated file. Do not edit manually. */ /* tslint:disable */ /* eslint-disable */ +export * as mocks from "./mocks"; export { TokenRelayer__factory } from "./TokenRelayer__factory"; diff --git a/contracts/relayer/typechain-types/factories/contracts/mocks/MockERC20Permit__factory.ts b/contracts/relayer/typechain-types/factories/contracts/mocks/MockERC20Permit__factory.ts new file mode 100644 index 000000000..3680fd779 --- /dev/null +++ b/contracts/relayer/typechain-types/factories/contracts/mocks/MockERC20Permit__factory.ts @@ -0,0 +1,634 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ +import { + Contract, + ContractFactory, + ContractTransactionResponse, + Interface, +} from "ethers"; +import type { Signer, ContractDeployTransaction, ContractRunner } from "ethers"; +import type { NonPayableOverrides } from "../../../common"; +import type { + MockERC20Permit, + MockERC20PermitInterface, +} from "../../../contracts/mocks/MockERC20Permit"; + +const _abi = [ + { + inputs: [], + stateMutability: "nonpayable", + type: "constructor", + }, + { + inputs: [], + name: "ECDSAInvalidSignature", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "length", + type: "uint256", + }, + ], + name: "ECDSAInvalidSignatureLength", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "s", + type: "bytes32", + }, + ], + name: "ECDSAInvalidSignatureS", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "spender", + type: "address", + }, + { + internalType: "uint256", + name: "allowance", + type: "uint256", + }, + { + internalType: "uint256", + name: "needed", + type: "uint256", + }, + ], + name: "ERC20InsufficientAllowance", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "sender", + type: "address", + }, + { + internalType: "uint256", + name: "balance", + type: "uint256", + }, + { + internalType: "uint256", + name: "needed", + type: "uint256", + }, + ], + name: "ERC20InsufficientBalance", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "approver", + type: "address", + }, + ], + name: "ERC20InvalidApprover", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "receiver", + type: "address", + }, + ], + name: "ERC20InvalidReceiver", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "sender", + type: "address", + }, + ], + name: "ERC20InvalidSender", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "spender", + type: "address", + }, + ], + name: "ERC20InvalidSpender", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "deadline", + type: "uint256", + }, + ], + name: "ERC2612ExpiredSignature", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "signer", + type: "address", + }, + { + internalType: "address", + name: "owner", + type: "address", + }, + ], + name: "ERC2612InvalidSigner", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "account", + type: "address", + }, + { + internalType: "uint256", + name: "currentNonce", + type: "uint256", + }, + ], + name: "InvalidAccountNonce", + type: "error", + }, + { + inputs: [], + name: "InvalidShortString", + type: "error", + }, + { + inputs: [ + { + internalType: "string", + name: "str", + type: "string", + }, + ], + name: "StringTooLong", + type: "error", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "spender", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + name: "Approval", + type: "event", + }, + { + anonymous: false, + inputs: [], + name: "EIP712DomainChanged", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "from", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "to", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + name: "Transfer", + type: "event", + }, + { + inputs: [], + name: "DOMAIN_SEPARATOR", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "address", + name: "spender", + type: "address", + }, + ], + name: "allowance", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "spender", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + name: "approve", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "balanceOf", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "decimals", + outputs: [ + { + internalType: "uint8", + name: "", + type: "uint8", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "eip712Domain", + outputs: [ + { + internalType: "bytes1", + name: "fields", + type: "bytes1", + }, + { + internalType: "string", + name: "name", + type: "string", + }, + { + internalType: "string", + name: "version", + type: "string", + }, + { + internalType: "uint256", + name: "chainId", + type: "uint256", + }, + { + internalType: "address", + name: "verifyingContract", + type: "address", + }, + { + internalType: "bytes32", + name: "salt", + type: "bytes32", + }, + { + internalType: "uint256[]", + name: "extensions", + type: "uint256[]", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "feeBps", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "mint", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "name", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "owner", + type: "address", + }, + ], + name: "nonces", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "owner", + type: "address", + }, + { + internalType: "address", + name: "spender", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + { + internalType: "uint256", + name: "deadline", + type: "uint256", + }, + { + internalType: "uint8", + name: "v", + type: "uint8", + }, + { + internalType: "bytes32", + name: "r", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "s", + type: "bytes32", + }, + ], + name: "permit", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "newFeeBps", + type: "uint256", + }, + ], + name: "setFeeBps", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "symbol", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "totalSupply", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + name: "transfer", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "from", + type: "address", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + name: "transferFrom", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + +const _bytecode = + "0x610160604052348015610010575f5ffd5b506040518060400160405280601181526020017026b7b1b5902832b936b4ba102a37b5b2b760791b81525080604051806040016040528060018152602001603160f81b8152506040518060400160405280601181526020017026b7b1b5902832b936b4ba102a37b5b2b760791b8152506040518060400160405280600381526020016213541560ea1b81525081600390816100ab9190610282565b5060046100b88282610282565b506100c891508390506005610172565b610120526100d7816006610172565b61014052815160208084019190912060e052815190820120610100524660a05261016360e05161010051604080517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f60208201529081019290925260608201524660808201523060a08201525f9060c00160405160208183030381529060405280519060200120905090565b60805250503060c05250610394565b5f60208351101561018d57610186836101a4565b905061019e565b816101988482610282565b5060ff90505b92915050565b5f5f829050601f815111156101d7578260405163305a27a960e01b81526004016101ce919061033c565b60405180910390fd5b80516101e282610371565b179392505050565b634e487b7160e01b5f52604160045260245ffd5b600181811c9082168061021257607f821691505b60208210810361023057634e487b7160e01b5f52602260045260245ffd5b50919050565b601f82111561027d57805f5260205f20601f840160051c8101602085101561025b5750805b601f840160051c820191505b8181101561027a575f8155600101610267565b50505b505050565b81516001600160401b0381111561029b5761029b6101ea565b6102af816102a984546101fe565b84610236565b6020601f8211600181146102e1575f83156102ca5750848201515b5f19600385901b1c1916600184901b17845561027a565b5f84815260208120601f198516915b8281101561031057878501518255602094850194600190920191016102f0565b508482101561032d57868401515f19600387901b60f8161c191681555b50505050600190811b01905550565b602081525f82518060208401528060208501604085015e5f604082850101526040601f19601f83011684010191505092915050565b80516020808301519190811015610230575f1960209190910360031b1b16919050565b60805160a05160c05160e0516101005161012051610140516110616103e55f395f6107ca01525f61079d01525f61071201525f6106ea01525f61064501525f61066f01525f61069901526110615ff3fe608060405234801561000f575f5ffd5b50600436106100fb575f3560e01c806370a082311161009357806395d89b411161006357806395d89b4114610203578063a9059cbb1461020b578063d505accf1461021e578063dd62ed3e14610231575f5ffd5b806370a082311461019a57806372c27b62146101c25780637ecebe00146101d557806384b0196e146101e8575f5ffd5b806324a9d853116100ce57806324a9d85314610165578063313ce5671461016e5780633644e5151461017d57806340c10f1914610185575f5ffd5b806306fdde03146100ff578063095ea7b31461011d57806318160ddd1461014057806323b872dd14610152575b5f5ffd5b610107610269565b6040516101149190610d75565b60405180910390f35b61013061012b366004610da9565b6102f9565b6040519015158152602001610114565b6002545b604051908152602001610114565b610130610160366004610dd1565b610312565b61014460085481565b60405160128152602001610114565b610144610335565b610198610193366004610da9565b610343565b005b6101446101a8366004610e0b565b6001600160a01b03165f9081526020819052604090205490565b6101986101d0366004610e24565b610351565b6101446101e3366004610e0b565b61039d565b6101f06103ba565b6040516101149796959493929190610e3b565b6101076103fc565b610130610219366004610da9565b61040b565b61019861022c366004610ed1565b610418565b61014461023f366004610f3e565b6001600160a01b039182165f90815260016020908152604080832093909416825291909152205490565b60606003805461027890610f6f565b80601f01602080910402602001604051908101604052809291908181526020018280546102a490610f6f565b80156102ef5780601f106102c6576101008083540402835291602001916102ef565b820191905f5260205f20905b8154815290600101906020018083116102d257829003601f168201915b5050505050905090565b5f3361030681858561054e565b60019150505b92915050565b5f3361031f858285610560565b61032a8585856105dc565b506001949350505050565b5f61033e610639565b905090565b61034d8282610762565b5050565b6127108111156103985760405162461bcd60e51b815260206004820152600d60248201526c66656520746f6f206c6172676560981b60448201526064015b60405180910390fd5b600855565b6001600160a01b0381165f9081526007602052604081205461030c565b5f6060805f5f5f60606103cb610796565b6103d36107c3565b604080515f80825260208201909252600f60f81b9b939a50919850469750309650945092509050565b60606004805461027890610f6f565b5f336103068185856105dc565b8342111561043c5760405163313c898160e11b81526004810185905260240161038f565b5f7f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c98888886104878c6001600160a01b03165f90815260076020526040902080546001810190915590565b6040805160208101969096526001600160a01b0394851690860152929091166060840152608083015260a082015260c0810186905260e0016040516020818303038152906040528051906020012090505f6104e1826107f0565b90505f6104f08287878761081c565b9050896001600160a01b0316816001600160a01b031614610537576040516325c0072360e11b81526001600160a01b0380831660048301528b16602482015260440161038f565b6105428a8a8a61054e565b50505050505050505050565b61055b8383836001610848565b505050565b6001600160a01b038381165f908152600160209081526040808320938616835292905220545f198110156105d657818110156105c857604051637dc7a0d960e11b81526001600160a01b0384166004820152602481018290526044810183905260640161038f565b6105d684848484035f610848565b50505050565b6001600160a01b03831661060557604051634b637e8f60e11b81525f600482015260240161038f565b6001600160a01b03821661062e5760405163ec442f0560e01b81525f600482015260240161038f565b61055b83838361091a565b5f306001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001614801561069157507f000000000000000000000000000000000000000000000000000000000000000046145b156106bb57507f000000000000000000000000000000000000000000000000000000000000000090565b61033e604080517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f60208201527f0000000000000000000000000000000000000000000000000000000000000000918101919091527f000000000000000000000000000000000000000000000000000000000000000060608201524660808201523060a08201525f9060c00160405160208183030381529060405280519060200120905090565b6001600160a01b03821661078b5760405163ec442f0560e01b81525f600482015260240161038f565b61034d5f838361091a565b606061033e7f00000000000000000000000000000000000000000000000000000000000000006005610994565b606061033e7f00000000000000000000000000000000000000000000000000000000000000006006610994565b5f61030c6107fc610639565b8360405161190160f01b8152600281019290925260228201526042902090565b5f5f5f5f61082c88888888610a3d565b92509250925061083c8282610b05565b50909695505050505050565b6001600160a01b0384166108715760405163e602df0560e01b81525f600482015260240161038f565b6001600160a01b03831661089a57604051634a1406b160e11b81525f600482015260240161038f565b6001600160a01b038085165f90815260016020908152604080832093871683529290522082905580156105d657826001600160a01b0316846001600160a01b03167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9258460405161090c91815260200190565b60405180910390a350505050565b5f60085411801561093357506001600160a01b03831615155b801561094757506001600160a01b03821615155b15610989575f6127106008548361095e9190610fbb565b6109689190610fd2565b9050610975845f83610bbd565b6105d684846109848486610ff1565b610bbd565b61055b838383610bbd565b606060ff83146109ae576109a783610ce3565b905061030c565b8180546109ba90610f6f565b80601f01602080910402602001604051908101604052809291908181526020018280546109e690610f6f565b8015610a315780601f10610a0857610100808354040283529160200191610a31565b820191905f5260205f20905b815481529060010190602001808311610a1457829003601f168201915b5050505050905061030c565b5f80807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0841115610a7657505f91506003905082610afb565b604080515f808252602082018084528a905260ff891692820192909252606081018790526080810186905260019060a0016020604051602081039080840390855afa158015610ac7573d5f5f3e3d5ffd5b5050604051601f1901519150506001600160a01b038116610af257505f925060019150829050610afb565b92505f91508190505b9450945094915050565b5f826003811115610b1857610b18611004565b03610b21575050565b6001826003811115610b3557610b35611004565b03610b535760405163f645eedf60e01b815260040160405180910390fd5b6002826003811115610b6757610b67611004565b03610b885760405163fce698f760e01b81526004810182905260240161038f565b6003826003811115610b9c57610b9c611004565b0361034d576040516335e2f38360e21b81526004810182905260240161038f565b6001600160a01b038316610be7578060025f828254610bdc9190611018565b90915550610c579050565b6001600160a01b0383165f9081526020819052604090205481811015610c395760405163391434e360e21b81526001600160a01b0385166004820152602481018290526044810183905260640161038f565b6001600160a01b0384165f9081526020819052604090209082900390555b6001600160a01b038216610c7357600280548290039055610c91565b6001600160a01b0382165f9081526020819052604090208054820190555b816001600160a01b0316836001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef83604051610cd691815260200190565b60405180910390a3505050565b60605f610cef83610d20565b6040805160208082528183019092529192505f91906020820181803683375050509182525060208101929092525090565b5f60ff8216601f81111561030c57604051632cd44ac360e21b815260040160405180910390fd5b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b602081525f610d876020830184610d47565b9392505050565b80356001600160a01b0381168114610da4575f5ffd5b919050565b5f5f60408385031215610dba575f5ffd5b610dc383610d8e565b946020939093013593505050565b5f5f5f60608486031215610de3575f5ffd5b610dec84610d8e565b9250610dfa60208501610d8e565b929592945050506040919091013590565b5f60208284031215610e1b575f5ffd5b610d8782610d8e565b5f60208284031215610e34575f5ffd5b5035919050565b60ff60f81b8816815260e060208201525f610e5960e0830189610d47565b8281036040840152610e6b8189610d47565b606084018890526001600160a01b038716608085015260a0840186905283810360c0850152845180825260208087019350909101905f5b81811015610ec0578351835260209384019390920191600101610ea2565b50909b9a5050505050505050505050565b5f5f5f5f5f5f5f60e0888a031215610ee7575f5ffd5b610ef088610d8e565b9650610efe60208901610d8e565b95506040880135945060608801359350608088013560ff81168114610f21575f5ffd5b9699959850939692959460a0840135945060c09093013592915050565b5f5f60408385031215610f4f575f5ffd5b610f5883610d8e565b9150610f6660208401610d8e565b90509250929050565b600181811c90821680610f8357607f821691505b602082108103610fa157634e487b7160e01b5f52602260045260245ffd5b50919050565b634e487b7160e01b5f52601160045260245ffd5b808202811582820484141761030c5761030c610fa7565b5f82610fec57634e487b7160e01b5f52601260045260245ffd5b500490565b8181038181111561030c5761030c610fa7565b634e487b7160e01b5f52602160045260245ffd5b8082018082111561030c5761030c610fa756fea26469706673582212205faa6459b9c2d29a39966568b5f8cda1ac5910adf3ef5aa808f550b2f77d1f6164736f6c634300081c0033"; + +type MockERC20PermitConstructorParams = + | [signer?: Signer] + | ConstructorParameters; + +const isSuperArgs = ( + xs: MockERC20PermitConstructorParams +): xs is ConstructorParameters => xs.length > 1; + +export class MockERC20Permit__factory extends ContractFactory { + constructor(...args: MockERC20PermitConstructorParams) { + if (isSuperArgs(args)) { + super(...args); + } else { + super(_abi, _bytecode, args[0]); + } + } + + override getDeployTransaction( + overrides?: NonPayableOverrides & { from?: string } + ): Promise { + return super.getDeployTransaction(overrides || {}); + } + override deploy(overrides?: NonPayableOverrides & { from?: string }) { + return super.deploy(overrides || {}) as Promise< + MockERC20Permit & { + deploymentTransaction(): ContractTransactionResponse; + } + >; + } + override connect(runner: ContractRunner | null): MockERC20Permit__factory { + return super.connect(runner) as MockERC20Permit__factory; + } + + static readonly bytecode = _bytecode; + static readonly abi = _abi; + static createInterface(): MockERC20PermitInterface { + return new Interface(_abi) as MockERC20PermitInterface; + } + static connect( + address: string, + runner?: ContractRunner | null + ): MockERC20Permit { + return new Contract(address, _abi, runner) as unknown as MockERC20Permit; + } +} diff --git a/contracts/relayer/typechain-types/factories/contracts/mocks/MockRelayerDestination__factory.ts b/contracts/relayer/typechain-types/factories/contracts/mocks/MockRelayerDestination__factory.ts new file mode 100644 index 000000000..e9bbe760c --- /dev/null +++ b/contracts/relayer/typechain-types/factories/contracts/mocks/MockRelayerDestination__factory.ts @@ -0,0 +1,135 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ +import { + Contract, + ContractFactory, + ContractTransactionResponse, + Interface, +} from "ethers"; +import type { Signer, ContractDeployTransaction, ContractRunner } from "ethers"; +import type { NonPayableOverrides } from "../../../common"; +import type { + MockRelayerDestination, + MockRelayerDestinationInterface, +} from "../../../contracts/mocks/MockRelayerDestination"; + +const _abi = [ + { + inputs: [ + { + internalType: "address", + name: "token", + type: "address", + }, + ], + name: "SafeERC20FailedOperation", + type: "error", + }, + { + inputs: [ + { + internalType: "address", + name: "token", + type: "address", + }, + { + internalType: "address", + name: "recipient", + type: "address", + }, + { + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "pull", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "token", + type: "address", + }, + { + internalType: "address", + name: "recipient", + type: "address", + }, + { + internalType: "uint256", + name: "amount", + type: "uint256", + }, + { + internalType: "uint256", + name: "refund", + type: "uint256", + }, + ], + name: "pullAndRefund", + outputs: [], + stateMutability: "payable", + type: "function", + }, +] as const; + +const _bytecode = + "0x6080604052348015600e575f5ffd5b5061028f8061001c5f395ff3fe608060405260043610610028575f3560e01c80636822beba1461002c5780637372c2b514610041575b5f5ffd5b61003f61003a3660046101e0565b610060565b005b34801561004c575f5ffd5b5061003f61005b36600461021f565b6100fc565b61006b84848461010c565b6040515f90339083908381818185875af1925050503d805f81146100aa576040519150601f19603f3d011682016040523d82523d5f602084013e6100af565b606091505b50509050806100f55760405162461bcd60e51b815260206004820152600d60248201526c1c99599d5b990819985a5b1959609a1b60448201526064015b60405180910390fd5b5050505050565b61010783838361010c565b505050565b6101076001600160a01b03841633848461012a848484846001610158565b61015257604051635274afe760e01b81526001600160a01b03851660048201526024016100ec565b50505050565b6040516323b872dd60e01b5f8181526001600160a01b038781166004528616602452604485905291602083606481808c5af1925060015f511483166101b45783831516156101a8573d5f823e3d81fd5b5f883b113d1516831692505b604052505f60605295945050505050565b80356001600160a01b03811681146101db575f5ffd5b919050565b5f5f5f5f608085870312156101f3575f5ffd5b6101fc856101c5565b935061020a602086016101c5565b93969395505050506040820135916060013590565b5f5f5f60608486031215610231575f5ffd5b61023a846101c5565b9250610248602085016101c5565b92959294505050604091909101359056fea26469706673582212203472ade4b8db4d4e4eccd0c9b66238df3b11fc6813c1e453cb84ba3dfffaf92c64736f6c634300081c0033"; + +type MockRelayerDestinationConstructorParams = + | [signer?: Signer] + | ConstructorParameters; + +const isSuperArgs = ( + xs: MockRelayerDestinationConstructorParams +): xs is ConstructorParameters => xs.length > 1; + +export class MockRelayerDestination__factory extends ContractFactory { + constructor(...args: MockRelayerDestinationConstructorParams) { + if (isSuperArgs(args)) { + super(...args); + } else { + super(_abi, _bytecode, args[0]); + } + } + + override getDeployTransaction( + overrides?: NonPayableOverrides & { from?: string } + ): Promise { + return super.getDeployTransaction(overrides || {}); + } + override deploy(overrides?: NonPayableOverrides & { from?: string }) { + return super.deploy(overrides || {}) as Promise< + MockRelayerDestination & { + deploymentTransaction(): ContractTransactionResponse; + } + >; + } + override connect( + runner: ContractRunner | null + ): MockRelayerDestination__factory { + return super.connect(runner) as MockRelayerDestination__factory; + } + + static readonly bytecode = _bytecode; + static readonly abi = _abi; + static createInterface(): MockRelayerDestinationInterface { + return new Interface(_abi) as MockRelayerDestinationInterface; + } + static connect( + address: string, + runner?: ContractRunner | null + ): MockRelayerDestination { + return new Contract( + address, + _abi, + runner + ) as unknown as MockRelayerDestination; + } +} diff --git a/contracts/relayer/typechain-types/factories/contracts/mocks/index.ts b/contracts/relayer/typechain-types/factories/contracts/mocks/index.ts new file mode 100644 index 000000000..a98938537 --- /dev/null +++ b/contracts/relayer/typechain-types/factories/contracts/mocks/index.ts @@ -0,0 +1,5 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ +export { MockERC20Permit__factory } from "./MockERC20Permit__factory"; +export { MockRelayerDestination__factory } from "./MockRelayerDestination__factory"; diff --git a/contracts/relayer/typechain-types/hardhat.d.ts b/contracts/relayer/typechain-types/hardhat.d.ts index 35ba7108b..15c2c584b 100644 --- a/contracts/relayer/typechain-types/hardhat.d.ts +++ b/contracts/relayer/typechain-types/hardhat.d.ts @@ -6,14 +6,29 @@ import { ethers } from "ethers"; import { DeployContractOptions, FactoryOptions, - HardhatEthersHelpers as HardhatEthersHelpersBase + HardhatEthersHelpers as HardhatEthersHelpersBase, } from "@nomicfoundation/hardhat-ethers/types"; import * as Contracts from "."; declare module "hardhat/types/runtime" { interface HardhatEthersHelpers extends HardhatEthersHelpersBase { - getContractFactory(name: "Ownable", signerOrOptions?: ethers.Signer | FactoryOptions): Promise; + getContractFactory( + name: "Ownable", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; + getContractFactory( + name: "IERC1155Errors", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; + getContractFactory( + name: "IERC20Errors", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; + getContractFactory( + name: "IERC721Errors", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; getContractFactory( name: "IERC1363", signerOrOptions?: ethers.Signer | FactoryOptions @@ -22,26 +37,54 @@ declare module "hardhat/types/runtime" { name: "IERC5267", signerOrOptions?: ethers.Signer | FactoryOptions ): Promise; + getContractFactory( + name: "ERC20", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; + getContractFactory( + name: "ERC20Permit", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; + getContractFactory( + name: "IERC20Metadata", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; getContractFactory( name: "IERC20Permit", signerOrOptions?: ethers.Signer | FactoryOptions ): Promise; - getContractFactory(name: "IERC20", signerOrOptions?: ethers.Signer | FactoryOptions): Promise; + getContractFactory( + name: "IERC20", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; getContractFactory( name: "SafeERC20", signerOrOptions?: ethers.Signer | FactoryOptions ): Promise; - getContractFactory(name: "ECDSA", signerOrOptions?: ethers.Signer | FactoryOptions): Promise; - getContractFactory(name: "EIP712", signerOrOptions?: ethers.Signer | FactoryOptions): Promise; + getContractFactory( + name: "ECDSA", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; + getContractFactory( + name: "EIP712", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; getContractFactory( name: "MessageHashUtils", signerOrOptions?: ethers.Signer | FactoryOptions ): Promise; - getContractFactory(name: "IERC165", signerOrOptions?: ethers.Signer | FactoryOptions): Promise; + getContractFactory( + name: "IERC165", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; getContractFactory( name: "SafeCast", signerOrOptions?: ethers.Signer | FactoryOptions ): Promise; + getContractFactory( + name: "Nonces", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; getContractFactory( name: "ReentrancyGuard", signerOrOptions?: ethers.Signer | FactoryOptions @@ -50,35 +93,113 @@ declare module "hardhat/types/runtime" { name: "ShortStrings", signerOrOptions?: ethers.Signer | FactoryOptions ): Promise; - getContractFactory(name: "Strings", signerOrOptions?: ethers.Signer | FactoryOptions): Promise; + getContractFactory( + name: "Strings", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; + getContractFactory( + name: "MockERC20Permit", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; + getContractFactory( + name: "MockRelayerDestination", + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; getContractFactory( name: "TokenRelayer", signerOrOptions?: ethers.Signer | FactoryOptions ): Promise; - getContractAt(name: "Ownable", address: string | ethers.Addressable, signer?: ethers.Signer): Promise; - getContractAt(name: "IERC1363", address: string | ethers.Addressable, signer?: ethers.Signer): Promise; - getContractAt(name: "IERC5267", address: string | ethers.Addressable, signer?: ethers.Signer): Promise; + getContractAt( + name: "Ownable", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "IERC1155Errors", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "IERC20Errors", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "IERC721Errors", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "IERC1363", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "IERC5267", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "ERC20", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "ERC20Permit", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "IERC20Metadata", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; getContractAt( name: "IERC20Permit", address: string | ethers.Addressable, signer?: ethers.Signer ): Promise; - getContractAt(name: "IERC20", address: string | ethers.Addressable, signer?: ethers.Signer): Promise; + getContractAt( + name: "IERC20", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; getContractAt( name: "SafeERC20", address: string | ethers.Addressable, signer?: ethers.Signer ): Promise; - getContractAt(name: "ECDSA", address: string | ethers.Addressable, signer?: ethers.Signer): Promise; - getContractAt(name: "EIP712", address: string | ethers.Addressable, signer?: ethers.Signer): Promise; + getContractAt( + name: "ECDSA", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "EIP712", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; getContractAt( name: "MessageHashUtils", address: string | ethers.Addressable, signer?: ethers.Signer ): Promise; - getContractAt(name: "IERC165", address: string | ethers.Addressable, signer?: ethers.Signer): Promise; - getContractAt(name: "SafeCast", address: string | ethers.Addressable, signer?: ethers.Signer): Promise; + getContractAt( + name: "IERC165", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "SafeCast", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "Nonces", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; getContractAt( name: "ReentrancyGuard", address: string | ethers.Addressable, @@ -89,30 +210,99 @@ declare module "hardhat/types/runtime" { address: string | ethers.Addressable, signer?: ethers.Signer ): Promise; - getContractAt(name: "Strings", address: string | ethers.Addressable, signer?: ethers.Signer): Promise; + getContractAt( + name: "Strings", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "MockERC20Permit", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; + getContractAt( + name: "MockRelayerDestination", + address: string | ethers.Addressable, + signer?: ethers.Signer + ): Promise; getContractAt( name: "TokenRelayer", address: string | ethers.Addressable, signer?: ethers.Signer ): Promise; - deployContract(name: "Ownable", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; - deployContract(name: "IERC1363", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; - deployContract(name: "IERC5267", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; + deployContract( + name: "Ownable", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "IERC1155Errors", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "IERC20Errors", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "IERC721Errors", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "IERC1363", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "IERC5267", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "ERC20", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "ERC20Permit", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "IERC20Metadata", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; deployContract( name: "IERC20Permit", signerOrOptions?: ethers.Signer | DeployContractOptions ): Promise; - deployContract(name: "IERC20", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; - deployContract(name: "SafeERC20", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; - deployContract(name: "ECDSA", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; - deployContract(name: "EIP712", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; + deployContract( + name: "IERC20", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "SafeERC20", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "ECDSA", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "EIP712", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; deployContract( name: "MessageHashUtils", signerOrOptions?: ethers.Signer | DeployContractOptions ): Promise; - deployContract(name: "IERC165", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; - deployContract(name: "SafeCast", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; + deployContract( + name: "IERC165", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "SafeCast", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "Nonces", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; deployContract( name: "ReentrancyGuard", signerOrOptions?: ethers.Signer | DeployContractOptions @@ -121,7 +311,18 @@ declare module "hardhat/types/runtime" { name: "ShortStrings", signerOrOptions?: ethers.Signer | DeployContractOptions ): Promise; - deployContract(name: "Strings", signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; + deployContract( + name: "Strings", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "MockERC20Permit", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "MockRelayerDestination", + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; deployContract( name: "TokenRelayer", signerOrOptions?: ethers.Signer | DeployContractOptions @@ -132,6 +333,21 @@ declare module "hardhat/types/runtime" { args: any[], signerOrOptions?: ethers.Signer | DeployContractOptions ): Promise; + deployContract( + name: "IERC1155Errors", + args: any[], + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "IERC20Errors", + args: any[], + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "IERC721Errors", + args: any[], + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; deployContract( name: "IERC1363", args: any[], @@ -142,6 +358,21 @@ declare module "hardhat/types/runtime" { args: any[], signerOrOptions?: ethers.Signer | DeployContractOptions ): Promise; + deployContract( + name: "ERC20", + args: any[], + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "ERC20Permit", + args: any[], + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "IERC20Metadata", + args: any[], + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; deployContract( name: "IERC20Permit", args: any[], @@ -182,6 +413,11 @@ declare module "hardhat/types/runtime" { args: any[], signerOrOptions?: ethers.Signer | DeployContractOptions ): Promise; + deployContract( + name: "Nonces", + args: any[], + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; deployContract( name: "ReentrancyGuard", args: any[], @@ -197,6 +433,16 @@ declare module "hardhat/types/runtime" { args: any[], signerOrOptions?: ethers.Signer | DeployContractOptions ): Promise; + deployContract( + name: "MockERC20Permit", + args: any[], + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; + deployContract( + name: "MockRelayerDestination", + args: any[], + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; deployContract( name: "TokenRelayer", args: any[], @@ -204,14 +450,24 @@ declare module "hardhat/types/runtime" { ): Promise; // default types - getContractFactory(name: string, signerOrOptions?: ethers.Signer | FactoryOptions): Promise; - getContractFactory(abi: any[], bytecode: ethers.BytesLike, signer?: ethers.Signer): Promise; + getContractFactory( + name: string, + signerOrOptions?: ethers.Signer | FactoryOptions + ): Promise; + getContractFactory( + abi: any[], + bytecode: ethers.BytesLike, + signer?: ethers.Signer + ): Promise; getContractAt( nameOrAbi: string | any[], address: string | ethers.Addressable, signer?: ethers.Signer ): Promise; - deployContract(name: string, signerOrOptions?: ethers.Signer | DeployContractOptions): Promise; + deployContract( + name: string, + signerOrOptions?: ethers.Signer | DeployContractOptions + ): Promise; deployContract( name: string, args: any[], diff --git a/contracts/relayer/typechain-types/index.ts b/contracts/relayer/typechain-types/index.ts index 277beba85..1ddd28198 100644 --- a/contracts/relayer/typechain-types/index.ts +++ b/contracts/relayer/typechain-types/index.ts @@ -2,38 +2,55 @@ /* tslint:disable */ /* eslint-disable */ import type * as openzeppelin from "./@openzeppelin"; -import type * as contracts from "./contracts"; - export type { openzeppelin }; +import type * as contracts from "./contracts"; export type { contracts }; -export type { Ownable } from "./@openzeppelin/contracts/access/Ownable"; -export type { IERC1363 } from "./@openzeppelin/contracts/interfaces/IERC1363"; -export type { IERC5267 } from "./@openzeppelin/contracts/interfaces/IERC5267"; -export type { IERC20Permit } from "./@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit"; -export type { IERC20 } from "./@openzeppelin/contracts/token/ERC20/IERC20"; -export type { SafeERC20 } from "./@openzeppelin/contracts/token/ERC20/utils/SafeERC20"; -export type { ECDSA } from "./@openzeppelin/contracts/utils/cryptography/ECDSA"; -export type { EIP712 } from "./@openzeppelin/contracts/utils/cryptography/EIP712"; -export type { MessageHashUtils } from "./@openzeppelin/contracts/utils/cryptography/MessageHashUtils"; -export type { IERC165 } from "./@openzeppelin/contracts/utils/introspection/IERC165"; -export type { SafeCast } from "./@openzeppelin/contracts/utils/math/SafeCast"; -export type { ReentrancyGuard } from "./@openzeppelin/contracts/utils/ReentrancyGuard"; -export type { ShortStrings } from "./@openzeppelin/contracts/utils/ShortStrings"; -export type { Strings } from "./@openzeppelin/contracts/utils/Strings"; -export type { TokenRelayer } from "./contracts/TokenRelayer"; export * as factories from "./factories"; +export type { Ownable } from "./@openzeppelin/contracts/access/Ownable"; export { Ownable__factory } from "./factories/@openzeppelin/contracts/access/Ownable__factory"; +export type { IERC1155Errors } from "./@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors"; +export { IERC1155Errors__factory } from "./factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC1155Errors__factory"; +export type { IERC20Errors } from "./@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors"; +export { IERC20Errors__factory } from "./factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC20Errors__factory"; +export type { IERC721Errors } from "./@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors"; +export { IERC721Errors__factory } from "./factories/@openzeppelin/contracts/interfaces/draft-IERC6093.sol/IERC721Errors__factory"; +export type { IERC1363 } from "./@openzeppelin/contracts/interfaces/IERC1363"; export { IERC1363__factory } from "./factories/@openzeppelin/contracts/interfaces/IERC1363__factory"; +export type { IERC5267 } from "./@openzeppelin/contracts/interfaces/IERC5267"; export { IERC5267__factory } from "./factories/@openzeppelin/contracts/interfaces/IERC5267__factory"; +export type { ERC20 } from "./@openzeppelin/contracts/token/ERC20/ERC20"; +export { ERC20__factory } from "./factories/@openzeppelin/contracts/token/ERC20/ERC20__factory"; +export type { ERC20Permit } from "./@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit"; +export { ERC20Permit__factory } from "./factories/@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit__factory"; +export type { IERC20Metadata } from "./@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata"; +export { IERC20Metadata__factory } from "./factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata__factory"; +export type { IERC20Permit } from "./@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit"; export { IERC20Permit__factory } from "./factories/@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit__factory"; +export type { IERC20 } from "./@openzeppelin/contracts/token/ERC20/IERC20"; export { IERC20__factory } from "./factories/@openzeppelin/contracts/token/ERC20/IERC20__factory"; +export type { SafeERC20 } from "./@openzeppelin/contracts/token/ERC20/utils/SafeERC20"; export { SafeERC20__factory } from "./factories/@openzeppelin/contracts/token/ERC20/utils/SafeERC20__factory"; +export type { ECDSA } from "./@openzeppelin/contracts/utils/cryptography/ECDSA"; export { ECDSA__factory } from "./factories/@openzeppelin/contracts/utils/cryptography/ECDSA__factory"; +export type { EIP712 } from "./@openzeppelin/contracts/utils/cryptography/EIP712"; export { EIP712__factory } from "./factories/@openzeppelin/contracts/utils/cryptography/EIP712__factory"; +export type { MessageHashUtils } from "./@openzeppelin/contracts/utils/cryptography/MessageHashUtils"; export { MessageHashUtils__factory } from "./factories/@openzeppelin/contracts/utils/cryptography/MessageHashUtils__factory"; +export type { IERC165 } from "./@openzeppelin/contracts/utils/introspection/IERC165"; export { IERC165__factory } from "./factories/@openzeppelin/contracts/utils/introspection/IERC165__factory"; +export type { SafeCast } from "./@openzeppelin/contracts/utils/math/SafeCast"; export { SafeCast__factory } from "./factories/@openzeppelin/contracts/utils/math/SafeCast__factory"; +export type { Nonces } from "./@openzeppelin/contracts/utils/Nonces"; +export { Nonces__factory } from "./factories/@openzeppelin/contracts/utils/Nonces__factory"; +export type { ReentrancyGuard } from "./@openzeppelin/contracts/utils/ReentrancyGuard"; export { ReentrancyGuard__factory } from "./factories/@openzeppelin/contracts/utils/ReentrancyGuard__factory"; +export type { ShortStrings } from "./@openzeppelin/contracts/utils/ShortStrings"; export { ShortStrings__factory } from "./factories/@openzeppelin/contracts/utils/ShortStrings__factory"; +export type { Strings } from "./@openzeppelin/contracts/utils/Strings"; export { Strings__factory } from "./factories/@openzeppelin/contracts/utils/Strings__factory"; +export type { MockERC20Permit } from "./contracts/mocks/MockERC20Permit"; +export { MockERC20Permit__factory } from "./factories/contracts/mocks/MockERC20Permit__factory"; +export type { MockRelayerDestination } from "./contracts/mocks/MockRelayerDestination"; +export { MockRelayerDestination__factory } from "./factories/contracts/mocks/MockRelayerDestination__factory"; +export type { TokenRelayer } from "./contracts/TokenRelayer"; export { TokenRelayer__factory } from "./factories/contracts/TokenRelayer__factory"; diff --git a/contracts/relayer/x-ray/architecture.svg b/contracts/relayer/x-ray/architecture.svg deleted file mode 100644 index 41c665473..000000000 --- a/contracts/relayer/x-ray/architecture.svg +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - TokenRelayer Architecture - - - Actor - - - Protocol - - - External - - Core Protocol - - External Dependencies - - - - - - - - - User - - - - Relayer Bot - - - - Owner - - - - - TokenRelayer - Relay + Forward - - - - - ERC20 Token - Permit Token - - - - - Destination Contract - Immutable Target - - - signs permit + payload - - submits execute() - - withdraw tokens/ETH - - permit + transferFrom - - forward call + ETH - - approve + revoke - \ No newline at end of file diff --git a/contracts/relayer/x-ray/entry-points.md b/contracts/relayer/x-ray/entry-points.md deleted file mode 100644 index cffdf22db..000000000 --- a/contracts/relayer/x-ray/entry-points.md +++ /dev/null @@ -1,59 +0,0 @@ -# Entry Point Map - -> Vortex TokenRelayer | 4 entry points | 1 permissionless | 0 role-gated | 2 admin-only - ---- - -## Protocol Flow Paths - -### Setup (Owner) - -`constructor(_destinationContract)` → contract deployed with immutable destination and owner = deployer - -### User Flow - -`[constructor above]` → User signs permit + payload off-chain → RelayerBot calls `execute(params)` - ├─→ tokens transferred from User → Relayer → Destination - └─→ arbitrary call forwarded to Destination - -### Recovery (Owner) - -`[any time]` → `withdrawToken(token, amount)` ← owner recovers stuck ERC-20 -`[any time]` → `withdrawETH(amount)` ← owner recovers stuck ETH - ---- - -## Permissionless - -### `TokenRelayer.execute()` - -| Aspect | Detail | -|--------|--------| -| Visibility | external payable, nonReentrant | -| Caller | Relayer Bot (anyone can call, but must provide valid user signatures) | -| Parameters | `params.token` (user-signed), `params.owner` (user-signed), `params.value` (user-signed), `params.deadline` (user-signed), `params.permitV/R/S` (user-signed), `params.payloadData` (user-signed), `params.payloadValue` (user-signed), `params.payloadNonce` (user-signed), `params.payloadDeadline` (user-signed), `params.payloadV/R/S` (user-signed) | -| Call chain | `→ ECDSA.recover()` → `_executePermitAndTransfer()` → `IERC20Permit.permit()` → `IERC20.safeTransferFrom(owner → relayer)` → `IERC20.forceApprove(destination, value)` → `_forwardCall()` → `destinationContract.call{value}(data)` → `IERC20.forceApprove(destination, 0)` | -| State modified | `usedPayloadNonces[owner][nonce]` set to `true` | -| Value flow | in (ERC-20 tokens from user to relayer), out (tokens approved to destination + ETH forwarded via call) | -| Reentrancy guard | yes (`nonReentrant`) | - -### `TokenRelayer.receive()` - -| Aspect | Detail | -|--------|--------| -| Visibility | external payable | -| Caller | Anyone (destination contract refunds, direct ETH sends) | -| Parameters | none | -| Call chain | (no-op — simply accepts ETH) | -| State modified | none (only ETH balance changes) | -| Value flow | in (ETH received) | -| Reentrancy guard | no | - ---- - -## Admin-Only - -| Contract | Function | Parameters | State Modified | -|----------|----------|------------|----------------| -| TokenRelayer | `withdrawToken(token, amount)` | `token` (owner-provided), `amount` (owner-provided) | none (token balance decreases) | -| TokenRelayer | `withdrawETH(amount)` | `amount` (owner-provided) | none (ETH balance decreases) | diff --git a/contracts/relayer/x-ray/x-ray.md b/contracts/relayer/x-ray/x-ray.md deleted file mode 100644 index bc831b775..000000000 --- a/contracts/relayer/x-ray/x-ray.md +++ /dev/null @@ -1,259 +0,0 @@ -# X-Ray Report - -> Vortex TokenRelayer | 138 nSLOC | 6d0c246ec (`create-spec-and-security-audit`) | Hardhat | 07/04/26 - ---- - -## 1. Protocol Overview - -**What it does:** A meta-transaction relayer that accepts ERC-20 permit signatures and forwards arbitrary calls to a fixed destination contract. - -- **Users**: Token holders who sign off-chain permit + payload signatures; a relayer bot submits the transaction on-chain -- **Core flow**: User signs permit (ERC-2612) + EIP-712 payload → relayer bot calls `execute()` → contract permits, transfers tokens in, approves destination, forwards call, revokes approval -- **Key mechanism**: EIP-712 signed payload authorization with nonce-based replay protection and permit-based gasless token approval -- **Token model**: Handles arbitrary ERC-20 tokens with ERC-2612 permit support; no protocol-native token -- **Admin model**: Single `Ownable` owner — can withdraw tokens and ETH; no timelock, no multisig, no governance - -For a visual overview of the protocol's architecture, see the [architecture diagram](architecture.svg). - -### Contracts in Scope - -| Subsystem | Key Contracts | nSLOC | Role | -|-----------|--------------|------:|------| -| Relayer | TokenRelayer.sol | 138 | Accepts signed permits + payloads, relays token transfers and arbitrary calls to immutable destination | - -### How It Fits Together - -The core trick: Users never submit transactions themselves — they sign two off-chain messages (permit + payload), and a relayer bot submits them on-chain in a single atomic transaction. - -### Execute Flow (Primary) - -``` -RelayerBot.execute(params) -├─ Checks: owner ≠ 0, token ≠ 0, nonce unused, deadline valid -├─ ECDSA.recover(EIP-712 digest) == owner -├─ Verify msg.value == payloadValue -├─ Effect: usedPayloadNonces[owner][nonce] = true -├─ _executePermitAndTransfer() -│ ├─ try: IERC20Permit.permit(owner → relayer) -│ │ └─ catch: require(allowance >= value) ← *front-run resilience* -│ └─ IERC20.safeTransferFrom(owner → relayer) ← *tokens pulled* -├─ IERC20.forceApprove(destination, value) ← *exact approval* -├─ _forwardCall(data, msg.value) → destination.call{value}(data) ← *arbitrary call* -└─ IERC20.forceApprove(destination, 0) ← *revoke approval* -``` - -### Owner Withdrawal - -``` -Owner.withdrawToken(token, amount) -└─ IERC20.safeTransfer(owner, amount) ← *recover stuck tokens* - -Owner.withdrawETH(amount) -└─ owner.call{value: amount}("") ← *recover stuck ETH* -``` - ---- - -## 2. Threat & Trust Model - -### Protocol Threat Profile - -> Protocol classified as: **Bridge/Relayer** with **Meta-transaction** characteristics - -The contract functions as a relayer layer — accepting off-chain signed authorizations and forwarding token + call operations to a fixed destination. It shares bridge-like trust patterns (signature verification, relay mechanics, nonce tracking) combined with meta-transaction gasless execution via ERC-2612 permits. - -### Actors & Adversary Model - -| Actor | Trust Level | Capabilities | -|-------|-------------|-------------| -| Owner | Trusted | Can withdraw any ERC-20 tokens and native ETH from the contract. All operations instant — no timelock, no multisig. Ownership transferable via `Ownable.transferOwnership()` (single-step). | -| Relayer Bot | Bounded (can only submit valid signed payloads) | Submits `execute()` with user-signed permit + payload. Cannot forge signatures, but chooses gas price and timing. | -| User (Token Owner) | Bounded (signs permits and payloads) | Signs off-chain messages authorizing token spend + call forwarding. Nonce prevents replay. | - -**Adversary Ranking** (ordered by threat level): - -1. **Compromised Owner** — Single EOA controls all fund recovery functions with no delay; immediate drain of any tokens or ETH held by the contract. -2. **Signature replay / front-run attacker** — Observes signed permit + payload in mempool; can front-run the permit call (mitigated by try-catch) or attempt payload replay (mitigated by nonces). -3. **Malicious destination contract** — The immutable `destinationContract` receives arbitrary calls with forwarded ETH; if compromised or malicious, it could exploit the approval window or callback during `_forwardCall`. -4. **MEV searcher** — Can sandwich or front-run `execute()` transactions to extract value from the token transfer or forwarded call. - -See [entry-points.md](entry-points.md) for the full permissionless entry point map. - -### Trust Boundaries - -- **User → Relayer Bot**: User trusts the relayer bot to submit their signed messages faithfully and in a timely manner. The bot cannot modify signed data but controls submission timing and gas. No on-chain enforcement of submission obligation. -- **Relayer Contract → Destination Contract**: The relayer grants exact-amount approval then forwards arbitrary calldata. The destination is immutable (set at construction), but the forwarded call is fully user-defined. If the destination contract has exploitable functions, the relayer's approval window (between `forceApprove` and revoke) is the attack surface. -- **Owner → Contract Funds**: Owner has instant, unrestricted withdrawal of all assets. No timelock or multisig protects this boundary. A compromised owner key means total loss of contract-held funds. - -### Key Attack Surfaces - -- **Owner key compromise** — Owner can instantly drain all ERC-20 tokens via `withdrawToken()` and all ETH via `withdrawETH()`. No timelock, no multisig, no delay. Single-step ownership transfer via `Ownable.transferOwnership()` (no acceptance step required). This is the highest-impact attack surface for any funds held by the contract. - -- **Approval window during execute()** — Between `forceApprove(destination, value)` and `forceApprove(destination, 0)`, the destination contract has an active token approval. The `_forwardCall` makes a low-level `.call()` to the destination with arbitrary data during this window. If the destination contract can be made to call back into the token (or if the token has callbacks like ERC-777), the approval could be exploited. The `nonReentrant` guard on `execute()` mitigates re-entry into the relayer but does not prevent the destination from using the approval directly. - -- **Forwarded call data integrity** — The EIP-712 payload signature includes `destination` hardcoded to `destinationContract` in `_computeDigest`, `token`, `value`, `data`, `ethValue`, `nonce`, and `deadline`. The user signs over these fields, so the relayer bot cannot alter them. However, the `data` field is opaque — the contract does not validate what function is being called on the destination. Security depends entirely on the user understanding what they're signing. - -- **Permit front-running resilience** — The try-catch around `permit()` handles the case where an attacker front-runs the permit call. However, the fallback checks `allowance(owner, relayer) >= value` — if a previous permit set a higher allowance that was partially consumed, the check could pass with a stale allowance from a different context. The `safeTransferFrom` after the check ensures tokens are actually available. - -### Protocol-Type Concerns - -**As a Bridge/Relayer:** -- The `_forwardCall` uses a raw `.call()` without return data validation. Success is checked but return data is silently discarded (`(bool success, ) = ...`). If the destination returns meaningful error data, it's lost — `TokenRelayer:186-188`. -- Nonce management uses a per-user, per-nonce boolean mapping. There is no sequential nonce enforcement — nonces can be used in any order. This is by design (flexibility) but means a user cannot cancel a pending payload by incrementing their nonce; they must wait for expiry — `TokenRelayer:35`. - -**As a Meta-transaction system:** -- The EIP-712 domain is `("TokenRelayer", "1")` with automatic chain ID handling via OZ's `EIP712`. On a chain fork, the domain separator updates correctly, preventing cross-chain replay — `TokenRelayer:68`. -- The `payloadDeadline` and `deadline` (permit) are separate parameters. A user could sign a permit with a long deadline but a short payload deadline, leaving a dangling permit approval if the payload expires — `TokenRelayer:42-49`. - -### Temporal Risk Profile - -**Deployment & Initialization:** -- The `destinationContract` is set immutably in the constructor with a zero-address check. No initialization front-running risk — `TokenRelayer:66-72`. However, ownership is set to `msg.sender` (deployer). If ownership transfer to a multisig is intended but delayed, the single EOA controls all withdrawal functions in the interim. - -### Composability & Dependency Risks - -**Dependency Risk Map:** - -> **ERC-20 Token (arbitrary)** — via `TokenRelayer:execute()` -> - Assumes: Standard ERC-20 with ERC-2612 permit; `safeTransferFrom` handles non-standard return values -> - Validates: Uses SafeERC20 for transfers, try-catch for permit -> - Mutability: Depends on token — many ERC-20s (USDC, USDT) are upgradeable proxies -> - On failure: Permit failure falls back to allowance check; transfer failure reverts - -> **Destination Contract (immutable address)** — via `TokenRelayer:_forwardCall()` -> - Assumes: Accepts arbitrary calldata, returns success/failure -> - Validates: Checks bool success only; return data discarded -> - Mutability: Address is immutable, but if destination is a proxy, implementation can change -> - On failure: Reverts entire execute() transaction - -**Token Assumptions** (unvalidated): -- Fee-on-transfer tokens: `safeTransferFrom` transfers `value` but actual received amount may be less — the subsequent `forceApprove(destination, value)` would approve more than the contract holds, which is benign (destination can only take what's there), but accounting is imprecise -- Rebasing tokens: Balance could change between `safeTransferFrom` and `_forwardCall` — no internal accounting to detect this -- ERC-777 tokens: `tokensReceived` callback during `safeTransferFrom` could trigger reentrancy; `nonReentrant` on `execute()` mitigates this -- Blocklist tokens (USDC, USDT): If the relayer contract address is blocklisted, all operations involving that token will revert permanently - ---- - -## 3. Invariants - -### Stated Invariants - -- "Nonce used" — each `(owner, nonce)` pair can only be consumed once: `require(!usedPayloadNonces[owner][nonce], "Nonce used")` — `TokenRelayer:86` -- "Payload expired" — payload must be executed before deadline: `require(block.timestamp <= params.payloadDeadline, "Payload expired")` — `TokenRelayer:87` -- "Invalid sig" — ECDSA-recovered signer must match declared owner: `require(ECDSA.recover(digest, ...) == owner, "Invalid sig")` — `TokenRelayer:100` -- "Incorrect ETH value provided" — msg.value must exactly match signed payloadValue: `require(msg.value == params.payloadValue, "Incorrect ETH value provided")` — `TokenRelayer:102` - -### Inferred Invariants - -- **Zero residual approval**: After every successful `execute()`, the destination contract's allowance from the relayer is 0. Derived from `TokenRelayer:121,127` (`forceApprove(value)` then `forceApprove(0)`). If violated: destination retains ability to pull tokens from the relayer. -- **CEI ordering**: State changes (`usedPayloadNonces` update) happen before all external interactions. Derived from `TokenRelayer:104-106`. If violated: replay within reentrancy. -- **Permit-or-allowance**: Token transfer proceeds if either permit succeeds OR pre-existing allowance ≥ value. Derived from `TokenRelayer:172-180`. If violated: legitimate transactions fail when permit is front-run. - ---- - -## 4. Documentation Quality - -| Aspect | Status | Notes | -|--------|--------|-------| -| README | Present | `contracts/README.md` — workspace-level only | -| NatSpec | ~5 annotations | Constructor, `withdrawToken`, `withdrawETH`, `_executePermitAndTransfer` have NatSpec; `execute()` lacks `@param`/`@return` documentation | -| Spec/Whitepaper | Missing | No formal specification document | -| Inline Comments | Adequate | Key decisions documented (CEI pattern, front-run resilience, approval revocation). References to audit findings (H-2, L-1, etc.) | -| Security Audit | Present | `SECURITY_AUDIT.md` — AI-generated review with 12 findings; critical findings (C-1, C-2) have been addressed in current code | - ---- - -## 5. Test Analysis - -| Metric | Value | Source | -|--------|-------|--------| -| Test files | 2 | File scan (integration scripts, not unit test suites) | -| Test functions | 0 | No `it()`/`describe()`/`test()` blocks — scripts are standalone execution flows | -| Line coverage | 0% | Coverage tool ran; tests failed — missing env vars (SECRET1, SECRET2, RELAYER_SECRET) | -| Branch coverage | 0% | Same — env var dependency prevents execution | - -### Test Depth - -| Category | Count | Contracts Covered | -|----------|-------|-------------------| -| Unit | 0 | none | -| Stateless Fuzz | 0 | none | -| Stateful Fuzz (Foundry) | 0 | none | -| Stateful Fuzz (Echidna) | 0 | none | -| Formal Verification (Certora) | 0 | none | -| Formal Verification (Halmos) | 0 | none | - -### Gaps - -- **No unit tests**: The 2 test files (`relayer-execution.ts`, `relayer-execution-squid.ts`) are integration/execution scripts requiring live env vars (private keys, RPC), not repeatable unit tests. No Hardhat/Mocha test framework usage detected. -- **No fuzz testing**: Signature verification, nonce handling, and permit edge cases (front-running, malleability) are prime candidates for stateless fuzzing. -- **No formal verification**: The EIP-712 digest construction and ECDSA recovery path would benefit from formal verification to ensure no signature bypass exists. -- **No invariant testing**: The "zero residual approval" and "nonce uniqueness" invariants are critical and untested. - ---- - -## 6. Developer & Git History - -> Repo shape: normal_dev — Normal development history with 4 source-touching commits over 1 month. Analyzed branch: `create-spec-and-security-audit` at `6d0c246ec`. - -### Contributors - -| Author | Commits | Source Lines (+/-) | % of Source Changes | -|--------|--------:|--------------------|--------------------:| -| Marcel Ebert | 4 | +266 / -48 | 100% | - -### Review & Process Signals - -| Signal | Value | Assessment | -|--------|-------|------------| -| Unique contributors (repo-wide) | 12 | Larger team on the monorepo | -| Unique contributors (contracts) | 1 | Single developer for all contract source | -| Merge commits | 745 of 5323 (14%) | Formal review process exists at repo level | -| Repo age | 2023-10-02 → 2026-04-07 | 2.5 years | -| Recent source activity (30d) | 0 source commits | Quiet — no source changes in last 30 days | -| Test co-change rate | 75% | 3 of 4 source commits also modified test files | - -### File Hotspots - -| File | Modifications | Note | -|------|-------------:|------| -| contracts/TokenRelayer.sol | 4 | Only source file — all 4 commits touch it | - -### Security-Relevant Commits - -| SHA | Date | Subject | Score | Key Signal | -|-----|------|---------|------:|------------| -| e63d38bce | 2026-03-04 | Upgrade smart contract with security findings | 14 | Explicit security language, changes signature/auth handling, net code removal | -| a8ff3f2c8 | 2026-03-04 | Refactor directory structure | 11 | Adds runtime guards (+23), tightens access control (+21), changes token transfer logic | -| 125f601d5 | 2026-03-04 | Adjust issues with TokenRelayer.sol | 10 | Rewrites runtime guards, changes signature/auth handling, changes accounting logic | -| 83973b1fa | 2026-03-04 | Adjust comments | 7 | Rewrites access control, changes signature handling | - -All 4 source commits occurred on the same day (2026-03-04), indicating a concentrated security hardening pass in response to the AI security audit. - -### Security Observations - -- **Single-developer contract code**: 100% of contract source written by one author. No evidence of peer review specifically on the Solidity code, though the broader repo has merge commit history. -- **Security hardening batch**: All 4 source commits on a single day address findings from `SECURITY_AUDIT.md` — C-1 (ReentrancyGuard), C-2 (ECDSA.recover), H-1 (exact approvals), H-2 (destination in digest), M-1 (ETH recovery), M-2 (permit try-catch), L-1 (remove executedCalls), L-2 (events), I-1 (Ownable), I-3 (EIP712). This is a positive signal — findings were systematically addressed. -- **No test updates with substance**: While test files were co-modified in 3/4 commits, the test files remain execution scripts, not unit tests. The test co-change rate (75%) overstates actual test coverage improvement. -- **No recent activity**: Zero source commits in the last 30 days. The contract appears stable but may also indicate paused development before deployment. - -### Cross-Reference Synthesis - -- TokenRelayer.sol is the sole source file, the sole hotspot (4 modifications), and the subject of all 4 fix-scored commits — all review effort should concentrate here. -- The security hardening commits (score 10-14) addressed the critical and high findings from the AI audit. The current code shows ReentrancyGuard, ECDSA.recover, exact approval + revoke, and destination hardcoded in digest — confirming remediation of C-1, C-2, H-1, H-2. -- Despite the fix commits having test co-changes, no actual unit tests exist — the "zero coverage" finding from Section 5 is confirmed by git history showing only script modifications, not test suite additions. -- Single-developer risk (Section 6) amplifies the owner key compromise surface (Section 2) — both the code authorship and the admin key likely trace to the same individual. - ---- - -## X-Ray Verdict - -**FRAGILE** — Single 138-nSLOC contract with addressed audit findings but zero automated tests and no operational safeguards on admin functions. - -**Structural facts:** -1. 138 nSLOC in 1 contract — minimal attack surface by size, but every line is security-critical (signature verification, token handling, arbitrary call forwarding). -2. 0 unit tests, 0 fuzz tests, 0 formal verification — the 2 "test" files are integration scripts requiring live secrets, providing zero repeatable coverage. -3. Single developer wrote 100% of contract code; all 4 source commits on one day as a security hardening batch. -4. Owner has instant, unrestricted withdrawal of all contract-held tokens and ETH — no timelock, no multisig, single-step ownership transfer. -5. Prior AI security audit findings (12 total: 2 critical, 2 high) have been addressed in the current code — ReentrancyGuard, ECDSA.recover, exact approval/revoke, EIP712, Ownable all integrated. diff --git a/docs/Alfredpay.md b/docs/Alfredpay.md deleted file mode 100644 index c117eff5e..000000000 --- a/docs/Alfredpay.md +++ /dev/null @@ -1,301 +0,0 @@ -# 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 **USD** (USA), **MXN** (Mexico), **COP** (Colombia), and **ARS** (Argentina). These currencies route through the same backend transaction phases; KYC/KYB onboarding differs per country. - ---- - -## Supported Currencies and Countries - -| FiatToken | Country | KYC Method | -|---|---|---| -| 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 these fiat tokens into the Alfredpay path. - ---- - -## Architecture Overview - -``` -Frontend KYC (XState machine) - ↓ KYC status = Success -Quote & Transaction Building - ↓ user confirms ramp + signs all presigned txs -processAlfredpayOnrampStart (ramp.service.ts) - POST /penny/onramp { depositAddress: evmEphemeralAddress, quoteId, ... } - ← fiatPaymentInstructions (bank account / CLABE shown to user) - ↓ user does manual bank transfer to those instructions - ↓ Alfredpay receives fiat, mints USDC, sends on-chain to depositAddress -alfredpayOnrampMint phase (backend polls ephemeral balance) - ↓ USDC lands on ephemeral Polygon address -fundEphemeral (gas top-up) - ↓ -squidRouterApprove + squidRouterSwap (or direct destinationTransfer if output = Polygon USDC) - ↓ -finalSettlementSubsidy / moonbeamToPend (destination-dependent) -``` - ---- - -## Key Files - -| Layer | File | -|---|---| -| Machine | `apps/frontend/src/machines/alfredpayKyc.machine.ts` | -| Machine entry | `apps/frontend/src/machines/kyc.states.ts` | -| Root screen orchestrator | `apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx` | -| Frontend API service | `apps/frontend/src/services/api/alfredpay.service.ts` | -| Backend routes | `apps/api/src/api/routes/v1/alfredpay.route.ts` | -| Backend controller | `apps/api/src/api/controllers/alfredpay.controller.ts` | -| Alfredpay HTTP client | `packages/shared/src/services/alfredpay/alfredpayApiService.ts` | -| Shared types | `packages/shared/src/services/alfredpay/types.ts` | -| Onramp phase handler | `apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts` | -| Onramp tx builder | `apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts` | -| Onramp quote strategy | `apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts` | -| DB model | `apps/api/src/models/alfredPayCustomer.model.ts` | - ---- - -## Phase 0 — Frontend KYC - -### Entry point - -`kyc.states.ts` dispatches to `alfredpayKycMachine` when `isAlfredpayToken(fiatToken)` is true. The machine receives `{ country, userId, walletAddress }` as input. - -### Machine states (all countries) - -| State | Description | -|---|---| -| `CheckingStatus` | GET `/alfredpayStatus?country=XX` — routes based on existing status | -| `CustomerDefinition` | Toggle individual / business; confirm to proceed | -| `CreatingCustomer` | POST `/createIndividualCustomer` or `/createBusinessCustomer` | -| `PollingStatus` | Polls `/getKycStatus` every 5 s, 20-min timeout; `Success` → `VerificationDone` | -| `VerificationDone` | User confirms → `Done` (final) | -| `FailureKyc` | `USER_RETRY` → `Retrying`; `USER_CANCEL` → `Done` | -| `Failure` | Technical error; `RETRY_PROCESS` → `CheckingStatus` | -| `Done` | Final state — machine exits, parent transitions to `KycComplete` | - -### USD iFrame flow - -``` -CheckingStatus → CustomerDefinition → CreatingCustomer - → GettingKycLink (GET /getKycRedirectLink) - → LinkReady (user clicks "Open KYC Link") - → OpeningLink (POST /kycRedirectOpened) - → FillingKyc (parallel: polls status; user completes iFrame) - → FinishingFilling (POST /kycRedirectFinished) - → PollingStatus → VerificationDone → Done -``` - -### MXN / COP API form flow - -``` -CheckingStatus → CustomerDefinition → CreatingCustomer - → FillingKycForm (MxnKycFormScreen or ColKycFormScreen) - → SubmittingKycInfo (POST /submitKycInformation) - → UploadingDocuments (MxnDocumentUploadScreen — shared by MX and CO) - → SubmittingFiles (POST /submitKycFile × 2: front + back) - → SendingSubmission (POST /sendKycSubmission) - → PollingStatus → VerificationDone → Done -``` - -### KYB (business) flow — MXN / COP - -``` -CustomerDefinition (business toggle) → CreatingCustomer (POST /createBusinessCustomer) - → FillingKybForm (KybFormScreen) - → SubmittingKybInfo (POST /submitKybInformation) - └ returns { submissionId, relatedPersons: [{ id }] } - → UploadingKybBusinessDocs (KybBusinessDocsScreen — 3 files) - → SubmittingKybBusinessFiles (POST /submitKybFile × 3) - → UploadingKybPersonDocs (KybPersonDocsScreen — paginates per person) - → SubmittingKybPersonFiles (POST /submitKybRelatedPersonFile × 2 per person) - → SendingKybSubmission (PUT /sendKybSubmission) - → PollingStatus → VerificationDone → Done -``` - -### KYB (business) — USD - -Same `CustomerDefinition` + `CreatingCustomer`, then routes to `GettingKycLink` (calls `getKybRedirectLink`) and follows the iFrame flow above. - ---- - -## Phase 1 — Quote & Transaction Building - -### Quote strategy: `OnrampAlfredpayToEvmStrategy` - -Engines run in order: - -1. **Initialize** — Calls Alfredpay `POST /penny/quotes` with `chain=MATIC`, `toCurrency=USDC`, `paymentMethodType=BANK`. Stores quote in `ctx.alfredpayMint` (amounts, fees, quoteId, expiration). -2. **Fee** — Returns Alfredpay fee in fiat currency; network fee = 0. -3. **SquidRouter** — Bridge quote: Polygon USDC → destination EVM. Skipped entirely if destination is Polygon USDC. -4. **Finalize** — Seals the quote ticket. - -### Transaction building: `prepareAlfredpayToEvmOnrampTransactions` - -Pre-condition: customer DB record must have `AlfredPayStatus.Success` — hard failure otherwise. - -Built transactions: -- **Polygon USDC destination (direct):** single `destinationTransfer` tx only. -- **All other EVM destinations:** `squidRouterApprove` + `squidRouterSwap` + `destinationTransfer` + fallback swap. - -State metadata written: `alfredpayUserId`, `evmEphemeralAddress`, `squidRouterQuoteId`, `squidRouterReceiverId`, `squidRouterReceiverHash`. - ---- - -## Phase 1b — Onramp Order Creation (`processAlfredpayOnrampStart`) - -**File:** `apps/api/src/api/services/ramp/ramp.service.ts:1116` - -Triggered once all presigned transactions are signed. Runs before the first phase handler. - -1. Calls `POST /penny/onramp` with `{ depositAddress: evmEphemeralAddress, quoteId, customerId: alfredpayUserId, amount, chain: MATIC, ... }` -2. Alfredpay responds with `{ transaction: { transactionId }, fiatPaymentInstructions }` -3. Both are stored in `rampState.state` (`alfredpayTransactionId` + `fiatPaymentInstructions`) -4. `fiatPaymentInstructions` (bank account number / CLABE / etc.) are surfaced to the user in the frontend -5. **User manually sends fiat** via bank transfer to those instructions — this step happens entirely outside Vortex -6. Alfredpay receives the fiat, mints USDC on Polygon, and sends it to `depositAddress` (the ephemeral address) - -This is the only step that communicates the ephemeral address to Alfredpay and creates the on-chain delivery instruction. - ---- - -## Phase 2 — `alfredpayOnrampMint` (Backend Phase Handler) - -**File:** `apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts` - -- **Timeout:** 5 minutes -- **Poll interval:** 5 seconds -- Runs two concurrent promises via `Promise.race()`: - 1. `checkEvmBalancePeriodically` — polls USDC balance at ephemeral Polygon address; resolves when balance reaches expected `outputAmountRaw`. - 2. `pollAlfredpayOnrampStatus` — polls Alfredpay `GET /penny/onramp/:transactionId`; only rejects (never resolves) on `FAILED` status; records `alfredpayOnrampMintTxHash` on `ON_CHAIN_COMPLETED`. - -**Ground truth is the on-chain balance, not Alfredpay's status.** This prevents race conditions where Alfredpay reports completion before the USDC is confirmably settled. - -On resolve → transitions to `fundEphemeral`. -On FAILED → transitions to `failed`. -On timeout → throws recoverable error. - ---- - -## Phase 3 — `fundEphemeral` - -The ephemeral Polygon account is topped up with native MATIC for gas. Pendulum ephemeral funding is **skipped** for Alfredpay onramps (no Pendulum hop required). - ---- - -## Phase 4 — Bridge / Transfer - -| Output destination | Phases | -|---|---| -| Polygon USDC | `destinationTransfer` only | -| Other EVM chain | `squidRouterApprove` → `squidRouterSwap` → `destinationTransfer` | -| AssetHub (Polkadot) | `squidRouterApprove` → `squidRouterSwap` → `moonbeamToPend` | - ---- - -## Colombia-Specific Details - -### Frontend: `ColKycFormScreen.tsx` - -Colombia-specific fields vs. Mexico: - -| Field | Colombia | Mexico | -|---|---|---| -| Document type | `typeDocumentCol` (`CC` or `CE`) | `typeDocument` (`INE`, etc.) | -| DNI format | CC: 10 digits; CE: 6–11 digits | varies | -| Phone number | required | not collected | - -The submit callback type reuses `MxnKycFormData` (`Omit`). This works at runtime because `typeDocumentCol` and `phoneNumber` both exist on `SubmitKycInformationRequest`. - -### Machine: country gate - -```typescript -// alfredpayKyc.machine.ts ~L316 -guard: ({ context }) => context.country === "MX" || context.country === "CO" -target: "FillingKycForm" -``` - -Both countries use the API form path. `AlfredpayKycFlow.tsx` distinguishes them for rendering: -```typescript -if (stateValue === "FillingKycForm" && isCo) { - return ; -} -``` - -### Backend: selective field stripping - -`alfredpayApiService.ts` deletes null fields before POST so each country sends only its own fields: -```typescript -if (!data.typeDocument) delete kycSubmission.typeDocument; -if (!data.typeDocumentCol) delete kycSubmission.typeDocumentCol; -if (!data.phoneNumber) delete kycSubmission.phoneNumber; -``` - -### Bank network - -`AlfredpayFiatAccountType.COELSA` — Colombia's interbank transfer network. - ---- - -## Backend API Endpoints - -All routes mounted under `/alfredpay/`, protected by `requireAuth` + `validateResultCountry`. - -| Method | Path | Purpose | -|---|---|---| -| GET | `/alfredpayStatus` | Internal + live KYC status | -| POST | `/createIndividualCustomer` | Create individual customer | -| POST | `/createBusinessCustomer` | Create business customer | -| GET | `/getKycRedirectLink` | iFrame URL (USD individual) | -| GET | `/getKybRedirectLink` | iFrame URL (USD business) | -| POST | `/kycRedirectOpened` | Set status → `LINK_OPENED` | -| POST | `/kycRedirectFinished` | Set status → `USER_COMPLETED` | -| GET | `/getKycStatus` | Poll + sync status from Alfredpay | -| POST | `/retryKyc` | Reset failed KYC (form reset for MX/CO; new link for USD/KYB) | -| POST | `/submitKycInformation` | MX/CO individual form data | -| POST | `/submitKycFile` | ID front/back upload (multer, 5 MB limit) | -| POST | `/sendKycSubmission` | Finalize MX/CO KYC | -| POST | `/submitKybInformation` | Business info form (updates a `PENDING`/`CREATED` submission in place via Alfredpay's `PUT …/customers/kyb` instead of POSTing a new one) | -| POST | `/submitKybFile` | Business document upload | -| POST | `/submitKybRelatedPersonFile` | Related-person ID upload | -| PUT | `/sendKybSubmission` | Finalize KYB (PUT, not POST) | -| POST | `/fiatAccounts` | Register bank account | -| GET | `/fiatAccounts` | List registered bank accounts | -| DELETE | `/fiatAccounts/:fiatAccountId` | Remove bank account | - ---- - -## Database: `alfredpay_customers` - -| Column | Type | Notes | -|---|---|---| -| `id` | UUID PK | | -| `user_id` | FK → profiles | | -| `alfred_pay_id` | UUID unique | Alfredpay's own customer ID | -| `country` | ENUM | US / MX / CO | -| `status` | ENUM | CONSULTED / LINK_OPENED / USER_COMPLETED / VERIFYING / FAILED / SUCCESS / UPDATE_REQUIRED | -| `type` | ENUM | INDIVIDUAL / BUSINESS | -| `last_failure_reasons` | string[] | | -| `status_external` | string | Raw status string from Alfredpay | - -Customer must have `status = SUCCESS` before any transaction can be prepared. - ---- - -## Known Gotchas - -1. **`sendKybSubmission` uses PUT, not POST.** All other file/form submissions use POST. This matches Alfredpay's API design for finalizing KYB. - -2. **KYB retry on Alfredpay is a no-op.** `retryKybSubmission` returns `{ message: "ok" }` — Alfredpay has no dedicated KYB retry endpoint. The controller handles retry by fetching a new verification URL. - -2a. **A `PENDING` submission blocks fresh POSTs.** Alfredpay reports a created-but-never-finalized (or invalid-data) submission as `PENDING` — a status outside the CREATED/IN_REVIEW/COMPLETED/FAILED/UPDATE_REQUIRED set the decisive mappers handle. A fresh POST meanwhile fails with `400 {"errorCode":111405,"errorMessage":"Customer KYB already exists"}`. `PENDING` maps to our canonical `pending` (resumable, not rejected), and `submitKybInformation` detects it (or recovers from the 111405 POST error) and calls Alfredpay's `PUT /api/v1/third-party-service/penny/customers/kyb` (`updateKybInformation`) to update the submission in place, returning the existing `submissionId`. Resolution of that id tries `GET …/customers/kyb/{customerId}` first and falls back to `GET …/kyb/details` (the last-submission response can omit `submissionId` in sandbox). The latest submission id is persisted on the account's `kyc_cases.providerCaseId`. Status strings from Alfredpay arrive in inconsistent casing (sandbox KYB reports lowercase `pending`) — all comparisons and `status_external` writes go through `normalizeAlfredpayProviderStatus` (uppercase). - -3. **KYB actors default country to `"MX"`.** If `context.country` is unset inside a KYB actor, it falls back to MX — a silent bug for Colombia KYB if machine context is ever missing. - -4. **Balance check is ground truth, not Alfredpay status.** `pollAlfredpayOnrampStatus` never resolves (only rejects on FAILED). `checkEvmBalancePeriodically` resolves the race. This prevents acting on an Alfredpay status that arrives before the block is finalized. - -5. **All Alfredpay minting happens on Polygon.** `chain=MATIC` is hardcoded in the quote. The SquidRouter bridge to other EVM chains is always a second step. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..38fbfc5d5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,104 @@ +# Project Documentation + +This is the entry point for durable Vortex documentation. The aim is one maintained +home for each kind of information, not a record of every implementation session. + +## Where information belongs + +Only two topics are large enough to earn their own directories: + +| Location | Purpose | Authority | +|---|---|---| +| [`security-spec/`](security-spec/README.md) | Security invariants, trust boundaries, current risks, and audit evidence | Normative for security-sensitive behavior | +| [`api/`](api/README.md) | Partner-facing OpenAPI, generated types, publication scripts, and integration guides | Public API contract and publication source | + +The smaller set of general project documents stays directly in `docs/`: + +| Document | Purpose | +|---|---| +| [`adr-0001-user-gated-ramp-registration.md`](adr-0001-user-gated-ramp-registration.md) | Accepted architectural decision and rationale | +| [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | +| [`operations-api-credential-rollout.md`](operations-api-credential-rollout.md) | Operational runbook for the API credential production rollout | +| [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | +| [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | +| [`proposal-headless-profiles-and-pricing-plans.md`](proposal-headless-profiles-and-pricing-plans.md) | Active proposal for headless profiles, profile-owned credentials, and explicit pricing plans | +| [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | + +The root [`README.md`](../README.md) is human onboarding, [`MAP.md`](../MAP.md) is +repository wayfinding, and `CLAUDE.md` files contain instructions for coding agents. +Those files should link here instead of duplicating project state. + +Repository-specific workflows may live under `.agents/skills/` when they are bounded, +invocable capabilities rather than general project memory. Keep their factual claims +linked to or synchronized with the canonical API and security documentation. + +Code-adjacent `README.md` files are appropriate only when a subsystem has a non-obvious +local contract that a contributor needs while editing it. Examples include the block-flow +engine and token configuration. Public package READMEs remain with their packages. + +## Authority and conflicts + +Use the most specific maintained source: + +1. For security requirements and accepted exceptions, use `security-spec/` and its + [authority rules](security-spec/README.md#document-authority). +2. For partner-visible requests and responses, use the OpenAPI source under `api/`. +3. For implemented behavior, verify the current code, migrations, and tests. Current + architecture and product docs explain that behavior but do not override it. +4. Proposals and historical evidence never override current code, an accepted ADR, or a + normative spec. + +When maintained documents disagree, fix or clearly mark the stale one in the same change. +Do not leave agents to choose between conflicting versions. + +## Rules for creating or changing docs + +Before adding a Markdown file: + +1. Search this index and update an existing canonical document whenever it has the same + audience and lifecycle. +2. Start new general documentation directly under `docs/` and name it + `-.md`. The supported kind prefixes are `architecture`, `product`, + `operations`, `adr`, `incident`, and `proposal`. +3. Create a subdirectory only when a coherent subsystem has multiple maintained artifacts + or its own generation/publishing tooling. +4. Give non-current or non-authoritative files an explicit status. +5. Link to implementation instead of copying file inventories, schemas, or command lists + that are already obvious from the repository. +6. Update links and this index in the same change. + +Do not add: + +- agent memory banks, active-context logs, progress journals, or handoff notes; +- completed implementation plans or refactor summaries; +- a second architecture document for behavior already owned by `security-spec/`; +- an in-repository archive of stale docs. Git history is the archive. + +### Proposals and decisions + +Active proposals use `proposal-.md` and must state their status and the decision +they seek. When accepted, capture the lasting rationale as `adr-NNNN-.md`, update +the current architecture or product document, and remove the proposal. When rejected or +abandoned, remove it; Git history preserves the discussion. + +ADRs contain: status, context, decision, consequences, and links to current +specifications. Amend an ADR only to clarify it; create a later ADR when the decision +changes and mark the earlier one superseded. + +### Temporary evidence + +An active incident investigation may use `incident-YYYY-MM-DD-.md`. Remove it once +the root cause and lasting controls are represented by code, tests, the security spec, or +the risk register; Git history preserves the forensic record. Summarize external research +in the proposal or ADR it informs instead of retaining a standalone vendor report after +the decision no longer needs it. + +## Documentation definition of done + +For a change that affects documented behavior: + +- update the canonical document, not a new summary; +- cross-check `security-spec/` when the change is security-sensitive; +- update public API docs when a partner-visible contract changes; +- verify relative Markdown links; +- remove temporary plans that the change completed or superseded. diff --git a/docs/adr-0001-user-gated-ramp-registration.md b/docs/adr-0001-user-gated-ramp-registration.md new file mode 100644 index 000000000..044d8d744 --- /dev/null +++ b/docs/adr-0001-user-gated-ramp-registration.md @@ -0,0 +1,64 @@ +# ADR 0001: User-Gated Ramp Registration + +Status: accepted. Last reconciled: 2026-07-31. + +## Context + +Every active Vortex corridor settles through a regulated fiat provider. Provider-backed +work must be tied to the customer who completed KYC/KYB, but unauthenticated users should +still be able to preview rates before creating an account. + +Historically, registration could accept provider identity from request data and could be +authenticated only as a partner. That allowed an integration to attempt work for a +provider customer it did not own. + +## Decision + +Quoting and ramp registration have different trust boundaries. + +1. **Quotes remain anonymous-eligible.** Public rate discovery may create a quote before + login. Partner and user credentials can still attach ownership or pricing where + applicable. +2. **Ramp registration requires an effective user.** The API resolves one from a Supabase + bearer token or the `user_id` bound to a validated secret API key. A partner-only key + has no authority to select an arbitrary user. +3. **An anonymous quote may be claimed once by an authenticated user.** A quote already + owned by another user cannot be registered. +4. **Provider identity is derived server-side.** Avenia tax identity, Alfredpay customer, + and equivalent provider records are resolved from the effective user's customer + entity. Request values may narrow or confirm the choice but cannot replace the + ownership check. + +The key axes are independent: + +| Partner attribution | User binding | Effect | +|---|---|---| +| yes | no | Partner pricing and quoting; no ramp registration | +| yes | yes | Partner pricing and ramps for the linked user | +| no | yes | User-scoped ramps with default pricing | +| no | no | Invalid principal | + +## Consequences + +- Partner integrations that register ramps need per-user authentication or a key bound + to exactly one profile. +- Anonymous quote-first funnels continue to work. +- Registration and provider read/write endpoints share the same effective-user ownership + model. +- New corridors must derive their provider customer from the effective user; adding a + body field that selects provider identity is not an acceptable shortcut. + +## Alternatives rejected + +- **Gate only selected corridors.** Every active corridor is provider-backed, and a + global registration invariant is harder to omit accidentally. +- **Trust a body-supplied provider ID after a partial check.** Server-side derivation has + a smaller IDOR surface and one ownership model across UI and SDK callers. + +## Current references + +- [`architecture-identity-model.md`](architecture-identity-model.md) +- [`security-spec/01-auth/api-keys.md`](security-spec/01-auth/api-keys.md) +- [`security-spec/03-ramp-engine/quote-lifecycle.md`](security-spec/03-ramp-engine/quote-lifecycle.md) +- `apps/api/src/api/middlewares/{dualAuth,effectiveUser,ownershipAuth}.ts` +- `apps/api/src/api/services/ramp/ramp.service.ts` diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 3cf055ccd..064a17722 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -2,8 +2,8 @@ "apidogProjectId": "918521", "endpointReference": { "currentDocumentedPaths": [ - "/v1/api-keys", - "/v1/api-keys/{keyId}", + "/v1/api-credentials", + "/v1/api-credentials/{credentialId}", "/v1/auth/request-otp", "/v1/auth/verify-otp", "/v1/brla/createSubaccount", @@ -19,6 +19,7 @@ "/v1/quotes/best", "/v1/quotes/{id}", "/v1/ramp/history/{walletAddress}", + "/v1/ramp-info", "/v1/ramp/register", "/v1/ramp/start", "/v1/ramp/update", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 595f421c6..0421cda6e 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -4,7 +4,7 @@ */ export interface paths { - "/v1/api-keys": { + "/v1/api-credentials": { parameters: { query?: never; header?: never; @@ -12,31 +12,27 @@ export interface paths { cookie?: never; }; /** - * 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. + * List API credentials + * @description Lists all profile-managed credentials owned by the authenticated profile, newest first. Each item represents one public/secret credential. Public values and safe secret prefixes are included; secret values are never returned. * - * **Auth:** requires `Authorization: Bearer ` obtained from `POST /v1/auth/verify-otp`. Partner `sk_*`/`pk_*` keys are not accepted. + * **Auth:** Supabase Bearer session only. */ - get: operations["listUserApiKeys"]; + get: operations["listApiCredentials"]; 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. + * Create an API credential + * @description Creates one credential row containing a public value and a hashed secret value for the authenticated profile. The secret is returned only in this response. Expiry defaults to one year and cannot exceed two years. At most five non-revoked, non-expired credentials may exist per profile. * - * 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. + * **Auth:** Supabase Bearer session only. */ - post: operations["createUserApiKey"]; + post: operations["createApiCredential"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/v1/api-keys/{keyId}": { + "/v1/api-credentials/{credentialId}": { parameters: { query?: never; header?: never; @@ -47,12 +43,34 @@ export interface paths { 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. + * Revoke an API credential + * @description Sets `revokedAt` on one profile-managed credential owned by the authenticated profile, atomically disabling its public and secret values. No request body or paired key ID is accepted. * - * **Auth:** requires `Authorization: Bearer ` obtained from `POST /v1/auth/verify-otp`. Partner `sk_*`/`pk_*` keys are not accepted. + * **Auth:** Supabase Bearer session only. */ - delete: operations["revokeUserApiKey"]; + delete: operations["revokeApiCredential"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/ramp-info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get sanitized ramp eligibility + * @description Returns only sanitized per-corridor KYC state and buy/sell eligibility for the profile derived from the validated credential or session. The endpoint accepts no user/profile selector and never returns PII, provider/customer IDs, KYC failure reasons, bank/wallet data, ramp history, or exact financial limits. When both public and secret headers are supplied they must belong to the same credential. + * + * **Auth:** `X-Public-Key`, `X-API-Key`, or Supabase Bearer session. + */ + get: operations["getRampInfo"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; @@ -276,6 +294,28 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/limits": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get user ramp limits + * @description Returns onramp and offramp limits for the authenticated user's requested fiat corridors. Alfredpay usage is calculated from completed Vortex ramps in the current UTC calendar month and may be delayed by the 60-second in-memory cache. Avenia BRL maximums, usage, and period are read from Avenia. + * + * **Auth:** requires either `X-API-Key: sk_*` linked to a user or `Authorization: Bearer `. Unlinked partner keys are rejected. + */ + post: operations["getUserLimits"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/public-key": { parameters: { query?: never; @@ -1018,6 +1058,8 @@ export interface paths { * @description Register a new webhook to receive event notifications. * * **Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints. + * + * Webhooks are bound to the account behind your secret key: a `quoteId` must belong to a quote created with your key (any other quote returns `404`). The callback URL must use HTTPS, must not embed credentials, and must resolve to a publicly routable address; private or reserved IP ranges are rejected. */ post: { parameters: { @@ -1030,11 +1072,11 @@ export interface paths { content: { "application/json": { events?: string[]; - /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific quote */ + /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific quote. The quote must have been created with your API key. */ 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 */ + /** @description Your HTTPS webhook endpoint URL. No embedded credentials; must resolve to a publicly routable address. */ url: string; }; }; @@ -1100,6 +1142,8 @@ export interface paths { * @description Remove a webhook subscription. * * **Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints. + * + * Deletion is scoped to your account: a webhook registered by another account returns `404`. */ delete: { parameters: { @@ -1308,12 +1352,12 @@ export interface components { GetRampHistoryTransaction: { currentPhase: components["schemas"]["RampPhase"]; date: string; + /** @description The deadline for starting an initial ramp. */ + expiresAt: 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; - /** @description The deadline for starting an initial ramp. */ - expiresAt: string; from: components["schemas"]["DestinationType"]; fromAmount: string; fromCurrency: components["schemas"]["RampCurrency"]; @@ -1326,6 +1370,12 @@ export interface components { /** @description Destination address for a BUY ramp when available. */ walletAddress?: string; }; + GetUserLimitsRequest: { + corridors: ("AR" | "BR" | "CO" | "MX" | "US")[]; + }; + GetUserLimitsResponse: { + limits: components["schemas"]["UserLimit"][]; + }; GetUserRemainingLimitResponse: { /** * Format: double @@ -1408,28 +1458,48 @@ export interface components { 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; - }[]; + ApiCredential: { + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + environment: "live" | "test"; + /** Format: date-time */ + expiresAt: string; + /** Format: uuid */ + id: string; + name: string; + /** Format: uuid */ + partnerId: string | null; + /** Format: uuid */ + profileId: string; + /** @description Retrievable public half of the credential. */ + publicKey: string; + /** Format: date-time */ + publicLastUsedAt: string | null; + /** Format: date-time */ + revokedAt: string | null; + /** @description Non-secret 16-character lookup/display prefix. The secret value is not retrievable. */ + secretKeyPrefix: string; + /** Format: date-time */ + secretLastUsedAt: string | null; + /** Format: date-time */ + updatedAt: string; + }; + CreateApiCredentialRequest: { + /** + * Format: date-time + * @description Optional future ISO-8601 expiry, at most two years from creation. Defaults to one year. + */ + expiresAt?: string; + /** @default API Credential */ + name: string; + }; + CreateApiCredentialResponse: components["schemas"]["ApiCredential"] & { + /** @description Returned only at creation. Store it immediately in a server-side secret manager. */ + secretKey: string; + }; + ListApiCredentialsResponse: { + credentials: components["schemas"]["ApiCredential"][]; }; /** * @description Supported blockchain networks. @@ -1713,41 +1783,47 @@ export interface components { */ rampId: string; }; - UserApiKeyErrorResponse: { + ApiCredentialErrorResponse: { error: { - /** @description Machine-readable error code, e.g. `AUTHENTICATION_REQUIRED`, `API_KEY_LIMIT_REACHED`, `INVALID_EXPIRES_AT`, `API_KEY_NOT_FOUND`. */ + /** @description Machine-readable error code such as `AUTHENTICATION_REQUIRED`, `INVALID_PUBLIC_KEY`, `INVALID_SECRET_KEY`, `CREDENTIAL_MISMATCH`, `CREDENTIAL_LIMIT_REACHED`, `CREDENTIAL_NOT_FOUND`, `CREDENTIAL_SUBJECT_REQUIRED`, `INVALID_CREDENTIAL_EXPIRY`, or `INVALID_CREDENTIAL_NAME`. */ 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"; + RampInfoResponse: { + /** @description Sanitized eligibility keyed by corridor country code. No exact limits, PII, provider IDs, or failure reasons are returned. */ + corridors: { + [key: string]: { + canBuy: boolean; + canSell: boolean; + /** @enum {string} */ + kycStatus: "not_started" | "pending" | "approved" | "rejected"; + }; }; }; + UserLimit: { + /** @enum {string} */ + corridor: "AR" | "BR" | "CO" | "MX" | "US"; + currency: components["schemas"]["RampCurrency"]; + direction: components["schemas"]["RampDirection"]; + /** @description Maximum amount in the returned currency's human units. */ + max: string; + period: components["schemas"]["UserLimitPeriod"]; + /** @description Amount consumed during the period in the returned currency's human units. */ + used: string; + }; + UserLimitPeriod: { + /** + * Format: date-time + * @description Exclusive end of the reported period. + */ + endsAt: string; + /** Format: date-time */ + startsAt: string; + /** @constant */ + type: "calendar_month"; + }; ValidatePixKeyResponse: { /** @description Indicates if the PIX key is valid. */ valid?: boolean; @@ -1784,7 +1860,7 @@ export interface components { } export type $defs = Record; export interface operations { - listUserApiKeys: { + listApiCredentials: { parameters: { query?: never; header?: never; @@ -1793,13 +1869,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Active keys, newest first. */ + /** @description Credentials, newest first, including revoked and expired lifecycle records. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListUserApiKeysResponse"]; + "application/json": components["schemas"]["ListApiCredentialsResponse"]; }; }; /** @description Missing or invalid Bearer token. */ @@ -1808,7 +1884,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; /** @description Internal server error. */ @@ -1817,12 +1893,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; }; }; - createUserApiKey: { + createApiCredential: { parameters: { query?: never; header?: never; @@ -1831,40 +1907,26 @@ export interface operations { }; 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; - }; + "application/json": components["schemas"]["CreateApiCredentialRequest"]; }; }; responses: { - /** @description Key pair created. Persist `secretKey.key` immediately; it cannot be retrieved again. */ + /** @description Credential created. Persist `secretKey` immediately; it cannot be retrieved again. */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyPairResponse"]; + "application/json": components["schemas"]["CreateApiCredentialResponse"]; }; }; - /** @description `INVALID_EXPIRES_AT`: expiresAt is not a valid ISO-8601 date or is more than 2 years from now. */ + /** @description `INVALID_CREDENTIAL_EXPIRY` or `INVALID_CREDENTIAL_NAME`. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; /** @description Missing or invalid Bearer token. */ @@ -1873,16 +1935,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; - /** @description `API_KEY_LIMIT_REACHED`: the user already holds the maximum of 10 active keys. */ + /** @description `CREDENTIAL_LIMIT_REACHED`: the profile already holds five active non-expired credentials. */ 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; /** @description Internal server error. */ @@ -1891,76 +1953,102 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; }; }; - revokeUserApiKey: { + revokeApiCredential: { parameters: { query?: never; header?: never; path: { - /** @description ID of the key to revoke. */ - keyId: string; + /** @description Immutable credential ID to revoke. */ + credentialId: 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; - }; - }; - }; + requestBody?: never; responses: { - /** @description Key(s) revoked. */ + /** @description Credential revoked; both values are immediately unusable. */ 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: { + /** @description Missing or invalid Bearer token. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; - /** @description Missing or invalid Bearer token. */ - 401: { + /** @description `CREDENTIAL_NOT_FOUND`: credential is missing, already revoked, partner-managed, or not owned by the profile. */ + 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; - /** @description `API_KEY_NOT_FOUND` or `PAIRED_PUBLIC_KEY_NOT_FOUND`: key missing, already revoked, or not owned by the user. */ - 404: { + /** @description Internal server error. */ + 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; - /** @description Internal server error. */ - 500: { + }; + }; + getRampInfo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sanitized corridor eligibility. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RampInfoResponse"]; + }; + }; + /** @description Malformed key or wrong key type. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; + }; + }; + /** @description Missing, invalid, expired, or revoked credential/session. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; + }; + }; + /** @description `CREDENTIAL_MISMATCH`: presented public and secret values belong to different credentials. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserApiKeyErrorResponse"]; + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; }; }; }; @@ -2457,6 +2545,58 @@ export interface operations { }; }; }; + getUserLimits: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GetUserLimitsRequest"]; + }; + }; + responses: { + /** @description Limits and consumed amounts for both directions of every requested corridor. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetUserLimitsResponse"]; + }; + }; + /** @description Invalid corridor list or no completed provider profile for a requested corridor. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The credential is not linked to a user. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Provider limits are unavailable or invalid. */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; createQuote: { parameters: { query?: never; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 0d6160d39..a68ec37b8 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -56,6 +56,70 @@ "required": ["address", "type"], "type": "object" }, + "ApiCredential": { + "properties": { + "createdAt": { "format": "date-time", "type": "string" }, + "environment": { "enum": ["live", "test"], "type": "string" }, + "expiresAt": { "format": "date-time", "type": "string" }, + "id": { "format": "uuid", "type": "string" }, + "name": { "maxLength": 100, "type": "string" }, + "partnerId": { "format": "uuid", "type": ["string", "null"] }, + "profileId": { "format": "uuid", "type": "string" }, + "publicKey": { + "description": "Retrievable public half of the credential.", + "pattern": "^pk_(live|test)_[a-zA-Z0-9]{32}$", + "type": "string" + }, + "publicLastUsedAt": { "format": "date-time", "type": ["string", "null"] }, + "revokedAt": { "format": "date-time", "type": ["string", "null"] }, + "secretKeyPrefix": { + "description": "Non-secret 16-character lookup/display prefix. The secret value is not retrievable.", + "maxLength": 16, + "minLength": 16, + "type": "string" + }, + "secretLastUsedAt": { "format": "date-time", "type": ["string", "null"] }, + "updatedAt": { "format": "date-time", "type": "string" } + }, + "required": [ + "id", + "name", + "profileId", + "partnerId", + "environment", + "publicKey", + "secretKeyPrefix", + "publicLastUsedAt", + "secretLastUsedAt", + "expiresAt", + "revokedAt", + "createdAt", + "updatedAt" + ], + "type": "object" + }, + "ApiCredentialErrorResponse": { + "properties": { + "error": { + "properties": { + "code": { + "description": "Machine-readable error code such as `AUTHENTICATION_REQUIRED`, `INVALID_PUBLIC_KEY`, `INVALID_SECRET_KEY`, `CREDENTIAL_MISMATCH`, `CREDENTIAL_LIMIT_REACHED`, `CREDENTIAL_NOT_FOUND`, `CREDENTIAL_SUBJECT_REQUIRED`, `INVALID_CREDENTIAL_EXPIRY`, or `INVALID_CREDENTIAL_NAME`.", + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": ["code", "message", "status"], + "type": "object" + } + }, + "required": ["error"], + "type": "object" + }, "AveniaDocumentType": { "enum": ["ID", "DRIVERS-LICENSE", "PASSPORT", "SELFIE", "SELFIE-FROM-LIVENESS"], "type": "string" @@ -174,6 +238,33 @@ "description": "Allowed values: `AR`, `BR`, `EU`", "type": "string" }, + "CreateApiCredentialRequest": { + "properties": { + "expiresAt": { + "description": "Optional future ISO-8601 expiry, at most two years from creation. Defaults to one year.", + "format": "date-time", + "type": "string" + }, + "name": { "default": "API Credential", "maxLength": 100, "type": "string" } + }, + "type": "object" + }, + "CreateApiCredentialResponse": { + "allOf": [ + { "$ref": "#/components/schemas/ApiCredential" }, + { + "properties": { + "secretKey": { + "description": "Returned only at creation. Store it immediately in a server-side secret manager.", + "pattern": "^sk_(live|test)_[a-zA-Z0-9]{32}$", + "type": "string" + } + }, + "required": ["secretKey"], + "type": "object" + } + ] + }, "CreateBestQuoteRequest": { "properties": { "apiKey": { @@ -503,6 +594,34 @@ ], "type": "object" }, + "GetUserLimitsRequest": { + "additionalProperties": false, + "properties": { + "corridors": { + "items": { + "enum": ["AR", "BR", "CO", "MX", "US"], + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": ["corridors"], + "type": "object" + }, + "GetUserLimitsResponse": { + "properties": { + "limits": { + "items": { + "$ref": "#/components/schemas/UserLimit" + }, + "type": "array" + } + }, + "required": ["limits"], + "type": "object" + }, "GetUserRemainingLimitResponse": { "properties": { "remainingLimitOfframp": { @@ -696,56 +815,14 @@ "required": ["id"], "type": "object" }, - "ListUserApiKeysResponse": { + "ListApiCredentialsResponse": { "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" - }, + "credentials": { + "items": { "$ref": "#/components/schemas/ApiCredential" }, "type": "array" } }, - "required": ["apiKeys"], + "required": ["credentials"], "type": "object" }, "Networks": { @@ -944,6 +1021,28 @@ "required": ["timestamp", "phase", "error"], "type": "object" }, + "RampInfoResponse": { + "properties": { + "corridors": { + "additionalProperties": { + "properties": { + "canBuy": { "type": "boolean" }, + "canSell": { "type": "boolean" }, + "kycStatus": { + "enum": ["not_started", "pending", "approved", "rejected"], + "type": "string" + } + }, + "required": ["kycStatus", "canBuy", "canSell"], + "type": "object" + }, + "description": "Sanitized eligibility keyed by corridor country code. No exact limits, PII, provider IDs, or failure reasons are returned.", + "type": "object" + } + }, + "required": ["corridors"], + "type": "object" + }, "RampPhase": { "description": "The current phase of the ramp process.", "enum": [ @@ -1323,91 +1422,50 @@ "required": ["rampId", "presignedTxs"], "type": "object" }, - "UserApiKeyErrorResponse": { + "UserLimit": { "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" + "corridor": { + "enum": ["AR", "BR", "CO", "MX", "US"], + "type": "string" + }, + "currency": { + "$ref": "#/components/schemas/RampCurrency" + }, + "direction": { + "$ref": "#/components/schemas/RampDirection" + }, + "max": { + "description": "Maximum amount in the returned currency's human units.", + "type": "string" + }, + "period": { + "$ref": "#/components/schemas/UserLimitPeriod" + }, + "used": { + "description": "Amount consumed during the period in the returned currency's human units.", + "type": "string" } }, - "required": ["error"], + "required": ["corridor", "currency", "direction", "max", "period", "used"], "type": "object" }, - "UserApiKeyPairResponse": { + "UserLimitPeriod": { "properties": { - "createdAt": { + "endsAt": { + "description": "Exclusive end of the reported period.", "format": "date-time", "type": "string" }, - "expiresAt": { + "startsAt": { "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" + "type": { + "const": "calendar_month", + "type": "string" } }, - "required": ["createdAt", "expiresAt", "isActive", "publicKey", "secretKey"], + "required": ["type", "startsAt", "endsAt"], "type": "object" }, "ValidatePixKeyResponse": { @@ -1420,38 +1478,56 @@ "type": "object" } }, - "securitySchemes": {} + "securitySchemes": { + "BearerAuth": { + "bearerFormat": "Supabase JWT", + "scheme": "bearer", + "type": "http" + }, + "PublicApiKey": { + "description": "Public credential value (`pk_live_*` or `pk_test_*`) for attribution and approved low-sensitivity reads.", + "in": "header", + "name": "X-Public-Key", + "type": "apiKey" + }, + "SecretApiKey": { + "description": "Server-side secret credential value (`sk_live_*` or `sk_test_*`).", + "in": "header", + "name": "X-API-Key", + "type": "apiKey" + } + } }, "info": { - "description": "Cross-border payments gateway built on the Pendulum blockchain.\n\n**Scope:** 25 paths verified against `apps/api/src/api/routes/v1/`.\n\n**Auth principals:**\n- `X-API-Key: sk__...` \u2014 partner SDK key (server-side).\n- `X-Public-Key: pk__...` \u2014 partner public key (browser; attribution only).\n- `Authorization: Bearer ` \u2014 first-party user session.\n\nAll `/v1/brla/*` endpoints accept Supabase Bearer only; partner sk_*/pk_* keys are not accepted on BRLA routes.\n\n**Webhook signing:** RSA-PSS 2048 / SHA-256. Fetch the signing key from `GET /v1/public-key`.\n", + "description": "Cross-border payments gateway built on the Pendulum blockchain.\n\n**API credentials:** one credential contains a public (`pk_*`) and secret (`sk_*`) value for one profile subject. Send public values through `X-Public-Key` and server-side secret values through `X-API-Key`. If both are sent, they must belong to the same credential or the request returns `403 CREDENTIAL_MISMATCH`. Public capability is limited to attribution and explicitly sanitized reads; secret capability is required for sensitive and state-changing partner operations.\n\n`Authorization: Bearer ` represents a first-party user session and is required for profile-managed credential lifecycle endpoints.\n\n**Webhook signing:** RSA-PSS 2048 / SHA-256. Fetch the signing key from `GET /v1/public-key`.\n", "title": "Vortex API", "version": "1.1.0" }, "openapi": "3.1.0", "paths": { - "/v1/api-keys": { + "/v1/api-credentials": { "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", + "description": "Lists all profile-managed credentials owned by the authenticated profile, newest first. Each item represents one public/secret credential. Public values and safe secret prefixes are included; secret values are never returned.\n\n**Auth:** Supabase Bearer session only.", + "operationId": "listApiCredentials", "parameters": [], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListUserApiKeysResponse" + "$ref": "#/components/schemas/ListApiCredentialsResponse" } } }, - "description": "Active keys, newest first.", + "description": "Credentials, newest first, including revoked and expired lifecycle records.", "headers": {} }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, @@ -1462,7 +1538,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, @@ -1470,36 +1546,19 @@ "headers": {} } }, - "security": [], - "summary": "List the user's API keys", + "security": [{ "BearerAuth": [] }], + "summary": "List API credentials", "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", + "description": "Creates one credential row containing a public value and a hashed secret value for the authenticated profile. The secret is returned only in this response. Expiry defaults to one year and cannot exceed two years. At most five non-revoked, non-expired credentials may exist per profile.\n\n**Auth:** Supabase Bearer session only.", + "operationId": "createApiCredential", "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" - } + "schema": { "$ref": "#/components/schemas/CreateApiCredentialRequest" } } }, "required": false @@ -1509,29 +1568,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyPairResponse" + "$ref": "#/components/schemas/CreateApiCredentialResponse" } } }, - "description": "Key pair created. Persist `secretKey.key` immediately; it cannot be retrieved again.", + "description": "Credential created. Persist `secretKey` immediately; it cannot be retrieved again.", "headers": {} }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, - "description": "`INVALID_EXPIRES_AT`: expiresAt is not a valid ISO-8601 date or is more than 2 years from now.", + "description": "`INVALID_CREDENTIAL_EXPIRY` or `INVALID_CREDENTIAL_NAME`.", "headers": {} }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, @@ -1542,18 +1601,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, - "description": "`API_KEY_LIMIT_REACHED`: the user already holds the maximum of 10 active keys.", + "description": "`CREDENTIAL_LIMIT_REACHED`: the profile already holds five active non-expired credentials.", "headers": {} }, "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, @@ -1561,67 +1620,38 @@ "headers": {} } }, - "security": [], - "summary": "Create a user-linked API key pair", + "security": [{ "BearerAuth": [] }], + "summary": "Create an API credential", "tags": ["Authentication"] } }, - "/v1/api-keys/{keyId}": { + "/v1/api-credentials/{credentialId}": { "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", + "description": "Sets `revokedAt` on one profile-managed credential owned by the authenticated profile, atomically disabling its public and secret values. No request body or paired key ID is accepted.\n\n**Auth:** Supabase Bearer session only.", + "operationId": "revokeApiCredential", "parameters": [ { - "description": "ID of the key to revoke.", + "description": "Immutable credential ID to revoke.", "in": "path", - "name": "keyId", + "name": "credentialId", "required": true, "schema": { + "format": "uuid", "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.", + "description": "Credential revoked; both values are immediately unusable.", "headers": {} }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, @@ -1632,18 +1662,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, - "description": "`API_KEY_NOT_FOUND` or `PAIRED_PUBLIC_KEY_NOT_FOUND`: key missing, already revoked, or not owned by the user.", + "description": "`CREDENTIAL_NOT_FOUND`: credential is missing, already revoked, partner-managed, or not owned by the profile.", "headers": {} }, "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserApiKeyErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, @@ -1651,8 +1681,8 @@ "headers": {} } }, - "security": [], - "summary": "Revoke an API key", + "security": [{ "BearerAuth": [] }], + "summary": "Revoke an API credential", "tags": ["Authentication"] } }, @@ -2318,6 +2348,50 @@ "tags": ["Account Management"] } }, + "/v1/limits": { + "post": { + "deprecated": false, + "description": "Returns onramp and offramp limits for the authenticated user's requested fiat corridors. Alfredpay usage is calculated from completed Vortex ramps in the current UTC calendar month and may be delayed by the 60-second in-memory cache. Avenia BRL maximums, usage, and period are read from Avenia.\n\n**Auth:** requires either `X-API-Key: sk_*` linked to a user or `Authorization: Bearer `. Unlinked partner keys are rejected.", + "operationId": "getUserLimits", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUserLimitsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUserLimitsResponse" + } + } + }, + "description": "Limits and consumed amounts for both directions of every requested corridor." + }, + "400": { + "description": "Invalid corridor list or no completed provider profile for a requested corridor." + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "The credential is not linked to a user." + }, + "502": { + "description": "Provider limits are unavailable or invalid." + } + }, + "security": [], + "summary": "Get user ramp limits", + "tags": ["Account Management"] + } + }, "/v1/public-key": { "get": { "deprecated": false, @@ -2822,6 +2896,43 @@ "tags": ["Quotes"] } }, + "/v1/ramp-info": { + "get": { + "deprecated": false, + "description": "Returns only sanitized per-corridor KYC state and buy/sell eligibility for the profile derived from the validated credential or session. The endpoint accepts no user/profile selector and never returns PII, provider/customer IDs, KYC failure reasons, bank/wallet data, ramp history, or exact financial limits. When both public and secret headers are supplied they must belong to the same credential.\n\n**Auth:** `X-Public-Key`, `X-API-Key`, or Supabase Bearer session.", + "operationId": "getRampInfo", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/RampInfoResponse" } } + }, + "description": "Sanitized corridor eligibility." + }, + "400": { + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } + }, + "description": "Malformed key or wrong key type." + }, + "401": { + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } + }, + "description": "Missing, invalid, expired, or revoked credential/session." + }, + "403": { + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } + }, + "description": "`CREDENTIAL_MISMATCH`: presented public and secret values belong to different credentials." + } + }, + "security": [{ "PublicApiKey": [] }, { "SecretApiKey": [] }, { "BearerAuth": [] }], + "summary": "Get sanitized ramp eligibility", + "tags": ["Account Management"] + } + }, "/v1/ramp/{id}": { "get": { "deprecated": false, @@ -3060,19 +3171,31 @@ "in": "query", "name": "limit", "required": false, - "schema": { "default": 20, "type": "integer" } + "schema": { + "default": 20, + "type": "integer" + } }, { "description": "The offset for querying older transactions.", "in": "query", "name": "offset", "required": false, - "schema": { "default": 0, "type": "integer" } + "schema": { + "default": 0, + "type": "integer" + } } ], "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GetRampHistoryResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetRampHistoryResponse" + } + } + }, "description": "Authenticated user's ramp history.", "headers": {} } @@ -4328,7 +4451,7 @@ "/v1/webhook": { "post": { "deprecated": false, - "description": "Register a new webhook to receive event notifications.\n\n**Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints.", + "description": "Register a new webhook to receive event notifications.\n\n**Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints.\n\nWebhooks are bound to the account behind your secret key: a `quoteId` must belong to a quote created with your key (any other quote returns `404`). The callback URL must use HTTPS, must not embed credentials, and must resolve to a publicly routable address; private or reserved IP ranges are rejected.", "parameters": [], "requestBody": { "content": { @@ -4344,7 +4467,7 @@ "type": "array" }, "quoteId": { - "description": "(required* one of two: quoteId or sessionId): Subscribe to events for a specific quote", + "description": "(required* one of two: quoteId or sessionId): Subscribe to events for a specific quote. The quote must have been created with your API key.", "type": "string" }, "sessionId": { @@ -4352,7 +4475,7 @@ "type": "string" }, "url": { - "description": "Your HTTPS webhook endpoint URL", + "description": "Your HTTPS webhook endpoint URL. No embedded credentials; must resolve to a publicly routable address.", "type": "string" } }, @@ -4426,7 +4549,7 @@ "/v1/webhook/{id}": { "delete": { "deprecated": false, - "description": "Remove a webhook subscription.\n\n**Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints.", + "description": "Remove a webhook subscription.\n\n**Auth:** requires `X-API-Key: sk_*`. Supabase Bearer is NOT accepted on webhook endpoints.\n\nDeletion is scoped to your account: a webhook registered by another account returns `404`.", "parameters": [ { "description": "", 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 efe648b96..07649f6d5 100644 --- a/docs/api/pages/02-quick-start-with-the-sdk.md +++ b/docs/api/pages/02-quick-start-with-the-sdk.md @@ -32,7 +32,14 @@ const config: VortexSdkConfig = { const sdk = new VortexSdk(config); ``` -`publicKey` is attached to quote requests for partner attribution and discount eligibility. `secretKey` is sent as the `X-API-Key` header on authenticated requests and must only be used server-side. +`publicKey` is sent as `X-Public-Key` (and retained in quote bodies for compatibility) for attribution, approved low-sensitivity reads, and discount eligibility. `secretKey` is sent as `X-API-Key` and must only be used server-side. Both values should come from the same API credential; a mixed pair returns `403 CREDENTIAL_MISMATCH`. A valid secret may be used without a public value. + +You can check the authenticated subject's sanitized corridor readiness without exposing exact limits or profile data: + +```js +const info = await sdk.getRampInfo(); +console.log(info.corridors.BR?.kycStatus, info.corridors.BR?.canBuy); +``` Constructing `VortexSdk` opens three WebSocket connections (Pendulum, Moonbeam, Hydration). Reuse one instance per process; do not construct a new SDK per request. @@ -59,7 +66,7 @@ console.log(rampProcess.depositQrCode); const started = await sdk.startRamp(rampProcess.id); ``` -The user must have completed BRL KYC level 1 or higher, and the SDK must be authenticated with that user's own user-linked `sk_*` key: the user's CPF/CNPJ is derived from the authenticated account. The `taxId` field is deprecated — if you still send it, it must match the tax ID on the account or registration is rejected. Partner keys cannot drive KYC and cannot register ramps; onboard the user through the Vortex app or Widget first. +The user must have completed BRL KYC level 1 or higher, and the SDK's credential must be bound to that profile. The user's CPF/CNPJ is derived from the authenticated account. The `taxId` field is deprecated — if you still send it, it must match the tax ID on the account or registration is rejected. A technical profile without the user's eligible provider account cannot drive KYC or register the user's ramp; onboard or provision the real subject first. ## BRL Offramp (Sell) @@ -147,9 +154,9 @@ 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. +Quotes can be requested without any key (anonymous rate discovery). Registering through the SDK requires the configured `secretKey` to resolve to an onboarded profile. The same profile must have completed KYC for the corridor's country, so registration resolves to its verified payment profile automatically. A `publicKey`-only registration is rejected. Raw API integrations may alternatively register with the user's Supabase Bearer session. -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. +The SDK cannot mint credentials or run KYC. Onboard the real user through the Vortex app or Widget, or use Vortex's managed-profile workflow, then use a credential bound to that profile. The secret is shown only once at creation; see [Authentication And API Credentials](https://api-docs.vortexfinance.co/authentication-and-partner-keys). This applies to buys and sells in all four corridors. ### Offramp (Sell) diff --git a/docs/api/pages/03-authentication-and-partner-keys.md b/docs/api/pages/03-authentication-and-partner-keys.md index fe0d410f5..e29ff1e99 100644 --- a/docs/api/pages/03-authentication-and-partner-keys.md +++ b/docs/api/pages/03-authentication-and-partner-keys.md @@ -1,162 +1,125 @@ # Authentication And API Keys -Vortex authenticates API clients with public/secret key pairs, and accepts Supabase Bearer session tokens for first-party user flows and key management. +Vortex issues one API credential with two values for one profile subject: -## Which Credential Do I Need? +- `pk_live_*` / `pk_test_*` is the public value. Send it as `X-Public-Key` for quote/widget attribution and approved low-sensitivity reads. It may be used in browser code. +- `sk_live_*` / `sk_test_*` is the secret value. Send it as `X-API-Key` for sensitive or state-changing operations. It must remain on a trusted server. -| Task | Credential | -|---|---| -| Quote attribution and partner pricing | `pk_*` public key (optional on quotes) | -| Ramp registration, all corridors | **User-linked** `sk_*` secret key | -| Webhook management | Partner secret key | -| Minting and managing user API keys | `Authorization: Bearer` session token | - -## Public Keys - -Public keys use the `pk_live_*` or `pk_test_*` prefix. They are used for partner attribution, tracking, and partner-specific quote behavior. Public keys may be included in SDK configuration or request bodies as `apiKey`. - -Public keys do not authenticate sensitive partner operations. An invalid or expired public key is rejected on routes that validate it; it is not silently ignored. +Both values share one immutable credential ID, subject profile, optional partner, environment, expiry, and revocation lifecycle. If a request sends both values, they must belong to the same credential or Vortex returns `403 CREDENTIAL_MISMATCH`. -## Secret Keys +## Capability Matrix -Secret keys use the `sk_live_*` or `sk_test_*` prefix. They authenticate operations through the `X-API-Key` header. +| Task | Public value | Secret value | Supabase Bearer | +|---|---:|---:|---:| +| Quote/widget attribution | Yes | Yes | Yes | +| Sanitized `GET /v1/ramp-info` | Yes | Yes | Yes | +| Exact limits and provider-account reads | No | Yes | Yes | +| Ramp register/update/start/status/history/errors | No | Yes | Yes | +| Webhook management | No | Yes | No | +| Profile-managed credential lifecycle | No | No | Yes | -Secret keys come in two scopes: +`GET /v1/ramp-info` returns only per-corridor `kycStatus`, `canBuy`, and `canSell`. It does not accept a profile/user selector and does not expose PII, provider identifiers, KYC failure reasons, account details, ramp history, or exact limits. -- **Partner-scoped** keys are issued to a partner organization. They authenticate webhook management and partner attribution. A partner key that is not linked to a user account cannot register ramps in any corridor. -- **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. **Ramp registration requires a user identity in every corridor** — the ramp acts as the key's user, and corridor identity fields are derived from that account rather than taken from the request. For BRL, an explicitly provided `taxId` is accepted only as a cross-check: it must match the tax ID on the authenticated account. See [Fiat Corridors](https://api-docs.vortexfinance.co/fiat-corridors). +## Subject And Partner Binding -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. +Every credential acts for exactly one Vortex profile. A profile-managed credential has no partner and is managed by its signed-in subject. A partner-managed credential has an optional partner attribution but still acts only for its bound profile. -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 registration requires that real profile subject in every corridor. KYC and provider identity are derived from the credential's profile, never from a request-selected user. For BRL, a supplied `taxId` is only a deprecated cross-check and must match the authenticated profile. A technical profile can operate only on provider/customer resources it actually owns. -Ramp endpoints, including register, update, start, status, history, and error logs, require authentication through either a secret key or a Supabase Bearer token. Registration additionally requires the authenticated identity to resolve to a user — a secret key with no linked user is rejected with `400`. +Partners must provision one genuine managed profile per individual, business, or technical subject when interactive signup is unavailable. Vortex's admin workflow binds the profile to immutable partner and external-user IDs and allows the same identity to be claimed later through OTP. Individual and business subjects receive the corresponding customer entity. Technical subjects receive no customer entity and cannot perform customer or ramp operations. Do not share dummy profiles between customers or infer a subject from a credential display name. -Webhook endpoints require a partner secret key and do not accept Supabase Bearer tokens. +## Secret Handling -## Supabase Bearer Tokens +Vortex stores only a SHA-256 digest and a safe lookup prefix for the secret value. The full secret is returned once when the credential is created. Store it immediately in a secret manager. Never place it in browser/mobile bundles, URLs, request bodies, screenshots, analytics, logs, support tickets, or source control. -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. +## Provision A Profile-Managed Credential -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 +### 1. Request And Verify An OTP ```http POST /v1/auth/request-otp Content-Type: application/json -``` -```json -{ - "email": "user@example.com" -} +{ "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" -} +{ "email": "user@example.com", "token": "123456" } ``` -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. +Verification returns `access_token`, `refresh_token`, and `user_id`, creating the profile on first sign-in. `POST /v1/auth/refresh` accepts the refresh token when needed. -`POST /v1/auth/refresh` with `{ "refresh_token": "..." }` returns a fresh token pair when the access token expires. - -### 3. Create The Key Pair +### 2. Create One Credential ```http -POST /v1/api-keys +POST /v1/api-credentials Authorization: Bearer Content-Type: application/json -``` -```json { - "name": "my-backend", - "expiresAt": "2027-07-06T00:00:00.000Z" + "name": "production backend", + "expiresAt": "2027-07-31T00:00:00.000Z" } ``` -Both body fields are optional. Response (`201`): +Both fields are optional. Expiry defaults to one year, must be in the future, and cannot exceed two years. The response is one resource: ```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" - } + "id": "00000000-0000-0000-0000-000000000000", + "name": "production backend", + "profileId": "00000000-0000-0000-0000-000000000001", + "partnerId": null, + "environment": "live", + "publicKey": "pk_live_...", + "secretKey": "sk_live_...", + "secretKeyPrefix": "16-character safe prefix", + "publicLastUsedAt": null, + "secretLastUsedAt": null, + "expiresAt": "2027-07-31T00:00:00.000Z", + "revokedAt": null, + "createdAt": "2026-07-31T00:00:00.000Z", + "updatedAt": "2026-07-31T00:00:00.000Z" } ``` -- **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. +The profile may have at most five active, non-expired credentials. Exceeding the cap returns `409 CREDENTIAL_LIMIT_REACHED`. Sandbox issues `*_test_*`; production issues `*_live_*`. -### 4. Use The Keys - -Configure the SDK (or send `X-API-Key` directly) with the minted pair: +### 3. Configure The SDK ```js const sdk = new VortexSdk({ apiBaseUrl: "https://api.vortexfinance.co", - publicKey: "pk_live_...", - secretKey: "sk_live_..." + publicKey: process.env.VORTEX_PUBLIC_KEY, + secretKey: process.env.VORTEX_SECRET_KEY }); ``` -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). +A secret may be configured without a public value when only authenticated operations are needed. A public-only SDK can call `getRampInfo()` and create attributed quotes but cannot register or operate a ramp. -### Managing Keys +## List And Revoke -- `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. +- `GET /v1/api-credentials` returns one item per credential. It includes the public value and safe secret prefix, never the secret value. +- `DELETE /v1/api-credentials/{credentialId}` returns `204` and atomically revokes both values. It takes no request body and no second key ID. -## Webhook Signing Key +Both endpoints require the subject's Supabase Bearer session. Secret API credentials cannot create or revoke other credentials. -`GET /v1/public-key` returns the RSA-PSS public key used to verify webhook signatures. It is unrelated to partner `pk_*` public keys. +## Common Errors -## Recommended Handling +| Code | Meaning | +|---|---| +| `INVALID_PUBLIC_KEY` | Public value is unknown, expired, or revoked. | +| `INVALID_SECRET_KEY` / `INVALID_API_KEY` | Secret value is malformed, unknown, expired, or revoked. | +| `CREDENTIAL_MISMATCH` | Presented public/body/header and secret values do not identify one credential. | +| `CREDENTIAL_LIMIT_REACHED` | The profile already has five active non-expired credentials. | +| `CREDENTIAL_NOT_FOUND` | Credential is missing, already revoked, or outside the authenticated manager's scope. | +| `CREDENTIAL_SUBJECT_REQUIRED` | A valid profile subject was not supplied for partner-managed issuance. | + +## Webhook Signing Key -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. +`GET /v1/public-key` returns the RSA-PSS public key used to verify webhook signatures. It is unrelated to a `pk_*` API credential value. --- diff --git a/docs/api/pages/04-ramp-lifecycle.md b/docs/api/pages/04-ramp-lifecycle.md index fbf554374..c756d2b7a 100644 --- a/docs/api/pages/04-ramp-lifecycle.md +++ b/docs/api/pages/04-ramp-lifecycle.md @@ -8,12 +8,16 @@ Use `POST /v1/quotes` when the route and network are known. Use `POST /v1/quotes A quote contains the input amount, expected output amount, source and destination, fee breakdown, payment method, selected network, and expiry. Quotes are short-lived and should be registered promptly. +For attribution, send the public credential value as `X-Public-Key`. Anonymous quotes remain supported. If both public and secret headers are sent, they must identify the same API credential. + `POST /v1/quotes/best` is not called by the SDK today. Use the raw API directly when you want Vortex to select the best available route, then pass the returned quote into the SDK ramp flow. ## 2. Register The Ramp Use `POST /v1/ramp/register` with the quote ID and public addresses of the ephemeral accounts created for this ramp. The response returns a `rampId`, current ramp state, and any unsigned transactions that must be signed before processing can continue. +Register, update, start, status, history, and diagnostic operations require `X-API-Key` secret capability or an accepted Supabase session. A public value cannot operate a ramp. The credential is bound to one profile, and provider/KYC ownership is derived from that profile rather than a request-selected user. + Only public addresses are sent to Vortex. The matching ephemeral secret keys must stay with the SDK or API client. ## 3. Update The Ramp @@ -32,6 +36,8 @@ Use `POST /v1/ramp/start` after required signatures, transaction hashes, and fia Use `GET /v1/ramp/{id}` to retrieve current state, or configure webhooks to receive lifecycle events asynchronously. `GET /v1/ramp/{id}/errors` returns the error log for a ramp and is useful for support tooling. +Before starting a flow, `sdk.getRampInfo()` / `GET /v1/ramp-info` can return the bound profile's sanitized per-corridor `kycStatus`, `canBuy`, and `canSell` using public, secret, or session capability. It intentionally does not return exact limits, PII, provider IDs, or ramp history. + Production integrations should persist the `quoteId`, `rampId`, partner order ID, user/session identifier, and any local ephemeral-key backup reference needed for support or recovery. --- diff --git a/docs/api/pages/06-quotes-and-pricing.md b/docs/api/pages/06-quotes-and-pricing.md index 9246f1ec3..5b1f91569 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -15,6 +15,7 @@ Quotes are the entry point for every Vortex ramp. A quote pins down the route, i ```http POST /v1/quotes Content-Type: application/json +X-Public-Key: pk_live_... ``` ```json @@ -24,15 +25,14 @@ Content-Type: application/json "to": "polygon", "inputAmount": "150", "inputCurrency": "BRL", - "outputCurrency": "USDC", - "apiKey": "pk_live_..." + "outputCurrency": "USDC" } ``` - `rampType` is `"BUY"` (onramp, fiat → crypto) or `"SELL"` (offramp, crypto → fiat). - `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. +- `X-Public-Key` (optional) carries the public half of one API credential for attribution and discount eligibility. The legacy body `apiKey` field remains accepted for quote compatibility; if both are sent, their values must match. ## Quote Response @@ -110,6 +110,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 API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). +Pass the credential's public value through `X-Public-Key` to apply partner pricing and attribution. The SDK also retains it in the quote body for compatibility. When `X-Public-Key` and `X-API-Key` are both present, they must belong to the same credential or Vortex returns `403 CREDENTIAL_MISMATCH`. See [Authentication And API Credentials](https://api-docs.vortexfinance.co/authentication-and-partner-keys). --- diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index fe5019c41..2711eb169 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -11,10 +11,14 @@ You can subscribe to: Every webhook request includes: -- `X-Vortex-Signature` — RSA-PSS signature of the raw request body, base64-encoded. -- `X-Vortex-Timestamp` — Unix timestamp (seconds) of the request. +- `X-Vortex-Signature` — base64-encoded RSA-PSS signature of the string `{timestamp}.{body}`, where `{timestamp}` is the value of `X-Vortex-Timestamp` and `{body}` is the raw request body. Because the timestamp is part of the signed string, a captured delivery cannot be replayed later with a fresh timestamp. +- `X-Vortex-Timestamp` — Unix timestamp (seconds) of the delivery attempt. -All webhook URLs **must use HTTPS**. Signatures are verified against the RSA-PSS 2048-bit public key returned by `GET /v1/public-key`. +Every event payload also carries an `eventId` that is unique per event and stays the same across delivery retries. Deduplicate on it: if you have already processed an `eventId`, acknowledge the request with `2xx` and skip your handler. + +All webhook URLs **must use HTTPS** and must not embed credentials. The hostname is checked at registration and again before every delivery: if it resolves to a private or otherwise non-public address the request is rejected. A hostname that does not resolve yet is accepted at registration (so you can register before DNS is live), but deliveries to it will fail until it resolves publicly. Signatures are verified against the RSA-PSS 2048-bit public key returned by `GET /v1/public-key`. + +Webhooks are bound to the account behind your secret key: you can only subscribe to a `quoteId` created with your key (any other quote returns `404`), and you can only delete webhooks your account registered. ## Registering A Webhook @@ -51,6 +55,7 @@ Fired immediately after the ramp state is created (`POST /v1/ramp/register`). ```json { + "eventId": "9f0c9a4e-4a3b-4a52-b0aa-1f6dc78c4a01", "eventType": "TRANSACTION_CREATED", "timestamp": "2025-01-15T10:30:00.000Z", "payload": { @@ -65,6 +70,7 @@ Fired immediately after the ramp state is created (`POST /v1/ramp/register`). | Field | Description | |---|---| +| `eventId` | Unique event identifier, stable across delivery retries — use for deduplication. | | `quoteId` | Unique identifier for the quote. | | `transactionId` | Unique identifier for the ramp (`rampId`). | | `sessionId` | Widget session identifier if registered against a session. | @@ -77,6 +83,7 @@ Fired whenever the ramp's status changes during processing. ```json { + "eventId": "5b8a0f1d-2e64-49c7-9d3b-8f2a3f0e6c22", "eventType": "STATUS_CHANGE", "timestamp": "2025-01-15T10:35:00.000Z", "payload": { @@ -114,7 +121,7 @@ Fetch the current public key: GET /v1/public-key ``` -Verify signatures using RSA-PSS with SHA-256. Reject requests that fail signature verification, are outside an acceptable timestamp window, contain malformed payloads, or do not match the expected event structure. +Verify signatures using RSA-PSS with SHA-256 over the string `{timestamp}.{body}` — the `X-Vortex-Timestamp` header value, a literal dot, then the raw request body. Reject requests that fail signature verification, are outside an acceptable timestamp window, contain malformed payloads, or do not match the expected event structure, and deduplicate on `eventId`. ### Example: Bun + TypeScript Listener @@ -188,7 +195,8 @@ serve({ const bodyText = await req.text(); if (!bodyText) return new Response("Empty body", { status: 400 }); - if (!(await verifier.verifySignature(bodyText, signature))) { + // The signature covers the timestamp header and the raw body, joined by a dot. + if (!(await verifier.verifySignature(`${timestamp}.${bodyText}`, signature))) { return new Response("Invalid signature", { status: 401 }); } @@ -197,7 +205,8 @@ serve({ return new Response(`Unsupported event type: ${event.eventType}`, { status: 400 }); } - // TODO: route event to your handler (update DB, notify user, etc.). + // TODO: deduplicate on event.eventId (stable across retries), then route the + // event to your handler (update DB, notify user, etc.). return new Response("OK", { status: 200 }); } diff --git a/docs/api/pages/08-widget-integration.md b/docs/api/pages/08-widget-integration.md index 50f7acdfe..e59ebcf40 100644 --- a/docs/api/pages/08-widget-integration.md +++ b/docs/api/pages/08-widget-integration.md @@ -10,7 +10,7 @@ POST /v1/session/create This single endpoint creates a widget session and returns a hosted URL. It supports two mutually exclusive request shapes depending on whether you already have a quote. -Authentication: pass your partner public key (`pk_live_*` / `pk_test_*`) as `apiKey` in the body for attribution. No secret key is required to create a session. +Authentication: send your credential's public value (`pk_live_*` / `pk_test_*`) as `X-Public-Key` for attribution. The body `apiKey` field remains accepted for widget compatibility; if both are present, they must match. Never expose the corresponding secret in widget or browser code. `externalSessionId` is **required in both modes**. It is your own opaque identifier for the session and is echoed back in [webhook payloads](https://api-docs.vortexfinance.co/webhooks) so you can correlate events to your records. @@ -21,6 +21,7 @@ Use this when your application has already created a quote via `POST /v1/quotes` ```http POST /v1/session/create Content-Type: application/json +X-Public-Key: pk_live_... ``` ```json @@ -67,7 +68,6 @@ Content-Type: application/json "fiat": "BRL", "cryptoLocked": "USDC", "paymentMethod": "pix", - "apiKey": "pk_live_...", "callbackUrl": "https://partner.example.com/ramp/complete", "walletAddressLocked": "0x1234567890123456789012345678901234567890" } @@ -84,7 +84,7 @@ Content-Type: application/json | `fiat` | no | Fiat currency for the fiat leg (e.g. `"BRL"`). Required in practice for fiat-side ramps. | | `cryptoLocked` | no | Pre-selects and locks the crypto asset in the widget (e.g. `"USDC"`). | | `paymentMethod` | no | Payment rail (e.g. `"pix"`). Required in practice for buy flows. | -| `apiKey` | no | Partner public key `pk_live_*` / `pk_test_*` used for attribution and partner pricing on the quotes the widget creates. | +| `apiKey` | no | Legacy body transport for the public credential value. Prefer `X-Public-Key`; if both are present, they must match. | | `countryCode` | no | ISO-3166 alpha-2 country code to pre-filter eligible options. | | `partnerId` | no | Partner identifier for attribution. | | `callbackUrl` | no | URL the widget redirects to after the user successfully creates the transaction. | @@ -146,6 +146,8 @@ Content-Type: application/json Webhook payloads include the `sessionId` so you can correlate events back to your `externalSessionId`. +Webhook management uses the corresponding server-side secret through `X-API-Key`. If an integration configures both public and secret values, keep them from the same credential; mixed credentials return `403 CREDENTIAL_MISMATCH`. + ## When To Use The Widget | Scenario | Use | diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index 0b77492c1..6aa5f879d 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -4,7 +4,7 @@ This page collects what each fiat corridor requires before a ramp can be registe ## 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 identity under which KYC is completed, but it is not how the ramp identifies the user: registration must be authenticated with the user's own **user-linked** `sk_*` key, and the tax ID is derived from that account. A `taxId` field may still be provided for backwards compatibility, but only as a cross-check — it must match the account's tax ID, and it cannot select a different user or claim an unlinked tax ID. +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 identity under which KYC is completed, but it is not how the ramp identifies the user: registration must authenticate as that user through a user-scoped key, a partner key delegated to the user, or a Supabase Bearer session. The tax ID is derived from that account. A `taxId` field may still be provided for backwards compatibility, but only as a cross-check — it must match the account's tax ID, and it cannot select a different user or claim an unlinked tax ID. 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 on the same account whose key registers the ramp; otherwise the ramp may fail or require additional account-management steps. @@ -27,7 +27,7 @@ All four corridors support buys and sells on EVM networks; AssetHub is not avail 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. +Unlike BRL, ramp registration still resolves the user's KYC and payment profile from the authenticated account, not from request fields. Authenticate as that user through a user-scoped key, a partner key delegated to the user, or a Supabase Bearer session. Your integration can mint a user-scoped key programmatically after an email OTP sign-in; see [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). Partner-only keys cannot register ramps or 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 @@ -41,6 +41,8 @@ After `POST /v1/ramp/start`, the response's `achPaymentData` contains the bank t 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. +Authenticated clients can request account limits with `POST /v1/limits`, passing a list of corridor country codes. The response contains separate onramp and offramp maximums, consumed amounts, units, and calendar-month boundaries. Avenia usage and period values come from the provider. Alfredpay usage is calculated from completed Vortex ramps and cached for 60 seconds; its calendar-month reset is a Vortex assumption because Alfredpay's public API does not publish quota-period semantics. + ## 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. diff --git a/docs/api/scripts/check-openapi.ts b/docs/api/scripts/check-openapi.ts index 638f35ab7..3118a7960 100644 --- a/docs/api/scripts/check-openapi.ts +++ b/docs/api/scripts/check-openapi.ts @@ -4,6 +4,8 @@ const OPENAPI_FILE = "docs/api/openapi/vortex.openapi.json"; const MANIFEST_FILE = "docs/api/apidog/page-manifest.json"; const REQUIRED_PATHS = [ + "/v1/api-credentials", + "/v1/api-credentials/{credentialId}", "/v1/brla/createSubaccount", "/v1/brla/getKycStatus", "/v1/brla/getSelfieLivenessUrl", @@ -18,6 +20,7 @@ const REQUIRED_PATHS = [ "/v1/quotes/{id}", "/v1/ramp/history", "/v1/ramp/history/{walletAddress}", + "/v1/ramp-info", "/v1/ramp/register", "/v1/ramp/start", "/v1/ramp/update", diff --git a/docs/architecture-identity-model.md b/docs/architecture-identity-model.md new file mode 100644 index 000000000..8aad0ff06 --- /dev/null +++ b/docs/architecture-identity-model.md @@ -0,0 +1,123 @@ +# Identity, Customer, and Partner Model + +Status: current architecture. Last reconciled with migrations 038–054 and the API models +on 2026-07-31. + +This document explains the implemented identity model across authentication, compliance +customers, provider accounts, partner pricing, and recipients. Security invariants remain +owned by [`docs/security-spec/`](security-spec/README.md). + +## Design principles + +- A login profile is not a legal or compliance identity. +- Every provider account and KYC/KYB case belongs to one customer entity. +- Partner identity is separate from per-direction and per-currency pricing. +- Ramp registration operates for an effective user; provider identity is resolved by the + server and is never selected freely by request data. +- Reusable payout details stay with the provider. Vortex stores provider references and + masked labels, not raw bank-account data. + +## Core model + +```mermaid +erDiagram + profiles ||--o{ customer_entities : owns + profiles ||--o{ api_keys : may_own + customer_entities ||--o{ provider_customers : owns + customer_entities ||--o{ kyc_cases : verifies + provider_customers ||--o{ kyc_cases : has + partners ||--o{ partner_pricing_configs : prices + partners ||--o{ api_keys : attributes + customer_entities ||--o{ recipient_invitations : sends + customer_entities ||--o{ sender_recipients : participates + sender_recipients ||--o{ recipient_payout_references : uses +``` + +### Profiles and customer entities + +`profiles` is the Supabase-linked login identity. Email OTP authentication yields a +Supabase user ID, which is also the profile ID used by the API. + +`customer_entities` represents the legal/compliance customer. A profile may own an +individual and a business entity, while `profiles.active_customer_entity_id` records the +dashboard's selected sender identity. Selection is ownership-checked and currently +immutable after it is set. Compliance records may outlive a deleted profile because the +profile foreign key is nullable. + +### Provider customers and verification cases + +`provider_customers` is the durable account at Avenia, Alfredpay, Mykobo, or Monerium. It +belongs to exactly one customer entity and stores provider identifiers, corridor data, +customer type, normalized verification status, and only the provider-specific identity +fields required by runtime behavior. + +`kyc_cases` records KYC/KYB attempts separately from the provider account. Both tables use +the normalized lifecycle `started`, `pending`, `in_review`, `approved`, or `rejected`, +while `status_external` preserves a provider's original value when one exists. + +Legacy placement caveat: the migration 040 backfill attached pre-cutover provider rows to +the profile's 038-backfilled *individual* entity — including business-typed rows. The +row's `customer_type` is therefore authoritative for type-scoped lookups; the owning +entity's `type` is not. Typed provider lookups and ownership checks scope by profile, and +new alfredpay rows co-locate with a profile's existing rows of the same `customer_type`. + +Avenia is the one current exception to the general preference against retaining raw tax +references: `provider_customers.tax_reference` remains a runtime join key for in-flight +ramp state. Its SHA-256 value backs lookup and uniqueness; masked display is derived at +read time rather than stored as a second copy. + +### Partners, pricing, and API keys + +`partners` contains one commercial identity per unique partner name. +`partner_pricing_configs` contains the BUY/SELL pricing rows, optionally scoped to a fiat +currency; a currency-specific row takes precedence over the wildcard row. + +API keys have two independent axes: + +- `partner_id` supplies commercial attribution and partner pricing; +- `user_id` identifies the profile the key may act for. + +Public `pk_*` keys are stored as public values and are suitable for attribution. Secret +`sk_*` keys are stored as hashes and may authenticate requests. A partner-only key may +quote but cannot register a ramp for an arbitrary customer; registration requires either +a Supabase user or a secret key linked to one user. See +[`ADR 0001`](adr-0001-user-gated-ramp-registration.md). + +### Recipients + +`recipient_invitations` contains a token-bound invitation from a sender entity. +`sender_recipients` is the accepted sender-to-recipient relationship, scoped per rail. +`recipient_payout_references` contains the provider-side payout instrument ID, masked +label, and verification status. + +The detailed redemption, token-retention, authorization, and payability rules are +normative in +[`security-spec/03-ramp-engine/recipient-transfers.md`](security-spec/03-ramp-engine/recipient-transfers.md). +Current product behavior and acknowledged gaps are in +[`product-dashboard.md`](product-dashboard.md). + +## Authentication and ownership flow + +1. `requirePartnerOrUserAuth()` accepts a valid secret API key or Supabase bearer token. +2. `getEffectiveUserId()` prefers the Supabase user and otherwise uses the user linked to + the validated secret key. +3. Ownership middleware scopes quotes, ramps, provider accounts, recipients, and history + to that effective user and their customer entities. +4. At ramp registration, the server resolves the provider account for the effective user. + Client-supplied provider identifiers are either ignored or accepted only when they + match the server-derived identity. + +Quotes remain available before login where the public API permits rate discovery. An +authenticated user may claim an anonymous quote at registration; an already user-owned +quote cannot be claimed by another user. + +## Implementation map + +- Sequelize models: `apps/api/src/models/{user,customerEntity,providerCustomer,kycCase,partner,partnerPricingConfig,apiKey,recipientInvitation,senderRecipient,recipientPayoutReference}.model.ts` +- Principal resolution: `apps/api/src/api/middlewares/{dualAuth,effectiveUser,ownershipAuth}.ts` +- Provider ownership resolution: `apps/api/src/api/services/avenia-account.ts` and provider controllers/services +- Schema history: `apps/api/src/database/migrations/038-*` onward +- Security details: `docs/security-spec/01-auth/`, `03-ramp-engine/recipient-transfers.md`, and the provider specs under `05-integrations/` + +Update this document only when the cross-module shape changes. Provider-specific flows, +security exceptions, and field-level audit checklists belong in the security spec. diff --git a/docs/architecture/api-key-authentication-complete.md b/docs/architecture/api-key-authentication-complete.md deleted file mode 100644 index 638d43be1..000000000 --- a/docs/architecture/api-key-authentication-complete.md +++ /dev/null @@ -1,815 +0,0 @@ -# API Key Authentication Architecture - Dual-Key System - -**Version:** 2.0 -**Last Updated:** 2025-10-29 -**Status:** Implementation Complete - -## Table of Contents -1. [Overview](#overview) -2. [Dual-Key Architecture](#dual-key-architecture) -3. [Database Schema](#database-schema) -4. [API Design](#api-design) -5. [Middleware Architecture](#middleware-architecture) -6. [Authentication Flows](#authentication-flows) -7. [Security Considerations](#security-considerations) -8. [Admin Interface](#admin-interface) -9. [Implementation Summary](#implementation-summary) -10. [Usage Examples](#usage-examples) - ---- - -## Overview - -Pendulum Pay uses a **dual-key authentication system** that balances security with flexibility: - -- **Public Keys (pk_*)**: Safe for client-side use, tracking, and widgets -- **Secret Keys (sk_*)**: Server-only authentication for secure operations - -### Problems Solved - -1. **Security**: Prevents unauthorized use of partner discounts -2. **Tracking**: Enables quote attribution without exposing secrets -3. **Flexibility**: Supports both client-side and server-to-server integrations -4. **Widget Support**: Public keys can be embedded in iframes/JavaScript -5. **Analytics**: Track which integrations generate quotes - -### Key Features - -- ✅ Two-tier key system (public + secret) -- ✅ Partner name-based associations (works for BUY & SELL configs) -- ✅ Environment-aware (test vs live prefixes) -- ✅ Admin authentication with Bearer tokens -- ✅ Quote tracking with public keys -- ✅ Secure authentication with secret keys -- ✅ Backward compatible -- ✅ Industry standard pattern (like Stripe, PayPal) - ---- - -## Dual-Key Architecture - -### Key Types - -#### Public Keys -- **Format**: `pk_live_[32_chars]` or `pk_test_[32_chars]` -- **Example**: `pk_live_xxx` -- **Storage**: Plaintext in `key_value` field -- **Security**: Can be safely exposed (no secret data) -- **Usage**: - - Request body or query parameters - - Client-side JavaScript - - Widget URLs - - Public integrations -- **Validation**: Simple database lookup -- **Purpose**: Track quote origins, apply discounts - -#### Secret Keys -- **Format**: `sk_live_[32_chars]` or `sk_test_[32_chars]` -- **Example**: `sk_live_xxx` -- **Storage**: Bcrypt hash in `key_hash` field -- **Security**: Must never be exposed -- **Usage**: - - X-API-Key header only - - Server-to-server communication - - Backend integrations -- **Validation**: Bcrypt hash comparison -- **Purpose**: Authenticate partner identity, enforce security - -### Environment Separation - -**Production (SANDBOX_ENABLED=false):** -- Public: `pk_live_*` -- Secret: `sk_live_*` - -**Sandbox (SANDBOX_ENABLED=true):** -- Public: `pk_test_*` -- Secret: `sk_test_*` - ---- - -## Database Schema - -### Table: `api_keys` - -```sql -CREATE TABLE api_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - partner_name VARCHAR(100) NOT NULL, -- Partner name (not ID) - key_type ENUM('public', 'secret') NOT NULL DEFAULT 'secret', - key_hash VARCHAR(255), -- Bcrypt hash (secret keys only) - key_value VARCHAR(255), -- Plaintext (public keys only) - key_prefix VARCHAR(16) NOT NULL, -- First 10 chars for lookup - name VARCHAR(100), -- Optional descriptive name - last_used_at TIMESTAMP, -- Track usage - expires_at TIMESTAMP, -- Optional expiration - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - - CONSTRAINT unique_key_hash UNIQUE (key_hash), - CONSTRAINT unique_key_value UNIQUE (key_value), - INDEX idx_api_keys_partner_name (partner_name), - INDEX idx_api_keys_key_type (key_type), - INDEX idx_api_keys_key_prefix (key_prefix), - INDEX idx_api_keys_key_value (key_value), - INDEX idx_api_keys_active (is_active), - INDEX idx_api_keys_active_prefix_type (is_active, key_prefix, key_type) -); -``` - -### Table: `quote_tickets` (Updated) - -```sql -ALTER TABLE quote_tickets ADD COLUMN api_key VARCHAR(255); -CREATE INDEX idx_quote_tickets_api_key ON quote_tickets(api_key); -``` - -### Field Explanations - -#### api_keys table: -- **`partner_name`**: Partner identifier (matches Partner.name field) - - Allows one key to work for both BUY and SELL configurations - - No foreign key constraint (manual lookup) - -- **`key_type`**: Distinguishes public from secret keys - - `'public'`: pk_* keys, stored in plaintext - - `'secret'`: sk_* keys, bcrypt hashed - -- **`key_hash`**: Bcrypt hash for secret keys - - NULL for public keys - - 10 salt rounds for security - -- **`key_value`**: Plaintext for public keys - - NULL for secret keys - - Indexed for fast lookup - -- **`key_prefix`**: First 10 characters - - Used for quick database lookups - - Safe to display/log - -#### quote_tickets table: -- **`api_key`**: Stores the public key used - - Enables tracking and analytics - - Links quotes to partner integrations - - Optional (nullable) - ---- - -## API Design - -### Authentication Methods - -#### 1. Public Key (Client-Side Safe) -```http -POST /v1/quotes -Content-Type: application/json - -{ - "apiKey": "pk_live_abc123...", - "rampType": "SELL", - ... -} -``` - -#### 2. Secret Key (Server-Only) -```http -POST /v1/quotes -X-API-Key: sk_live_xyz789... -Content-Type: application/json - -{ - "partnerId": "PartnerName", - ... -} -``` - -#### 3. Both Keys Together -```http -POST /v1/quotes -X-API-Key: sk_live_xyz789... -Content-Type: application/json - -{ - "apiKey": "pk_live_abc123...", - "partnerId": "PartnerName", - ... -} -``` - -### Admin Endpoints - -#### Create API Key Pair -```http -POST /v1/admin/partners/:partnerName/api-keys -Authorization: Bearer -Content-Type: application/json - -{ - "name": "Production Keys", - "expiresAt": "2025-12-31T23:59:59Z" // optional -} - -Response (201): -{ - "partnerName": "TestPartner", - "partnerCount": 2, // Number of partner records (BUY + SELL) - "publicKey": { - "id": "uuid-1", - "key": "pk_live_abc123...", // Full key (can show anytime) - "keyPrefix": "pk_live_ab", - "name": "Production Keys (Public)", - "type": "public" - }, - "secretKey": { - "id": "uuid-2", - "key": "sk_live_xyz789...", // Shown ONLY ONCE! - "keyPrefix": "sk_live_xy", - "name": "Production Keys (Secret)", - "type": "secret" - }, - "expiresAt": "2025-12-31T23:59:59Z", - "isActive": true, - "createdAt": "2025-10-29T16:00:00Z" -} -``` - -#### List API Keys -```http -GET /v1/admin/partners/:partnerName/api-keys -Authorization: Bearer - -Response (200): -{ - "partnerName": "TestPartner", - "partnerCount": 2, - "apiKeys": [ - { - "id": "uuid-1", - "type": "public", - "key": "pk_live_abc123...", // Full key shown - "keyPrefix": "pk_live_ab", - "name": "Production Keys (Public)", - "lastUsedAt": "2025-10-29T16:00:00Z", - "expiresAt": null, - "isActive": true - }, - { - "id": "uuid-2", - "type": "secret", - "keyPrefix": "sk_live_xy", // Only prefix shown - "name": "Production Keys (Secret)", - "lastUsedAt": "2025-10-29T16:05:00Z", - "expiresAt": null, - "isActive": true - } - ] -} -``` - -#### Revoke API Key -```http -DELETE /v1/admin/partners/:partnerName/api-keys/:keyId -Authorization: Bearer - -Response (204 No Content) -``` - -### Error Responses - -#### 400 Bad Request - Invalid Public Key Format -```json -{ - "error": { - "code": "INVALID_API_KEY_FORMAT", - "message": "Invalid API key format. Expected: pk_live_* or pk_test_*", - "status": 400 - } -} -``` - -#### 401 Unauthorized - Invalid Public Key -```json -{ - "error": { - "code": "INVALID_PUBLIC_KEY", - "message": "The provided public API key is invalid, expired, or inactive", - "status": 401 - } -} -``` - -#### 401 Unauthorized - Invalid Secret Key -```json -{ - "error": { - "code": "INVALID_SECRET_KEY", - "message": "X-API-Key header must contain a secret key (sk_live_* or sk_test_*)", - "status": 401 - } -} -``` - -#### 403 Forbidden - Authentication Required -```json -{ - "error": { - "code": "AUTHENTICATION_REQUIRED", - "message": "Authentication is required when partnerId is specified", - "status": 403 - } -} -``` - -#### 403 Forbidden - Partner Mismatch -```json -{ - "error": { - "code": "PARTNER_MISMATCH", - "message": "The authenticated partner name does not match the requested partner's name", - "status": 403, - "details": { - "authenticatedPartnerName": "PartnerA", - "requestedPartnerName": "PartnerB" - } - } -} -``` - ---- - -## Middleware Architecture - -### Middleware Stack - -```typescript -// Quote creation route -router.post("/", - validateCreateQuoteInput, // 1. Validate request structure - validatePublicKey(), // 2. Validate public key if provided (optional) - apiKeyAuth({ required: false}),// 3. Validate secret key if provided (optional) - enforcePartnerAuth(), // 4. Enforce secret key if partnerId present - createQuote // 5. Create quote -); -``` - -### Middleware Components - -#### 1. validatePublicKey() -```typescript -// apps/api/src/api/middlewares/publicKeyAuth.ts - -/** - * Validates public API keys (pk_*) from body or query params - * - Optional validation (continues if no key provided) - * - Validates key exists and is active - * - Attaches validated info to req.validatedPublicKey - * - Used for tracking and discount application - */ -export function validatePublicKey(); -``` - -**Extended Request:** -```typescript -interface Request { - validatedPublicKey?: { - apiKey: string; - partnerName: string; - }; -} -``` - -#### 2. apiKeyAuth() -```typescript -// apps/api/src/api/middlewares/apiKeyAuth.ts - -/** - * Validates secret API keys (sk_*) from X-API-Key header - * - Optional by default (required: false) - * - Validates key format and hash - * - Looks up partner by name - * - Attaches authenticated info to req.authenticatedPartner - * - Used for secure authentication - */ -export function apiKeyAuth(options?: { - required?: boolean; - validatePartnerMatch?: boolean; -}); -``` - -**Extended Request:** -```typescript -interface Request { - authenticatedPartner?: { - id: string; - name: string; - discount: number; - }; -} -``` - -#### 3. enforcePartnerAuth() -```typescript -/** - * Enforces secret key authentication when partnerId is present - * - Checks if partnerId is provided in payload - * - Requires req.authenticatedPartner to be set - * - Validates partner names match (supports both UUID and name format) - * - Returns 403 if validation fails - */ -export function enforcePartnerAuth(); -``` - -### Helper Functions - -```typescript -// apps/api/src/api/middlewares/apiKeyAuth.helpers.ts - -// Key generation -generateApiKey(keyType: 'public' | 'secret', environment: 'live' | 'test'): string - -// Validation -isValidApiKeyFormat(key: string): boolean -isValidSecretKeyFormat(key: string): boolean -getKeyType(key: string): 'public' | 'secret' | null -getKeyPrefix(key: string): string - -// Public key validation -validatePublicApiKey(apiKey: string): Promise - -// Secret key validation -validateSecretApiKey(apiKey: string): Promise - -// Hashing -hashApiKey(key: string): Promise -``` - ---- - -## Authentication Flows - -### Flow 1: Public Key Only (Client-Side) - -```mermaid -sequenceDiagram - participant Client - participant PublicKeyMW as validatePublicKey() - participant QuoteService - participant Database - - Client->>PublicKeyMW: POST /v1/quotes
{apiKey: "pk_live_..."} - PublicKeyMW->>Database: Find public key - Database-->>PublicKeyMW: Key record - PublicKeyMW->>Database: Find partner by name - Database-->>PublicKeyMW: Partner record - PublicKeyMW->>PublicKeyMW: Attach to req.validatedPublicKey - PublicKeyMW->>QuoteService: Continue - QuoteService->>Database: Create quote with apiKey - Database-->>QuoteService: Quote created - QuoteService-->>Client: Quote with discount -``` - -**Use Case**: Widget embeds, client-side integrations - -### Flow 2: Secret Key Only (Server-to-Server) - -```mermaid -sequenceDiagram - participant Server - participant SecretKeyMW as apiKeyAuth() - participant EnforceMW as enforcePartnerAuth() - participant QuoteService - participant Database - - Server->>SecretKeyMW: POST /v1/quotes
X-API-Key: sk_live_...
{partnerId: "Partner"} - SecretKeyMW->>Database: Find secret key by prefix - Database-->>SecretKeyMW: Key records - SecretKeyMW->>SecretKeyMW: Bcrypt compare - SecretKeyMW->>Database: Find partner by name - Database-->>SecretKeyMW: Partner record - SecretKeyMW->>SecretKeyMW: Attach to req.authenticatedPartner - SecretKeyMW->>EnforceMW: Continue - EnforceMW->>Database: Lookup partner by partnerId - Database-->>EnforceMW: Partner record - EnforceMW->>EnforceMW: Compare names - EnforceMW->>QuoteService: Continue - QuoteService->>Database: Create quote - Database-->>QuoteService: Quote created - QuoteService-->>Server: Quote with discount -``` - -**Use Case**: Backend integrations, secure API calls - -### Flow 3: Both Keys (Full Tracking + Auth) - -```mermaid -sequenceDiagram - participant Server - participant PublicKeyMW - participant SecretKeyMW - participant EnforceMW - participant QuoteService - - Server->>PublicKeyMW: POST with apiKey + X-API-Key - PublicKeyMW->>PublicKeyMW: Validate public key - PublicKeyMW->>SecretKeyMW: Continue - SecretKeyMW->>SecretKeyMW: Validate secret key - SecretKeyMW->>EnforceMW: Continue - EnforceMW->>EnforceMW: Validate partner match - EnforceMW->>QuoteService: Continue - QuoteService->>QuoteService: Create with tracking + auth - QuoteService-->>Server: Quote with discount -``` - -**Use Case**: Full-featured partner integrations - ---- - -## Security Considerations - -### Public Key Security - -**Design Principles:** -- **Expected to be public**: Can appear in logs, URLs, browser history -- **No authentication value**: Only validates existence -- **Tracking only**: Identifies which integration created the quote -- **Revocable**: Can be deactivated if abused - -**Why It's Safe:** -- Partners trust their own public key (they created the integration) -- Discounts only apply if key is valid -- No sensitive operations without secret key -- Full audit trail via `quote_tickets.api_key` - -**Attack Mitigation:** -- Rate limiting per public key -- Monitoring for unusual patterns -- Soft deletion preserves audit trail -- Expiration dates for time-limited access - -### Secret Key Security - -**Design Principles:** -- **Never expose**: Server-only, never in client code -- **Bcrypt protected**: 10 salt rounds, constant-time comparison -- **One-time display**: Shown only on creation -- **Prefix-based lookup**: Reduces bcrypt operations - -**Storage:** -- ❌ Never store plaintext -- ✅ Only bcrypt hash stored -- ✅ Prefix for quick lookup -- ✅ Salt rounds: 10 (security/performance balance) - -**Transmission:** -- ✅ HTTPS only (TLS 1.2+) -- ✅ Header only (never in URL/body) -- ✅ Never logged -- ✅ Server-to-server only - -**Validation:** -- ✅ Constant-time bcrypt comparison -- ✅ Expiration checking -- ✅ Active status checking -- ✅ Partner name matching - -### Admin Authentication - -**Bearer Token Pattern:** -```http -Authorization: Bearer -``` - -**Implementation:** -- Environment variable: `ADMIN_SECRET` -- Constant-time comparison -- Protects all admin endpoints -- Separate from partner keys - -**Security:** -- Generate: `openssl rand -base64 32` -- Store securely (secrets manager, env vars) -- Rotate periodically -- Different per environment - ---- - -## Admin Interface - -### Admin Authentication - -All admin endpoints require Bearer token: -```http -Authorization: Bearer -``` - -Set via environment variable: -```bash -export ADMIN_SECRET="your-secure-random-secret" -``` - -### Partner Name-Based Management - -Admin endpoints use partner **name** (not UUID): -``` -/v1/admin/partners/:partnerName/api-keys -``` - -**Why names instead of IDs:** -- One key pair works for both BUY and SELL partner records -- More intuitive for admins -- Simpler URL structure - ---- - -## Implementation Summary - -### Completed Components - -**Phase 1: Foundation** -- ✅ Database migrations (017, 018) -- ✅ ApiKey model with dual-key support -- ✅ QuoteTicket model with apiKey field -- ✅ Partner name-based associations -- ✅ bcrypt dependency - -**Phase 2: Authentication Layer** -- ✅ Public key validation middleware -- ✅ Secret key authentication middleware -- ✅ Helper functions for both key types -- ✅ Type definitions and extensions - -**Phase 3: Admin Interface** -- ✅ Admin authentication (Bearer token) -- ✅ Create key pair endpoint -- ✅ List keys endpoint -- ✅ Revoke key endpoint -- ✅ Partner name-based routing - -**Phase 4: Quote Integration** -- ✅ Public key validation in quote creation -- ✅ Secret key enforcement for partnerId -- ✅ apiKey storage on quotes -- ✅ Partner discount application -- ✅ Updated shared types - -### Files Created - -1. `apps/api/src/database/migrations/017-create-api-keys-table.ts` -2. `apps/api/src/database/migrations/018-add-api-key-to-quote-tickets.ts` -3. `apps/api/src/models/apiKey.model.ts` -4. `apps/api/src/api/middlewares/apiKeyAuth.ts` -5. `apps/api/src/api/middlewares/apiKeyAuth.helpers.ts` -6. `apps/api/src/api/middlewares/publicKeyAuth.ts` -7. `apps/api/src/api/middlewares/adminAuth.ts` -8. `apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts` -9. `apps/api/src/api/routes/v1/admin/partner-api-keys.route.ts` - -### Files Modified - -1. `apps/api/package.json` - Added bcrypt dependency -2. `apps/api/.env.example` - Documented ADMIN_SECRET and SANDBOX_ENABLED -3. `apps/api/src/config/vars.ts` - Added adminSecret config -4. `apps/api/src/models/index.ts` - Updated associations -5. `apps/api/src/models/quoteTicket.model.ts` - Added apiKey field -6. `apps/api/src/api/routes/v1/index.ts` - Registered admin routes -7. `apps/api/src/api/routes/v1/quote.route.ts` - Added middleware -8. `apps/api/src/api/controllers/quote.controller.ts` - Public key handling -9. `apps/api/src/api/services/quote/index.ts` - Partner name lookup -10. `apps/api/src/api/services/quote/engines/finalize/index.ts` - Store apiKey -11. `packages/shared/src/endpoints/quote.endpoints.ts` - Added apiKey field - ---- - -## Usage Examples - -### Scenario 1: Widget Integration (Public Key) - -**Setup:** -```html - -``` - -**Result:** -- ✅ Quote created and tracked to partner -- ✅ Partner discount applied -- ✅ Public key safely visible in browser -- ✅ Can be in iframe, widget URLs, etc. - -### Scenario 2: Backend Integration (Secret Key) - -**Setup:** -```javascript -const SECRET_KEY = process.env.SECRET_KEY; // Never expose! - -async function createQuote() { - const response = await fetch('https://api.pendulumpay.com/v1/quotes', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': SECRET_KEY - }, - body: JSON.stringify({ - partnerId: 'MyPartner', - rampType: 'SELL', - ... - }) - }); - - return response.json(); -} -``` - -**Result:** -- ✅ Authenticated via secret key -- ✅ Partner discount applied -- ✅ Secure server-to-server -- ✅ Secret never exposed - -### Scenario 3: Full Integration (Both Keys) - -**Setup:** -```javascript -const PUBLIC_KEY = 'pk_live_abc123...'; // Client-side -const SECRET_KEY = process.env.SECRET_KEY; // Server-side - -// Server endpoint -async function createQuote(req, res) { - const response = await fetch('https://api.pendulumpay.com/v1/quotes', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': SECRET_KEY - }, - body: JSON.stringify({ - apiKey: PUBLIC_KEY, - partnerId: 'MyPartner', - ...req.body - }) - }); - - res.json(await response.json()); -} -``` - -**Result:** -- ✅ Full tracking via public key -- ✅ Secure auth via secret key -- ✅ Complete audit trail -- ✅ Partner discount applied - ---- - -## Future Enhancements - -### Key Rotation -- Automatic rotation schedule -- Grace period for old keys -- Notification system - -### Analytics Dashboard -- Usage per public key -- Success rates -- Geographic distribution -- Top endpoints - -### Advanced Features -- IP whitelisting per key -- Rate limiting per key type -- Scoped permissions -- HMAC request signing - ---- - -## Conclusion - -The dual-key system provides: - -1. **Security**: Secret keys protect high-value operations -2. **Flexibility**: Public keys enable client-side usage -3. **Tracking**: Full audit trail of quote origins -4. **Industry Standard**: Follows Stripe/PayPal patterns -5. **Scalability**: Supports diverse integration patterns - -Partners can choose: -- Public key only (widgets, tracking) -- Secret key only (backend, security) -- Both together (full features) - -The architecture balances security, usability, and business requirements. diff --git a/docs/architecture/current-fee-derivation.md b/docs/architecture/current-fee-derivation.md deleted file mode 100644 index 447e9992e..000000000 --- a/docs/architecture/current-fee-derivation.md +++ /dev/null @@ -1,103 +0,0 @@ -# Current Fee Derivation Logic in Vortex Quote Calculation - -This document explains the current fee calculation process implemented in the Vortex quote system, focusing on how fees impact the final quote amount. - -## Overview - -Fees currently impact the final quote amount via helper functions applied during the `calculateOutputAmount` process in `api/src/api/services/ramp/quote.service.ts`. While the `calculateFeeComponents` function is called and its results are stored in the database and API response, the **actual** fees affecting the transaction amounts are calculated separately through the helper functions `calculateTotalReceiveOnramp` and `calculateTotalReceive`. - -## Fee Source - -Fee parameters are sourced from Fiat token details within the `shared` module via the `getAnyFiatTokenDetails` function. These parameters include: - -- `onrampFeesBasisPoints`: Percentage-based fee for on-ramp transactions (in basis points, where 100 = 1%) -- `onrampFeesFixedComponent`: Fixed fee amount for on-ramp transactions -- `offrampFeesBasisPoints`: Percentage-based fee for off-ramp transactions (in basis points) -- `offrampFeesFixedComponent`: Fixed fee amount for off-ramp transactions - -These parameters are defined in the token configuration files: -- `shared/src/tokens/moonbeam/config.ts` for BRL -- `shared/src/tokens/stellar/config.ts` for ARS and EURC - -## On-Ramp Fee Application - -For on-ramp transactions (fiat to crypto), the `calculateTotalReceiveOnramp` function is used when the input currency is a fiat currency: - -```javascript -export function calculateTotalReceiveOnramp(fromAmount: Big, inputCurrency: RampCurrency): string { - if (isFiatToken(inputCurrency)) { - const inputTokenDetails = getAnyFiatTokenDetails(inputCurrency); - const feeBasisPoints = inputTokenDetails.onrampFeesBasisPoints; - const fixedFees = new Big( - inputTokenDetails.onrampFeesFixedComponent ? inputTokenDetails.onrampFeesFixedComponent : 0, - ); - const fees = fromAmount.mul(feeBasisPoints).div(10000).add(fixedFees).round(6, 0); - const totalReceiveRaw = fromAmount.minus(fees); - - if (totalReceiveRaw.gt(0)) { - return totalReceiveRaw.toFixed(6, 0); - } - return '0'; - } -} -``` - -Key points: -- Fees are calculated based on the original `inputAmount` -- Both percentage-based fees (basis points) and fixed component fees are applied -- Fees are deducted from the input amount **before** the core swap logic (`getTokenOutAmount`) -- The resulting `inputAmountAfterFees` is what gets processed through the swap - -## Off-Ramp Fee Application - -For off-ramp transactions (crypto to fiat), the `calculateTotalReceive` function is used when the output currency is a fiat currency: - -```javascript -export function calculateTotalReceive(toAmount: Big, outputCurrency: RampCurrency): string { - if (isFiatToken(outputCurrency)) { - const outputTokenDetails = getAnyFiatTokenDetails(outputCurrency); - const feeBasisPoints = outputTokenDetails.offrampFeesBasisPoints; - const fixedFees = new Big( - outputTokenDetails.offrampFeesFixedComponent ? outputTokenDetails.offrampFeesFixedComponent : 0, - ); - const fees = toAmount.mul(feeBasisPoints).div(10000).add(fixedFees).round(2, 1); - const totalReceiveRaw = toAmount.minus(fees); - - if (totalReceiveRaw.gt(0)) { - return totalReceiveRaw.toFixed(2, 0); - } - return '0'; - } -} -``` - -Key points: -- Fees are calculated based on the amount **after** the core swap logic (`getTokenOutAmount`) -- Both percentage-based fees (basis points) and fixed component fees are applied -- Fees are deducted from the swap result to determine the final amount the user receives - -## "Effective Fees" Display - -The `calculateOutputAmount` function calculates and returns an `effectiveFees` value that represents the total fee impact: - -```javascript -const effectiveFeesOfframp = amountOut.preciseQuotedAmountOut.preciseBigDecimal - .minus(outputAmountAfterFees) - .toFixed(2, 0); - -const effectiveFeesOnrampBrl = new Big(inputAmount).minus(inputAmountAfterFees); -const effectiveFeesOnramp = effectiveFeesOnrampBrl.mul(amountOut.effectiveExchangeRate).toFixed(6, 0); -const effectiveFees = rampType === 'off' ? effectiveFeesOfframp : effectiveFeesOnramp; -``` - -This calculation differs depending on the ramp type: -- For off-ramp: It's the difference between the raw swap output and the final amount after fees -- For on-ramp: It's the input amount fees converted to the output currency using the effective exchange rate - -## Discrepancy with `calculateFeeComponents` - -The `calculateFeeComponents` function is called within `createQuote` and its results are stored in the database and API response. However, **this logic does not currently determine the actual fees deducted from the transaction amount**. - -The fee components calculated by this function (network fee, processing fee, partner markup fee) are based on the `FeeConfiguration` database table and partner settings, but they are only used for display and record-keeping purposes. The actual fee deduction that impacts the user's final amount is determined by the `calculateTotalReceiveOnramp` and `calculateTotalReceive` functions using the fee parameters from the token configuration. - -This discrepancy suggests a partially implemented refactor or an intended future state where the fee configuration from the database would drive the actual fee calculations. diff --git a/docs/architecture/fee-enhancement-plan.md b/docs/architecture/fee-enhancement-plan.md deleted file mode 100644 index 698f5f1e0..000000000 --- a/docs/architecture/fee-enhancement-plan.md +++ /dev/null @@ -1,125 +0,0 @@ -# Plan: Enhanced Fee Structure - -**Date:** 2025-04-28 - -**Status:** Proposed - -## 1. Overview - -This document outlines the architectural changes required to enhance the fee structure within Pendulum Pay (Vortex). The current single `fee` field on `QuoteTicket` will be replaced with a granular breakdown including network fees, processing fees (Vortex Foundation + Anchor), and optional partner markups. Fees will be standardized to USD. Partner identification will be handled by passing a `partner_id` (UUID) in the quote request. - -## 2. Database Schema Changes - -### 2.1. `quote_tickets` Table (`api/src/models/quoteTicket.model.ts`) - -* **Remove:** - * `fee: DECIMAL(38, 18)` -* **Add:** - * `network_fee: DECIMAL(38, 18)` - Estimated/actual cost of on-chain transactions. - * `processing_fee: DECIMAL(38, 18)` - Sum of Vortex Foundation + Anchor/Provider fees. - * `partner_markup_fee: DECIMAL(38, 18)` - Optional fee set by a partner (defaults to 0). - * `total_fee: DECIMAL(38, 18)` - Calculated sum: `network_fee + processing_fee + partner_markup_fee`. - * `fee_currency: STRING(8)` - Currency code for all fee fields (Default 'USD'). - * `partner_id: UUID` - Nullable Foreign Key referencing `partners(id)`. - -### 2.2. New `partners` Table - -* **Purpose:** Stores information about partners who can apply markups. -* **Columns:** - * `id: UUID` (PK) - * `name: STRING` (Unique internal identifier, e.g., "PartnerX") - * `display_name: STRING` (User-facing name) - * `logo_url: STRING` (Optional) - * `markup_type: ENUM('absolute', 'relative', 'none')` (Default 'none') - * `markup_value: DECIMAL(10, 4)` (Amount for 'absolute' or percentage for 'relative') - * `markup_currency: STRING(8)` (Required if `markup_type` is 'absolute', should be 'USD') - * `payout_address: STRING` (Blockchain address for receiving collected fees) - * `is_active: BOOLEAN` (Default `true`) - * `created_at: TIMESTAMPTZ` (Default `NOW()`) - * `updated_at: TIMESTAMPTZ` (Default `NOW()`) - -### 2.3. New `fee_configurations` Table - -* **Purpose:** Stores system-wide base fees and estimates. -* **Columns:** - * `id: UUID` (PK) - * `fee_type: ENUM('vortex_foundation', 'anchor_base', 'network_estimate')` - * `identifier: STRING` (Optional context, e.g., network name 'polygon', anchor name 'moonbeam_brla', 'default') - * `value_type: ENUM('absolute', 'relative')` - * `value: DECIMAL(10, 4)` - * `currency: STRING(8)` (Should be 'USD') - * `is_active: BOOLEAN` (Default `true`) - * `created_at: TIMESTAMPTZ` (Default `NOW()`) - * `updated_at: TIMESTAMPTZ` (Default `NOW()`) -* **Initial Data:** - * Add entry for static 1 USD network fee: `{ fee_type: 'network_estimate', identifier: 'default', value_type: 'absolute', value: 1.00, currency: 'USD' }`. - * Add entries for the Vortex Foundation fee (e.g., relative 0.1%). - * Add entries for base anchor fees per relevant integration (e.g., absolute fee for BRLA anchor). - -## 3. Backend Logic (`api/src/api/services/ramp/quote.service.ts`) - -* **Partner Identification:** - * Expect an optional `partner_id` (UUID) in the `/v1/ramp/quotes` request payload (body or query parameter). - * If `partner_id` is provided, validate it against the `partners` table. Check if the partner `is_active`. - * If valid and active, store the `partner_id`; otherwise, treat as null. -* **Fee Calculation (Standardized to USD):** - 1. **Partner Markup Fee:** If a valid `partner_id` was identified, fetch the partner's `markup_type` and `markup_value`. Calculate the fee based on the quote amount (if relative) or use the absolute value. Ensure the result is in USD. Default to 0 if no valid partner. - 2. **Processing Fee:** Fetch the active `vortex_foundation` fee configuration. Fetch the active `anchor_base` fee configuration relevant to the current quote context (e.g., based on the specific anchor/provider involved). Sum these values (ensure both are in USD). - 3. **Network Fee:** Fetch the active `network_estimate` fee configuration with `identifier: 'default'`. Use its value (1.00 USD). - 4. **Total Fee:** Sum `partner_markup_fee + processing_fee + network_fee`. -* **Save Quote:** Persist the calculated `network_fee`, `processing_fee`, `partner_markup_fee`, `total_fee`, `fee_currency: 'USD'`, and the validated `partner_id` (or null) to the `quote_tickets` table record. - -## 4. API & Documentation - -* **API DTOs (`shared/src/endpoints/quote.endpoints.ts`):** - * Modify the request DTO for `/v1/ramp/quotes` to accept an optional `partner_id: string` (UUID format). - * Modify the response DTO for `/v1/ramp/quotes` to return the fee breakdown: `network_fee: string`, `processing_fee: string`, `partner_markup_fee: string`, `total_fee: string`, `fee_currency: string`. -* **Memory Bank:** Update `decisionLog.md`, `activeContext.md`, and `systemPatterns.md` to reflect these decisions. - -## 5. Diagram: Fee Calculation Flow - -```mermaid -graph TD - subgraph API Layer - R[Incoming Request w/ partnerId?] --> A[QuoteService]; - end - - subgraph QuoteService - A --> B{Check for partnerId in Request}; - B -- partnerId exists --> VLD[Validate Partner ID in DB]; - VLD -- Valid --> C[Fetch Partner Config from DB]; - VLD -- Invalid/Not Found --> D[Markup = 0, partnerId = null]; - B -- No partnerId --> D; - C --> E[Calculate Partner Markup Fee (USD)]; - D --> F[Fetch Base Fees (Vortex, Anchor) from DB]; - E --> F; - F --> G[Fetch Static Network Fee (1 USD) from DB]; - G --> H[Calculate Total Fee (USD)]; - H --> J[Save QuoteTicket w/ Fee Breakdown (USD) & partnerId]; - end - - subgraph Database - K[(partners)]; - L[(fee_configurations)]; - N[(quote_tickets)]; - end - - VLD --> K; - C --> K; - F --> L; - G --> L; - J --> N; - - style K fill:#f9f,stroke:#333,stroke-width:2px - style L fill:#f9f,stroke:#333,stroke-width:2px - style N fill:#f9f,stroke:#333,stroke-width:2px -``` - -## 6. Next Steps - -* Implement database migrations for new tables and modifications. -* Update Sequelize models (`quoteTicket.model.ts`, create `partner.model.ts`, `feeConfiguration.model.ts`). -* Update `QuoteService` logic. -* Update API DTOs. -* Update Memory Bank files. -* (Handover to Code Mode) Implement frontend changes to pass `partner_id` and display fee breakdown. diff --git a/docs/architecture/maintenance-feature-design.md b/docs/architecture/maintenance-feature-design.md deleted file mode 100644 index 7b88b6338..000000000 --- a/docs/architecture/maintenance-feature-design.md +++ /dev/null @@ -1,127 +0,0 @@ -# Plan: 'Under Maintenance' Feature Design - -This document outlines the database schema and API endpoint design for the 'under maintenance' feature in the Pendulum Pay project. - -## 1. Database Schema - -A new table named `maintenance_schedules` will be created to store maintenance window configurations. - -**Table: `maintenance_schedules`** - -| Column Name | Data Type | Constraints & Description | -| :------------------ | :------------------ | :---------------------------------------------------------- | -| `id` | `UUID` | Primary Key, auto-generated (e.g., using `uuid-ossp`) | -| `title` | `VARCHAR(255)` | A short title for the maintenance window (e.g., "Database Upgrade Q2") | -| `start_datetime` | `TIMESTAMPTZ` | Not Null. The date and time when maintenance begins (UTC). | -| `end_datetime` | `TIMESTAMPTZ` | Not Null. The date and time when maintenance is scheduled to end (UTC). | -| `message_to_display`| `TEXT` | Not Null. The message that will be shown to users. | -| `is_active_config` | `BOOLEAN` | Not Null, Default: `false`. If `true`, this schedule is considered for activation. Allows pre-configuring schedules. | -| `notes` | `TEXT` | Optional. Internal notes for administrators. | -| `created_at` | `TIMESTAMPTZ` | Not Null, Default: `CURRENT_TIMESTAMP`. | -| `updated_at` | `TIMESTAMPTZ` | Not Null, Default: `CURRENT_TIMESTAMP`. | - -**Indexes:** -* An index on `(is_active_config, start_datetime, end_datetime)` would be beneficial for quickly querying active maintenance windows. -* An index on `is_active_config` alone might also be useful. - -**SQL-like Definition (PostgreSQL):** -```sql -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - -CREATE TABLE maintenance_schedules ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - title VARCHAR(255) NOT NULL, - start_datetime TIMESTAMPTZ NOT NULL, - end_datetime TIMESTAMPTZ NOT NULL, - message_to_display TEXT NOT NULL, - is_active_config BOOLEAN NOT NULL DEFAULT false, - notes TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_maintenance_schedules_active_period -ON maintenance_schedules (is_active_config, start_datetime, end_datetime); - --- Optional: Trigger to update updated_at timestamp -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER update_maintenance_schedules_updated_at -BEFORE UPDATE ON maintenance_schedules -FOR EACH ROW -EXECUTE FUNCTION update_updated_at_column(); -``` - -**Mermaid Diagram:** -```mermaid -erDiagram - maintenance_schedules { - UUID id PK - VARCHAR(255) title - TIMESTAMPTZ start_datetime - TIMESTAMPTZ end_datetime - TEXT message_to_display - BOOLEAN is_active_config - TEXT notes - TIMESTAMPTZ created_at - TIMESTAMPTZ updated_at - } -``` - -## 2. API Endpoint(s) - -One primary endpoint will be defined for the frontend to query the current maintenance status. - -**Endpoint: Get Maintenance Status** - -* **HTTP Method:** `GET` -* **URL:** `/api/v1/maintenance/status` -* **Request Parameters:** None -* **Authentication:** This endpoint should likely be public or require minimal authentication, as it needs to be accessible even when the main application parts are "down." - -* **Success Response (HTTP 200 OK):** - - * **If maintenance is active:** - ```json - { - "is_maintenance_active": true, - "maintenance_details": { - "title": "Scheduled System Update", - "start_datetime": "2025-07-15T10:00:00Z", - "end_datetime": "2025-07-15T12:00:00Z", - "message": "Pendulum Pay is currently undergoing scheduled maintenance. We expect to be back online by 12:00 PM UTC. Thank you for your patience.", - "estimated_time_remaining_seconds": 3600 - } - } - ``` - - * **If maintenance is NOT active:** - ```json - { - "is_maintenance_active": false, - "maintenance_details": null - } - ``` - -* **Error Responses:** - * `500 Internal Server Error`: If there's an issue querying the database. - -**Backend Logic for `/api/v1/maintenance/status`:** -1. Query the `maintenance_schedules` table. -2. Filter for records where `is_active_config` is `true`. -3. From the filtered records, find any schedule where the current server time (UTC) is `>= start_datetime` AND `< end_datetime`. -4. If multiple such schedules exist, the system could prioritize one (e.g., the one with the earliest `end_datetime` or latest `created_at`). For simplicity, returning the first one found is acceptable. -5. If an active maintenance schedule is found: - * Set `is_maintenance_active` to `true`. - * Populate `maintenance_details` with data from the found schedule. - * Optionally, calculate `estimated_time_remaining_seconds` based on `end_datetime` and current time. -6. If no active maintenance schedule is found: - * Set `is_maintenance_active` to `false`. - * Set `maintenance_details` to `null`. -7. Return the JSON response. diff --git a/docs/architecture/ramp-machine-widget-flow.md b/docs/architecture/ramp-machine-widget-flow.md deleted file mode 100644 index b5d0cb53c..000000000 --- a/docs/architecture/ramp-machine-widget-flow.md +++ /dev/null @@ -1,159 +0,0 @@ -# Ramp Machine + Widget Card Flow - -This document maps how the top-level XState machine (`ramp.machine.ts`) drives what the widget renders, and which UI actions send events back into the machine. - -## Source of truth -- Machine: `apps/frontend/src/machines/ramp.machine.ts` -- KYC node: `apps/frontend/src/machines/kyc.states.ts` -- Widget rendering switch: `apps/frontend/src/pages/widget/index.tsx` -- URL/bootstrap events: `apps/frontend/src/hooks/useRampUrlParams.ts` -- Main CTA behavior: `apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx` - -## High-level flow (machine) -```mermaid -flowchart TD - A[Idle] -->|SET_QUOTE| B[LoadingQuote] - B -->|quote loaded + unauth + enteredViaForm| AA[CheckAuth] - B -->|quote loaded otherwise| C[QuoteReady] - C -->|CONFIRM| D[RampRequested] - - D -->|kycNeeded = true| E[KYC] - D -->|kycNeeded = false and BRL| F[KycComplete] - - E -->|child KYC done| F - E -->|child KYC error| X[KycFailure] - X --> R[Resetting] - - F -->|PROCEED_TO_REGISTRATION + authenticated| G[RegisterRamp] - F -->|PROCEED_TO_REGISTRATION + unauthenticated| AA[CheckAuth] - F -->|GO_BACK| C - - G --> H[UpdateRamp] - - H -->|SELL onDone| I[StartRamp] - H -->|BUY onDone| H - H -->|PAYMENT_CONFIRMED 'BUY'| I - - I -->|callbackUrl present| J[RedirectCallback] - I -->|no callbackUrl| K[RampFollowUp] - - K -->|FINISH_OFFRAMPING| R - J -->|after 5s + cleanup| A - - C -->|GO_BACK| A - A -->|INITIAL_QUOTE_FETCH_FAILED| Q[InitialFetchFailed] - - R -->|urlCleaner done| A - - AA -->|authenticated + postAuthTarget=RegisterRamp| G - AA -->|authenticated + postAuthTarget=QuoteReady| C - AA -->|authenticated| C - AA -->|not authenticated| AB[EnterEmail] - AB --> AC[CheckingEmail] - AC --> AD[RequestingOTP] - AD --> AE[EnterOTP] - AE --> AF[VerifyingOTP] - AF -->|success + postAuthTarget=RegisterRamp| G - AF -->|success otherwise| C - AF -->|error| AE - - G -->|error| Z[Error] - H -->|error| Z - I -->|error| Z -``` - -## Widget card resolution order (important) -`WidgetContent` picks the first matching branch in this order: - -1. `ErrorStep` if machine matches `Error` -2. `RampFollowUpRedirectStep` if machine matches `RedirectCallback` -3. `AuthEmailStep` for `CheckAuth | EnterEmail | CheckingEmail | RequestingOTP` -4. `AuthOTPStep` for `EnterOTP | VerifyingOTP` -5. `MoneriumRedirectStep` if Monerium child actor exists and child state is `Redirect` -6. `SummaryStep` for `KycComplete | RegisterRamp | UpdateRamp | StartRamp` -7. Avenia branch if Avenia child actor exists: - - `AveniaKYBFlow` when CNPJ + `kybUrls` present - - else `AveniaKYBForm` (CNPJ) - - else `AveniaKYCForm` (CPF) -8. `InitialQuoteFailedStep` for `InitialFetchFailed` -9. fallback: `DetailsStep` - -## KYC subflow and cards -```mermaid -flowchart TD - KYC[KYC.Deciding] -->|fiat = BRL| AV[Avenia child machine] - KYC -->|fiat = EURC and BUY| MO[Monerium child machine] - KYC -->|otherwise| ST[Stellar child machine] - - AV -->|done without error| VC[VerificationComplete] - MO -->|done with authToken| VC - ST -->|done with paymentData| VC - - VC --> KC[KycComplete] - - AV -->|error| KF[KycFailure] - MO -->|error| KF - ST -->|error| KF - - MO -. child state Redirect .-> MR[MoneriumRedirectStep card] - - AV -. child exists .-> AC[Avenia cards] - AC --> AKYC[AveniaKYCForm] - AC --> AKYB[AveniaKYBForm] - AC --> AKYBF[AveniaKYBFlow] -``` - -## State-to-card map -| Machine state / condition | Card shown | -|---|---| -| `Error` | `ErrorStep` | -| `RedirectCallback` | `RampFollowUpRedirectStep` | -| `CheckAuth`, `EnterEmail`, `CheckingEmail`, `RequestingOTP` | `AuthEmailStep` | -| `EnterOTP`, `VerifyingOTP` | `AuthOTPStep` | -| Monerium child actor state `Redirect` | `MoneriumRedirectStep` | -| `KycComplete`, `RegisterRamp`, `UpdateRamp`, `StartRamp` | `SummaryStep` | -| Avenia actor exists + CNPJ + `kybUrls` | `AveniaKYBFlow` | -| Avenia actor exists + CNPJ (no `kybUrls`) | `AveniaKYBForm` | -| Avenia actor exists + CPF | `AveniaKYCForm` | -| `InitialFetchFailed` | `InitialQuoteFailedStep` | -| everything else | `DetailsStep` | - -## Key UI -> machine events -- `DetailsStep` submit -> `CONFIRM` (via `useRampSubmission`) and `SET_ADDRESS` -- `RampSubmitButton`: - - in `QuoteReady` -> `CONFIRM` - - in `KycComplete` -> `PROCEED_TO_REGISTRATION` - - default -> `SummaryConfirm` - - in `UpdateRamp` on onramp -> `PAYMENT_CONFIRMED` - - if quote expired -> `RESET_RAMP` -- `AuthEmailStep` -> `ENTER_EMAIL` -- `AuthOTPStep` -> `VERIFY_OTP` -- Error/initial-failure/retry actions -> `RESET_RAMP` -- Back button (`StepBackButton`) primarily sends `GO_BACK` (with Avenia-specific child events in document/liveness/KYB sub-steps) - -## URL/bootstrap interactions -`useSetRampUrlParams` seeds machine state at widget load: -- `SET_QUOTE_PARAMS` -- `SET_EXTERNAL_ID` (if provided) -- `SET_QUOTE` (provided quoteId or fetched quote) -- `INITIAL_QUOTE_FETCH_FAILED` on quote fetch failure - -This is why many sessions start in `LoadingQuote`/`QuoteReady` rather than plain `Idle`. - -## Auth gating change -- The initial `Idle -> CheckAuth` auto-transition was removed. -- For `/widget` entry coming from Quote form (`enteredViaForm`), auth can happen directly after `LoadingQuote` and before `QuoteReady`. -- Auth is also deferred to `KycComplete -> PROCEED_TO_REGISTRATION` when needed. -- `postAuthTarget` tracks whether post-auth continuation should be `QuoteReady` or `RegisterRamp`. -- `GO_BACK` behavior in auth states: - - `CheckAuth`, `EnterEmail`, `CheckingEmail`, `RequestingOTP`: back to `KycComplete` when `postAuthTarget=RegisterRamp`, otherwise reset to `Idle` (Quote form path). - - `EnterOTP`, `VerifyingOTP`: back to `EnterEmail`. - -## Practical reading model -When debugging what card should show, check in this order: -1. Top-level ramp state (`rampActor.getSnapshot().value`) -2. Whether Monerium child is in `Redirect` -3. Whether Avenia child exists and its context (`taxId`, `kybUrls`, `kybStep`) -4. Whether auth gating states (`CheckAuth`...`VerifyingOTP`) currently match - -The render priority order can override expectations from raw machine state (for example, a Monerium `Redirect` child card can appear before generic details/some other fallback views). diff --git a/docs/architecture/recipient-transfers-schema.md b/docs/architecture/recipient-transfers-schema.md deleted file mode 100644 index ff52b4f57..000000000 --- a/docs/architecture/recipient-transfers-schema.md +++ /dev/null @@ -1,128 +0,0 @@ -# Recipient Transfers Schema (Dashboard) - -**Date:** 2026-06-25 -**Status:** Proposed / discussion draft - -## Summary - -The dashboard lets a signed-in **sender** invite a **recipient** by email to receive a payout for a specific country/rail. The recipient accepts, creates a Vortex profile, onboards, and only then can the sender transfer to them. - -This is net-new product scope. It **builds on** the core identity model in [unified-user-management-schema.md](unified-user-management-schema.md) — senders and recipients are both `customer_entities`, their onboarding is `provider_customers` + `kyc_cases`. This doc adds only the relationship/invite/payout tables on top. - -Principles carried over: - -- **Recipients are normal customers** — an accepted recipient gets a `profile` + `customer_entity`, not a separate identity model. -- **The sender owns the relationship, not the recipient's identity** — one recipient can be linked to many senders. -- **Provider-host payout instruments** — store provider references + masked metadata, never reusable PIX/IBAN/ACH/CLABE/CBU PII locally. - -Tables list only decision-bearing columns; assume `id` (UUID PK) + standard `created_at`/`updated_at`. - -## Flow - -```mermaid -flowchart TD - Sender[customer_entities: sender] --> Invite[recipient_invitations: email invite] - Invite --> Signup[profiles: recipient signs up] - Signup --> Recipient[customer_entities: recipient] - Sender --> Rel[sender_recipients: relationship] - Recipient --> Rel - Recipient --> Onboard[provider_customers + kyc_cases: recipient onboarding] - Rel --> Payout[recipient_payout_references: provider payout ref] - Onboard --> Gate[transfer_eligibility: view] - Payout --> Gate - Rel --> Gate - Gate --> Transfer[quote/ramp creation] -``` - -## Schema - -### `recipient_invitations` - -Email invite created by a sender before the recipient has a profile. - -| Column | Notes | -| :-- | :-- | -| `sender_customer_entity_id` | FK to sender `customer_entities.id`. | -| `created_by_profile_id` | Sender profile that created the invite. | -| `invitee_email` / `invitee_email_canonical` | Display value + normalized value used for uniqueness and acceptance. | -| `invitee_type` | `individual` or `business`. | -| `country`, `rail`, `payout_currency` | Requested corridor. | -| `alias` | Sender-local label typed at creation; identifies the link/recipient in the sender's list. | -| `status` | `pending`, `accepted`, `expired`, `revoked`. | -| `token_hash` | sha256 hash of the invite token — the only redemption lookup key. | -| `token` | Raw invite token, retained **while pending** so the sender can re-copy the link; cleared (`NULL`) on first acceptance. Deliberate product decision — see the security spec. | -| `archived_at` | Sender-side soft hide: archived invitations are excluded from the sender's list but stay fully redeemable. | -| `expires_at`, `accepted_at`, `revoked_at` | Lifecycle. | -| `accepted_by_profile_id` | Nullable FK, set after the recipient signs up. | - -```sql -UNIQUE (token_hash) --- optional: one active invite per sender/email/corridor --- UNIQUE (sender_customer_entity_id, invitee_email_canonical, country, rail) WHERE status = 'pending' -``` - -Redemption is email-bound: if the accepting profile's email differs from `invitee_email_canonical`, require re-verification or reject. - -### `sender_recipients` - -The relationship after an invite is accepted (or a recipient is otherwise attached). - -| Column | Notes | -| :-- | :-- | -| `sender_customer_entity_id` | FK to sender `customer_entities.id`. | -| `recipient_customer_entity_id` | FK to recipient `customer_entities.id`. | -| `invitation_id` | Nullable FK to `recipient_invitations.id`. | -| `relationship_status` | `invited`, `active`, `blocked`, `archived`. | -| `nickname` | Sender-local label. | -| `disabled_at` | Optional. | - -```sql -UNIQUE (sender_customer_entity_id, recipient_customer_entity_id) -``` - -The sender owns this row; the recipient owns their own profile/customer/compliance identity, reusable across senders. - -### `recipient_payout_references` - -Provider payout-instrument reference for a relationship. No reusable payout PII stored locally. - -| Column | Notes | -| :-- | :-- | -| `sender_recipient_id` | FK to `sender_recipients.id`. | -| `recipient_customer_entity_id` | FK to recipient `customer_entities.id`. | -| `provider`, `country`, `rail`, `currency` | Payout target. | -| `instrument_type` | Non-sensitive category: `pix`, `iban`, `clabe`, `ach`, `cbu_cvu`, `account_number`. | -| `provider_instrument_id` | Provider-side instrument/account/recipient ID (the source of truth). | -| `masked_display_label` | Provider-masked label only. | -| `status` | `pending`, `verified`, `rejected`, `disabled`. | -| `last_provider_sync_at` | Last provider fetch/sync. | - -Full payout details are fetched from the provider just in time. The dashboard mock's `{ method, value }` becomes provider-side creation input, not a local record. - -### `transfer_eligibility` (view, not a table) - -"Can this sender transfer to this recipient for this country/rail?" computed from the rows above — a database **view or function**, not a stored table (avoids staleness). Promote to materialized only if perf demands it. - -Returns: `sender_recipient_id`, parties, `country`, `rail`, `can_create_transfer` (bool), `blocking_reason_code` (`invite_not_accepted`, `recipient_onboarding_pending`, `provider_payout_reference_unverified`, `provider_restricted`). - -A transfer is creatable only when **all** hold: - -```text -invite accepted -AND relationship active -AND recipient onboarding approved for country/rail -AND provider payout reference verified -AND provider/customer status allows payouts -``` - -The API enforces this at quote/ramp creation — not just in the UI. - -## Open questions - -1. Can one recipient have multiple active payout references for the same sender/country/rail? -2. Are sender-entered payout details editable before approval, after, or only via re-invite/re-verify? -3. Is recipient onboarding reusable across senders for the same country/rail, or does each relationship need its own approval? - -## Security-spec impact - -New/updated specs required for: invite token generation/hashing/expiry/revocation/redemption; sender↔recipient authorization boundaries; recipient onboarding reuse; provider-held payout references and PII fetch/redaction policy; transfer gating. diff --git a/docs/architecture/supabase-auth.md b/docs/architecture/supabase-auth.md deleted file mode 100644 index 614af6c42..000000000 --- a/docs/architecture/supabase-auth.md +++ /dev/null @@ -1,86 +0,0 @@ -# Supabase Auth Integration - -## Overview - -This architectural document describes the integration of Supabase Authentication into Vortex. The system uses a passwordless Email OTP flow, leveraging Supabase's infrastructure for identity management while maintaining user-related data in our local PostgreSQL database. - -## Architecture - -### User Flow - -The authentication flow is designed to be unobtrusive, allowing users to browse and calculate quotes before being required to identify themselves. - -1. **Quote Creation**: Unauthenticated users can view and calculate quotes. -2. **Confirmation**: When a user clicks "Confirm" on a quote, the system checks for an active session. -3. **Authentication**: - * If no session exists, the user is prompted for their email. - * **OTP**: A one-time password is sent to their email (via Supabase). - * **Verification**: User enters the code. On success, access and refresh tokens are issued and stored locally. -4. **Transaction**: Authenticated user proceeds to the ramp transaction. `user_id` is now attached to the created resources. - -### Data Model - -We utilize Supabase's `auth` schema for identity management but strictly separate our application data. - -* **`auth.users`**: Internal Supabase table storing identity, email, and encrypted passwords (unused here). -* **`public.profiles`**: Our local table that extends the auth user, linked 1:1 with `auth.users`. - * *Note*: This table was explicitly named `profiles` to avoid conflicts with `auth.users`. -* **Entity Linking**: The following core entities reference the Supabase `user_id` (UUID) to maintain ownership: - * `quote_tickets` - * `ramp_states` - * `kyc_level_2` - * `tax_ids` - -## Backend Architecture - -The backend acts as a bridge between the frontend and Supabase, ensuring data integrity without handling sensitive credential storage. - -### Tech Stack -* **Framework**: Express -* **Auth Client**: `@supabase/supabase-js` -* **Database**: PostgreSQL (via Sequelize) - -### Service Layer -The **Auth Service** encapsulates interactions with Supabase: -* **Admin Client**: Used for privileged operations like checking if a user exists (`admin.listUsers`) without logging them in. -* **Anon Client**: Used for standard operations like `signInWithOtp` and `verifyOtp`. - -### Middleware -* **`requireAuth`**: Validates the `Bearer` token against Supabase using `getUser()`. Attaches `userId` to the request object. -* **`optionalAuth`**: checks for a token but continues even if invalid/missing (useful for mixed-access endpoints). - -### API Endpoints -All auth-related operational endpoints are grouped under `/api/v1/auth`: -* `GET /check-email`: Checks if a user exists (determines Sign In vs Sign Up UI flow). -* `POST /request-otp`: Triggers the email OTP. -* `POST /verify-otp`: Exchanges OTP for session tokens. -* `POST /refresh`: Rotates expired access tokens using the refresh token. -* `POST /verify`: Validates a token server-side. - -## Frontend Architecture - -The frontend manages the user session and guides the user through the auth steps within the transaction flow. - -### Tech Stack -* **Framework**: React -* **State Management**: XState -* **Client**: `@supabase/supabase-js` - -### State Machine Integration -The `rampMachine` handles the authentication lifecycle as a distinct phase in the transaction flow. - -* **States**: - * `CheckAuth`: Decides whether to skip to `RampRequested` or enter the auth flow. - * `EnterEmail` / `CheckingEmail`: Captures and validates email. - * `RequestingOTP`: Calls API to send code. - * `EnterOTP` / `VerifyingOTP`: Captures code and exchanges for tokens. -* **Transitions**: The flow blocks the "Confirm" action until `AUTH_SUCCESS` is reached, ensuring no quote is finalized without a user. - -### Token Management -* **Storage**: `localStorage` is used to store `access_token`, `refresh_token`, and `user_id`. -* **Auto-Refresh**: A background process (via `useAuthTokens` hook) monitors token validity and refreshes them automatically ~5 minutes before expiry to prevent session interruption during long flows. - -### UI Components -The auth UI is embedded directly into the widget flow rather than a separate page: -* `AuthEmailStep`: Simple email input with existence check. -* `AuthOTPStep`: 6-digit code input with auto-submit and paste support. diff --git a/docs/architecture/unified-user-management-schema.md b/docs/architecture/unified-user-management-schema.md deleted file mode 100644 index 9e1dcacc6..000000000 --- a/docs/architecture/unified-user-management-schema.md +++ /dev/null @@ -1,340 +0,0 @@ -# Unified User Management Schema - -**Date:** 2026-06-25 -**Status:** Proposed / discussion draft - -## Summary - -Our user-management tables grew table-by-table as auth, partners, and new rails were added. The model now mixes concepts that should be separate. This doc proposes a cleaner core identity model and lists the migration to get there. - -Three concrete problems it fixes: - -1. **A "partner" is not one row.** `partners` is keyed on `(name, ramp_type)` with a non-unique name, so a logical partner is 1–2 rows. That's why `api_keys` links by `partner_name` and assignments carry both `buy_partner_id`/`sell_partner_id`. -2. **A provider customer is three bespoke tables.** `mykobo_customers`, `alfredpay_customers`, and the Avenia half of `tax_ids` model the same idea three different ways. -3. **A KYC'd subaccount has no enforced owner.** `tax_ids.user_id` is nullable and adopted by the first caller; SDK/API callers can use any subaccount. (See [ownership enforcement](#tax-id--subaccount-ownership-enforcement).) - -**Scope:** this doc covers the *existing identity model* only. The dashboard recipient/invitation/payout flow builds on this and lives in [recipient-transfers-schema.md](recipient-transfers-schema.md). - -## Current → Proposed mapping - -| Current table | What happens | Why | -| :-- | :-- | :-- | -| `profiles` | **Keep** as login identity, with a nullable active-entity pointer. | A profile is a sign-in, not a customer; the pointer records which owned legal identity the dashboard uses. | -| `partners` | **Split** into `partners` (unique `name`) + `partner_pricing_configs` (per `ramp_type`). | Separate commercial identity from per-direction pricing; gives a stable `partner_id`. | -| `profile_partner_assignments` | **Keep**, collapse `buy_partner_id`/`sell_partner_id` → one `partner_id`. | The buy/sell split only exists because `partners` is split by direction; one `partner_id` replaces it. | -| `api_keys` | **Refactor**: drop `partner_name`; add `partner_id` FK + `profile_id`; keep `key_type`/`key_value`. | Resolve by FK, and bind each key to one customer (enables SDK ownership checks). | -| `mykobo_customers` | **Fold into** `provider_customers` (`provider = mykobo`). | One provider-account model. | -| `alfredpay_customers` | **Fold into** `provider_customers` (`provider = alfredpay`). | Same; also drops the phantom `email` index bug. | -| `tax_ids` | **Split**: Avenia subaccount → `provider_customers` (`provider = avenia`, owner required); KYC workflow → `kyc_cases`; quote provenance dropped (or migrated if still read). | Untangle identity, provider account, and workflow; remove raw-tax-ID primary key. | -| `kyc_level_2` | **Replace** with `kyc_cases`. | Generalize beyond BRLA. | -| `quote_tickets` | **Keep**. `partner_id`/`pricing_partner_id` now point at the new `partners`. | Ownership/pricing split is correct; preserve it. | -| `ramp_states` | **Keep**, unchanged. | — | -| — | **New** `customer_entities` | Legal/compliance customer; the owner anchor for provider accounts and KYC. | -| — | **New** `provider_customers` | Unified provider/rail account (Mykobo, AlfredPay, Avenia, future). | -| — | **New** `kyc_cases` | Unified KYC/KYB verification attempts. | -| — | **New** `partner_pricing_configs` | Per-direction pricing split out of `partners`. | - -Tables below list only decision-bearing columns; assume every table also has `id` (UUID PK) and standard `created_at`/`updated_at` unless noted. - -## Proposed schema - -### `customer_entities` (new) - -The legal/compliance customer — the owner of provider accounts and KYC. Sits between `profiles` (login) and the provider/KYC tables. - -| Column | Notes | -| :-- | :-- | -| `profile_id` | FK to `profiles.id`. Nullable only so compliance records can outlive a deleted profile; created eagerly (one `individual` entity per new profile). | -| `type` | `individual` or `business`. | -| `country` | Optional default/legal country. | -| `status` | `active`, `archived`, `blocked`. | - -One profile may own many customer entities (e.g. individual + business); the product can start with one. - -`profiles.active_customer_entity_id` is a nullable FK to `customer_entities.id`. Migration 048 -backfills an unambiguous active entity only when it already owns provider or recipient data. Empty -legacy individual entities remain unselected so existing users can still choose company. Profiles with -multiple entities stay unselected. The authenticated dashboard makes one initial `individual` or -`business` selection; the API verifies ownership, rejects ambiguous same-type matches, and does not -permit changing the selection afterward. - -### `partners` + `partner_pricing_configs` (split) - -`partners` — commercial identity: - -| Column | Notes | -| :-- | :-- | -| `name` | **Unique.** The stable handle API keys and assignments reference. | -| `display_name`, `logo_url`, `is_active` | As today. | - -`partner_pricing_configs` — per-direction pricing: - -| Column | Notes | -| :-- | :-- | -| `partner_id` | FK to `partners.id`. | -| `ramp_type` | `BUY` or `SELL`. | -| markup / vortex-fee / discount / dynamic-difference fields | Moved verbatim from today's `partners`. | -| `payout_address_evm`, `payout_address_substrate` | Moved from today's `partners`. | - -```sql -UNIQUE (name) -- partners -UNIQUE (partner_id, ramp_type) -- partner_pricing_configs -``` - -Pricing then resolves via `(partner_id, ramp_type)`; `api_keys`, `quote_tickets`, and `profile_partner_assignments` all reference a single `partner_id`. - -### `provider_customers` (new) - -One anchor for every provider/rail account, including the durable provider reference and tax reference. - -| Column | Notes | -| :-- | :-- | -| `customer_entity_id` | FK to `customer_entities.id`. **NOT NULL** — every provider account has exactly one owner. | -| `provider` | `mykobo`, `alfredpay`, `avenia`, `monerium`. (Avenia *is* the BRLA integration — the code is mid-rename, service dir `brla/` + `BrlaApiService` but `Avenia*` types. Use one provider value, not two.) | -| `rail` | `eur`, `mxn`, `cop`, `ars`, `brl`. | -| `country` | Provider/customer country. | -| `provider_customer_id` | External provider customer ID, if any. | -| `provider_subaccount_id` | External subaccount ID. For Avenia/BRLA this is the durable key (`subAccountId`) used to fetch profile/tax data on demand. | -| `company_name` | Nullable provider-recognized company name for business accounts. Avenia fills this on creation and lazily hydrates legacy rows from account info. | -| `tax_reference`, `tax_reference_hash` | Avenia normalized tax ID and its sha256 lookup key. Masked display is derived at read time with `maskTaxReference`; no masked copy is persisted. | -| `customer_type` | `individual` or `business`. | -| `status`, `status_external` | Canonical `started`/`pending`/`in_review`/`approved`/`rejected` status plus the unmodified provider status when one was returned. | -| `last_failure_reasons` | Structured (JSONB), PII-restricted. | - -```sql -UNIQUE (provider, provider_customer_id) -UNIQUE (provider, provider_subaccount_id) -- where subaccount is the durable key -UNIQUE (provider, customer_entity_id, rail, country) -``` - -> Per-provider detail tables (e.g. `provider_customer_avenia_details`) and a `sandbox`/`production` column are **not** added now — only if a concrete field or a shared-DB environment requires them. - -### `kyc_cases` (new, replaces `kyc_level_2`) - -Verification attempts/outcomes, independent of the provider account row. - -| Column | Notes | -| :-- | :-- | -| `customer_entity_id` | FK to `customer_entities.id`. | -| `provider_customer_id` | Nullable FK to `provider_customers.id`. | -| `provider` | Provider handling verification. | -| `level` | `level_1`, `level_2`, or mapped provider level. | -| `type` | `kyc` or `kyb`. | -| `status`, `status_external` | Canonical `started`/`pending`/`in_review`/`approved`/`rejected` status plus the unmodified provider status when one was returned. | -| `provider_case_id` | External case ID, if any. | -| `failure_reasons` | Structured (JSONB), PII-restricted. | -| `submitted_at`, `approved_at`, `rejected_at` | Lifecycle timestamps. | - -> The dashboard's per-country KYC status view is just a query over `provider_customers` + `kyc_cases`. Add a database view if/when the dashboard needs it; no extra table required. - -Canonical progression is `not_started → started → pending → in_review → approved/rejected`. -`not_started` is represented by the absence of a provider account, not a database status. -`pending` is reserved for missing or stale provider data and is only used where applicable. - -`status_external` stores the provider value unchanged whenever one was returned. It is null for -local-only transitions and missing provider data. `authorization_started` is a synthetic Monerium -state rather than a provider response, but is retained in `status_external` to distinguish the -OAuth hand-off from provider review. - -| Provider | Provider/workflow state | Source | Canonical `status` | Persisted `status_external` | -| :-- | :-- | :-- | :-- | :-- | -| Avenia | Initial tax consultation (`Consulted`) | Local workflow | `started` | `null` | -| Avenia | Missing KYC attempt | Missing provider data | `pending` | `null` | -| Avenia | Attempt `EXPIRED` | Provider | `pending` | `EXPIRED` | -| Avenia | Submitted (`Requested`) | Local workflow | `in_review` | `null` until polled | -| Avenia | Attempt `PENDING` | Provider | `in_review` | `PENDING` | -| Avenia | Attempt `PROCESSING` | Provider | `in_review` | `PROCESSING` | -| Avenia | `COMPLETED` with result `APPROVED` | Provider | `approved` | `COMPLETED` | -| Avenia | `COMPLETED` with result `REJECTED` | Provider | `rejected` | `COMPLETED` | -| Avenia | Account identity `CONFIRMED` fallback | Provider | `approved` | `CONFIRMED` | -| AlfredPay | Customer created before provider status is fetched | Local workflow | `started` | `null` | -| AlfredPay | Provider `CREATED` | Provider | `started` | `CREATED` | -| AlfredPay | KYC/KYB link opened | Local workflow | `started` | `null` | -| AlfredPay | `UPDATE_REQUIRED` | Provider | `started` | `UPDATE_REQUIRED` | -| AlfredPay | Missing or stale submission | Missing provider data | `pending` | `null` | -| AlfredPay | User finished the redirect | Local workflow | `in_review` | `null` until polled | -| AlfredPay | `IN_REVIEW` | Provider | `in_review` | `IN_REVIEW` | -| AlfredPay | `COMPLETED` | Provider | `approved` | `COMPLETED` | -| AlfredPay | `FAILED` | Provider | `rejected` | `FAILED` | -| Mykobo | Profile submission begins | Local workflow | `started` | `null` | -| Mykobo | Profile creation failed | Missing provider data | `pending` | `null` | -| Mykobo | Profile lookup returns `404` | Missing provider data | `pending` | `null` | -| Mykobo | Missing review status | Missing provider data | `pending` | `null` | -| Mykobo | Unknown review status | Provider | `pending` | Exact provider value | -| Mykobo | `pending` | Provider | `in_review` | Exact provider value | -| Mykobo | `approved` | Provider | `approved` | Exact provider value | -| Mykobo | `rejected` | Provider | `rejected` | Exact provider value | -| Monerium | OAuth authorization started | Synthetic local state | `started` | `authorization_started` | -| Monerium | `created` | Provider | `started` | `created` | -| Monerium | `incomplete` | Provider | `started` | `incomplete` | -| Monerium | Missing or stale mirrored status | Missing provider data | `pending` | `null` | -| Monerium | `pending` | Provider | `in_review` | `pending` | -| Monerium | Another submitted non-terminal state, such as `submitted` | Provider | `in_review` | Exact provider value | -| Monerium | `approved` | Provider | `approved` | `approved` | -| Monerium | `rejected` | Provider | `rejected` | `rejected` | - -### `api_keys` (refactor) - -Each key is bound to one customer (via profile) and references a stable partner row. - -| Column | Notes | -| :-- | :-- | -| `profile_id` | FK to `profiles.id`. The principal the key acts as → resolves to a `customer_entity`. Required for customer/SDK keys. | -| `partner_id` | Nullable FK to `partners.id` for commercial attribution. | -| `key_type` | `public` or `secret`. Kept — the two kinds are stored/matched differently. | -| `key_hash` | Bcrypt hash for **secret** keys. Raw secret never stored. | -| `key_value` | Plaintext for **public** keys only (public by design, matched by equality). Null for secret keys. | -| `key_prefix`, `name`, `scopes`, `is_active`, `expires_at`, `last_used_at`, `revoked_at` | As today / standard. | - -Removed: `partner_name`. Authorization resolves through `partner_id`. - -## Tax-ID / subaccount ownership enforcement - -The security-relevant outcome of this redesign. Invariant: - -> An Avenia subaccount (`provider_customers` row, `provider = avenia`) is owned by exactly one `customer_entity`, and can only be used to ramp by a principal that owns that customer entity. - -Principal resolution: - -- **UI (Supabase):** token → `profile` → `customer_entity`. -- **SDK/API:** secret key → `api_keys.profile_id` → `customer_entity`. A key binds to one customer, so the same check applies uniformly. - -Enforcement: - -- Reject quote/ramp creation targeting a `provider_customer` the authenticated customer doesn't own. -- Apply on **all** Avenia/BRLA ramp + read/limit endpoints (today only `getAveniaUser` checks, and only for Supabase users; `getAveniaUserRemainingLimit` does a bare lookup). -- No lazy null-owner adoption — a subaccount is created with its owner. - -## Entity relationship diagram - -```mermaid -erDiagram - profiles ||--o{ customer_entities : owns - profiles ||--o{ api_keys : owns - profiles ||--o{ quote_tickets : owns - profiles ||--o{ ramp_states : owns - profiles ||--o{ profile_partner_assignments : has - - customer_entities ||--o{ provider_customers : owns - customer_entities ||--o{ kyc_cases : verifies - provider_customers ||--o{ kyc_cases : may_have - - partners ||--o{ partner_pricing_configs : prices - partners ||--o{ api_keys : attributed_to - partners ||--o{ profile_partner_assignments : assigned - partners ||--o{ quote_tickets : owns - partners ||--o{ quote_tickets : prices - - quote_tickets ||--|| ramp_states : creates - - profiles { - UUID id PK - TEXT email UK - UUID active_customer_entity_id FK - } - customer_entities { - UUID id PK - UUID profile_id FK - TEXT type - TEXT country - TEXT status - } - provider_customers { - UUID id PK - UUID customer_entity_id FK - TEXT provider - TEXT rail - TEXT country - TEXT provider_customer_id - TEXT provider_subaccount_id - TEXT company_name - TEXT tax_reference - TEXT tax_reference_hash - TEXT customer_type - TEXT status - TEXT status_external - } - kyc_cases { - UUID id PK - UUID customer_entity_id FK - UUID provider_customer_id FK - TEXT provider - TEXT level - TEXT type - TEXT status - TEXT provider_case_id - } - api_keys { - UUID id PK - UUID profile_id FK - UUID partner_id FK - TEXT key_type - TEXT key_hash - TEXT key_value - TEXT key_prefix - BOOLEAN is_active - } - partners { - UUID id PK - TEXT name UK - TEXT display_name - BOOLEAN is_active - } - partner_pricing_configs { - UUID id PK - UUID partner_id FK - TEXT ramp_type - TEXT markup_type - DECIMAL markup_value - TEXT vortex_fee_type - DECIMAL vortex_fee_value - } - quote_tickets { - UUID id PK - UUID user_id FK - UUID partner_id FK - UUID pricing_partner_id FK - } - ramp_states { - UUID id PK - UUID user_id FK - UUID quote_id FK - } - profile_partner_assignments { - UUID id PK - UUID user_id FK - UUID partner_id FK - BOOLEAN is_active - } -``` - -## Migration (additive, phased) - -1. **Split `partners` first.** Create `partners` (unique name) + `partner_pricing_configs`; collapse the per-direction rows; point `partner-resolution.ts`, assignments, and api-key resolution at `(partner_id, ramp_type)`. -2. **Add `customer_entities`, `provider_customers`, `kyc_cases`.** Backfill `customer_entities` from `profiles`; convert `mykobo_customers`/`alfredpay_customers` and the Avenia half of `tax_ids` into `provider_customers` with the correct owner; convert `kyc_level_2` → `kyc_cases`. -3. **Refactor `api_keys`.** Add `partner_id` (backfill from `partner_name`) + `profile_id`, dual-write, then drop `partner_name`. Note: `profile_id` **cannot** be backfilled for existing partner-wide keys — they stay partner-only until re-keyed per customer, so `profile_id` is nullable in practice and the SDK ownership check only applies to keys that have one. -4. **Enforce subaccount ownership** on all Avenia/BRLA paths; quarantine any migrated subaccount whose owner is unclear (don't auto-assign). -5. **Switch reads, then deprecate** legacy columns/tables after parity checks and security-spec updates. - -Each step is additive (add → backfill → dual-write → cut over reads → drop) so it can ship independently. - -## Open questions - -1. Resolved: one profile may own multiple customer entities, but the dashboard persists exactly one immutable active entity. -2. Was `tax_ids`' "one tax ID globally" (raw tax ID as PK) an intentional dedup/fraud guard to reproduce on `provider_customers`, or can it relax? -3. What parts of `kyc_level_2.upload_data` / provider failure payloads may be retained locally? -4. Retention policy when a profile is deleted but compliance records must remain? - -> Resolved in review: a secret key binds to exactly one customer (enables uniform ownership checks); per-provider detail tables and an `environment` column are deferred until needed; the dashboard onboarding-status projection is a view, not a table. - -## Non-goals (first implementation) - -- Don't remove legacy tables before parity is proven. -- Don't collapse `partner_id` and `pricing_partner_id`. -- Don't let a profile partner assignment grant partner *ownership* (pricing only). -- Don't store raw API secrets or raw tax IDs. -- Don't auto-assign an owner to a subaccount with no clear owner during migration. - -## Security-spec impact - -Security-relevant; update specs in the same change set — especially `01-auth/api-keys.md`, `05-integrations/brla.md` (subaccount ownership), `05-integrations/mykobo.md`, `05-integrations/alfredpay.md`, and `03-ramp-engine/profile-partner-pricing.md`. diff --git a/docs/architecture/user-gated-ramp-registration.md b/docs/architecture/user-gated-ramp-registration.md deleted file mode 100644 index ab3ffa9b1..000000000 --- a/docs/architecture/user-gated-ramp-registration.md +++ /dev/null @@ -1,95 +0,0 @@ -# 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/features/contract-tests.md b/docs/features/contract-tests.md deleted file mode 100644 index a8e2cfdc0..000000000 --- a/docs/features/contract-tests.md +++ /dev/null @@ -1,218 +0,0 @@ -# PRD: External API contract tests — verifying the fake world against the real one - -Status: milestones 1–4 (SquidRouter, Alfredpay, Avenia/BRLA, price feeds) implemented; -milestone 5 (warn-only production parsing) ships separately per endpoint once its schema -has survived a quiet week of nightlies. Methods without production consumers -(`getQuote` on Alfredpay; `createOnchainSwapQuote`/`createOnchainSwapTicket`/ -`getMainAccountBalance`/`getAveniaSwapTicket` on Avenia) are deliberately uncovered — -there is no consumed contract to verify. -Reference: extends the test suite described in [`docs/testing-strategy.md`](../testing-strategy.md). - -Naming: this layer is called **"external API contracts"** everywhere (docs, directory names, -test titles) to avoid confusion with the existing "SDK contract" layer, which verifies the -opposite boundary (our SDK against our API). - -## Problem - -The hermetic test suite runs everything against the fake world in -`apps/api/src/test-utils/fake-world/`. The fakes implement the same TypeScript interfaces as the -real clients (`AlfredpayApiService`, `BrlaApiService`, `getRoute`, `priceFeedService`), so fakes -and production code are consistent with each other **by construction**. But nothing verifies that -those TypeScript types match what the partner APIs actually return: the real clients cast -`response.json()` to a type without runtime validation (e.g. -`packages/shared/src/services/alfredpay/alfredpayApiService.ts`), and the fakes are written -against the same unverified types. - -Concretely: if Alfredpay renames a response field or adds a new status value, every hermetic test -stays green, TypeScript stays happy, and production breaks — silently, typically as an -`undefined` propagating several phases downstream. - -The drift risk is therefore **our shared types/fakes vs. reality**, not "fake vs. real client". - -## Goals - -1. Detect drift between the shared types (and thus the fakes) and the real partner APIs, within - one nightly cycle instead of at the next production incident. -2. Define each external contract **once**, as a runtime-checkable artifact (zod schema), instead - of assertions duplicated between a hermetic suite and a live suite. -3. Verify on every PR that the fakes satisfy the same contract — the "verified fake" pattern. -4. Keep the PR path fully hermetic. Nothing here may add a network dependency, secret, or - third-party sandbox to PR-blocking CI. - -## Non-goals - -- **No live corridor scenarios.** The layer-3 scenario tests script the world (balances arriving - after N polls, statuses flipped on command); that cannot be done against reality. The reusable - artifact is the service seam, not the scenario suite. -- **No funds movement.** Live tests stop at the point where a sandbox would require a real - payment (e.g. an onramp stays `AWAITING_PAYMENT`; that is a valid terminal point for the test). -- **No behavioral-equivalence proof.** Webhook ordering, status-transition timing, and which - intermediate statuses actually occur are not verifiable this way. Residual coverage for that is - production monitoring — explicitly accepted. -- **No calldata/chain fidelity.** The no-Anvil decision in `testing-strategy.md` stands; the - EVM/Pendulum fakes sit above calldata level on purpose and get no contract suite. -- **No Mykobo suite** while the EUR corridor is kill-switched (it already has `RUN_LIVE_TESTS` - sandbox tests; fold them into this pattern only if EUR is re-enabled). -- **No production runtime validation in the test-layer PRs.** Warn-only `safeParse` in the - production clients ships separately because it touches production code paths — but per - endpoint and early, not deferred to the end (see Milestone 5): it is the only check that runs - against production responses. - -## Accepted assumption: sandbox ≈ production - -Partner sandboxes may be shaky and can deviate from production responses. **We accept this -risk**: behaving like production is what sandboxes are for, and it is the only environment we can -test against without moving real money. Consequences we commit to: - -- Live contract results are **never PR-blocking** — they run nightly and alert. -- A live failure is a *signal to triage*, not automatically a bug in our code. Triage order: - (1) transient sandbox flakiness → rerun; (2) sandbox-only deviation → confirm against - production logs/traffic before touching anything, then encode the deviation as a documented - loosening of the schema if it's sandbox-only; (3) real contract change → update schema, types, - fake, and any affected handler **in the same PR**. -- Network-level failures (timeouts, 5xx, DNS) are reported as **skips with a warning**, not - contract failures — only a successful response that violates the schema fails the suite. This - keeps the alert channel meaningful despite shaky sandboxes. - -## Design - -### The contract: zod schemas in `packages/shared` - -For each covered service, add a `schemas.ts` next to its `types.ts` -(e.g. `packages/shared/src/services/alfredpay/schemas.ts`). - -**The schemas model the raw wire JSON of the fields we consume** — two boundaries the current -types blur: - -- **Wire, not internal.** Schemas describe what `response.json()` actually yields: timestamps are - `z.string()` (ISO), never `Date`. Some existing types already lie about this (e.g. - `BaseTicket.createdAt: Date` in `packages/shared/src/services/brla/types.ts` — JSON cannot - contain a `Date`). Each anchor's milestone resolves such discrepancies for its endpoints: - either fix the type to the wire shape or add an explicit transform at the client boundary. - These are pre-existing bugs the schemas surface, not scope creep. -- **Consumed contract, not full partner response.** Schemas cover the fields our code actually - reads, not everything the partner returns. The fakes are deliberately partial (e.g. - `FakeSquidRouter` omits `quoteId`, `aggregateSlippage`, `toAmountMin`, `toAmountUSD`, which the - full `SquidrouterRoute` type requires) — that is correct, and we keep it: validating fields - nothing consumes would add nightly flake surface with no protective value. Where the consumed - set is a subset of the shared type, the schema is typed against a derived `Pick` of it. - -Each schema is declared with `satisfies z.ZodType`, which catches renames and -removals of consumed fields at compile time. This is a strong guard, **not a proof**: coercions, -`.optional()`, transforms, and `any` can still hide mismatch. Discipline that keeps it honest: no -`z.any()`, no `.optional()` unless the field is genuinely absent in some real responses, no -input-widening coercions in wire schemas. - -Schemas cover **response** shapes (that's where drift bites; requests are our own construction). -Assertions are properties, not values: fields present, amounts parse as decimals, statuses ∈ -enum, ids non-empty. Never exact amounts, ids, or timestamps — sandbox responses are -non-deterministic. - -Unknown extra fields are allowed (loose-object semantics) — partners adding fields is not -drift we care about; partners removing or renaming fields is. - -**Dependency decision:** zod is currently a dependency only of `apps/frontend` (`^4.3.6`). Putting -schemas in `packages/shared` makes zod a dependency of shared (and transitively of the api). This -is deliberate and acceptable: the frontend already bundles zod, so browser bundle weight is -unchanged; add it via the root `catalog:` on the same major version the frontend uses. - -### The suites: one contract test file per service, two modes - -Location: `apps/api/src/tests/contracts/.contract.test.ts` (the fakes live in -`apps/api`, and the api workspace already has the env/preload infrastructure). - -Each file has two halves running the **same schema assertions**: - -1. **Hermetic half** (default, PR-blocking): instantiate the fake, call each covered method, - `schema.parse()` the result. Cheap, no network, guarantees the fake never drifts from the - declared contract. -2. **Live half** (`describe.skipIf(!process.env.RUN_LIVE_TESTS)`): the real client against the - partner sandbox, same parses. The existing preload already disables the fetch guard and fake - installation under `RUN_LIVE_TESTS=1`, so this follows the established convention (see the - Mykobo integration tests). Skips cleanly (with a log line) when the required credentials are - absent from `.env`. - -Where a method's fake output and live output can both be produced, the shared assertion is a -plain function (`assertOnrampQuoteContract(quote)`) called from both halves — no test -parameterization framework needed. - -### Sandbox state - -Anchor sandboxes are stateful (KYC'd customers, fiat accounts, rate limits). Per anchor: - -- Use **pre-provisioned sandbox fixtures** (a KYC-approved test customer, a registered fiat - account) whose ids live in `.env` alongside the sandbox credentials, documented in - `.env.example`. Creating these per-run is not worth the flakiness of driving KYC flows in CI. -- Live tests must be **idempotent and cheap**: create quotes freely (they expire), create at most - one transaction per direction per run, never depend on state left by a previous run. - -## Scope and priority - -| # | Service | Contract surface (v1) | Live credentials | -|---|---|---|---| -| 1 | SquidRouter | `/v2/route` response shape vs. what `FakeSquidRouter` emits (route tx fields the handlers read: target, value, calldata, estimate amounts, quoteId). The status endpoint is covered hermetically only — a live check needs the hash of a real, recent cross-chain transaction. | none — public API | -| 2 | Alfredpay | `getAllConfigs`, `createOnrampQuote` / `createOfframpQuote` / `getQuote`, `createOnramp` + `getOnrampTransaction` (to `AWAITING_PAYMENT`), `createOfframp` + `getOfframpTransaction`, `listFiatAccounts`, KYC status shapes, the limit-breach **error shape** | sandbox API key + pre-provisioned customer | -| 3 | Avenia (BRLA) | `createPayInQuote` / `createPayOutQuote` / `createOnchainSwapQuote`, `validatePixKey`, ticket creation + `getAveniaPayoutTicket`/`getAveniaPayinTickets` shapes, `getSubaccountUsedLimit`, balances | sandbox key + pre-provisioned subaccount | -| 4 | Price feeds | response shapes for the feeds `priceFeedService` consumes | none/free tier | - -**SquidRouter goes first** despite lower business criticality: no secrets, no partner fixtures, -and it proves the whole harness (schema conventions, both suite halves, the nightly job) end to -end in one small PR. Alfredpay (4 currencies × 2 directions, most recent investment) is the first -business-critical anchor and the template for Avenia. Each service is a self-contained milestone. - -**Milestone 5 — warn-only production parsing (separate PRs, per endpoint, not deferred to the -end):** the production client `safeParse`s responses through the same schema and logs a -structured, **redacted** warning on mismatch (schema name + failing paths, never response -bodies — they contain PII). This is the only check in the plan that runs against *production* -responses rather than sandbox ones — it is the direct hedge for the sandbox ≈ production -assumption — so an endpoint gets it as soon as its schema has survived a quiet week of -nightlies, rather than after all anchors are done. Promotion from warn to hard `parse` is a -later, per-endpoint decision once prod logs stay quiet. Kept out of the test-layer PRs because it -changes production code paths. - -## CI - -- **PR-blocking:** only the hermetic halves, which run automatically as part of the existing - `bun test:api` (they are ordinary tests in `src/tests/`). They count toward the api coverage - ratchet like any other test. -- **Nightly:** a job running `RUN_LIVE_TESTS=1 bun test src/tests/contracts/` in `apps/api`, with - sandbox credentials as repository secrets. Non-blocking, failures alert — same policy and - workflow home as the Playwright nightly (`.github/workflows/e2e.yml` or a sibling - `contracts.yml`). -- **Skips must not rot silently.** Skip-with-warning on network errors and missing credentials is - right per-test, but a nightly where *nothing* live actually ran is an outage of the drift - detector, not a pass. The nightly job sets `CONTRACT_EXPECT_LIVE=1`; under that flag the suite - fails if zero live assertions executed (e.g. credentials rotted, sandbox down all night), so a - week of green-but-empty runs is impossible. - -## Success criteria - -1. A renamed/removed field or a new status value in a sandbox response fails the nightly run with - an error naming the schema and field — not a green suite and a later production incident. -2. Every fake's output is schema-validated on every PR; a fake edited out of sync with the - contract fails locally before push. -3. Each external contract exists exactly once (the schema), used by: hermetic half, live half, - and (Milestone 5) warn-only production parsing. -4. Zero new network dependencies, secrets, or flakiness in PR-blocking CI. -5. `docs/testing-strategy.md` gains a contract-test layer row and a short section, updated in the - same PR as milestone 1. - -## Risks - -| Risk | Position | -|---|---| -| Sandbox deviates from production | Accepted (see above). Triage protocol distinguishes sandbox-only quirks from real drift; sandbox-only loosenings are documented in the schema file. | -| Sandbox downtime / flakiness | Network errors → skip-with-warning, not failure. Only schema violations on successful responses fail. | -| Sandbox rate limits | Live suite is small by design (one transaction per direction per anchor per night); quotes only otherwise. | -| Credential rot (expired keys, deleted fixtures) | Missing creds → clean skip with log locally; in the nightly, `CONTRACT_EXPECT_LIVE=1` turns an all-skipped run into a failure, and an invalid-auth response fails loudly — rot is noticed within a day, not months. | -| Schemas drift from TS types | `satisfies z.ZodType` makes renames/removals of consumed fields a compile error. Not a proof — loose schemas (`any`, needless `.optional()`, coercions) can still hide drift; the schema-discipline rules above are the actual guard. | -| False confidence | Contract tests prove shapes, not behavior (timing, webhook ordering, intermediate statuses). Stated as a non-goal; residual risk owned by monitoring. | - -## Open questions (resolve during milestone 1) - -1. Which sandbox credentials do we already hold (Alfredpay, Avenia), and can a fiat account / - KYC-approved customer be pre-provisioned in each? (Determines how much of the transaction - surface the live half can cover.) -2. How far does the Alfredpay sandbox let an offramp progress without a real on-chain deposit? - (Determines the terminal assertion point for the SELL live test.) -3. Where do nightly failures alert today (the E2E workflow's channel), and does this job reuse it? diff --git a/docs/features/maintenance-mode.md b/docs/features/maintenance-mode.md deleted file mode 100644 index b897f75dc..000000000 --- a/docs/features/maintenance-mode.md +++ /dev/null @@ -1,43 +0,0 @@ -# Maintenance Mode Documentation - -## User-Facing Documentation - -### What is Maintenance Mode? -Maintenance mode is a temporary state of the Pendulum Pay application where certain functionalities are disabled to allow for system updates, improvements, or repairs. During this time, users may experience limited access to specific features. - -### User Experience During Maintenance -When the application is in maintenance mode, users will see a prominent banner at the top of the interface indicating that maintenance is currently active. This banner will provide information about the maintenance period and any relevant messages. - -Additionally, key actions that require confirmation will be disabled, preventing users from performing operations that may be affected by the ongoing maintenance. - ---- - -## Technical Documentation - -### Overview of Architecture -The maintenance mode feature is designed to provide a seamless experience for users while allowing administrators to manage maintenance schedules effectively. For a detailed architectural overview, please refer to the [Maintenance Feature Design Document](docs/architecture/maintenance-feature-design.md). - -### Backend Implementation -The backend determines the maintenance status by querying the `maintenance_schedules` table in the PostgreSQL database. This table is managed by Sequelize and contains records that define the active maintenance windows. - -#### API Endpoint -- **Endpoint:** `GET /api/v1/maintenance/status` -- **Response:** - - The API returns a JSON object indicating whether the application is currently in maintenance mode and provides details about the active maintenance window, including: - - `is_active`: Boolean indicating if maintenance is active. - - `message_to_display`: A message to be shown to users during maintenance. - -### Frontend Implementation -The frontend fetches the maintenance status from the API endpoint mentioned above. It utilizes the Zustand store for state management, caching the maintenance status to optimize performance. - -When maintenance is active, the frontend displays a banner using the `MaintenanceBanner` component, which informs users of the ongoing maintenance. Additionally, key confirm actions are disabled through the `useMaintenanceAware` hook, ensuring that users cannot perform actions that may conflict with the maintenance process. - -### Configuration -Maintenance windows are managed by inserting or updating records in the `maintenance_schedules` database table. The key fields in this table include: -- `title`: A brief title for the maintenance window. -- `start_datetime`: The start time of the maintenance period. -- `end_datetime`: The end time of the maintenance period. -- `message_to_display`: A message that will be shown to users during maintenance. -- `is_active_config`: A boolean indicating whether the maintenance window is currently active. - -Administrators typically manage these records through direct database access or a future admin interface, as outlined in the original design document. diff --git a/docs/features/mykobo-eur-offramp.md b/docs/features/mykobo-eur-offramp.md deleted file mode 100644 index 86d8ec244..000000000 --- a/docs/features/mykobo-eur-offramp.md +++ /dev/null @@ -1,247 +0,0 @@ -# Mykobo EUR Offramp Integration Plan - -**Status**: In progress — backend ramping flow -**Owner**: this session -**Stellar EUR offramp**: untouched for now; removed in a later session after Mykobo is verified - ---- - -## Goal - -Replace the EUR offramp leg that currently runs through Stellar anchors (Spacewalk redeem → Stellar payment) with a new EVM-only flow that: - -1. Starts on any `supportsRamp: true` EVM chain (Polygon, Ethereum, BSC, Arbitrum, Base, Avalanche — **not** AssetHub, Hydration, or any substrate chain) -2. Uses Squidrouter (permit-based, AlfredPay-style) to deliver Circle USDC onto a Base EVM ephemeral account -3. Swaps USDC → EURC on Base Nabla DEX -4. Forwards EURC to Mykobo's receivables wallet (returned by their intent API) -5. Mykobo pays the user in EUR via SEPA - -KYC / profile creation is a **separate session**. This session focuses on ramping flow + quote engine only. - ---- - -## Mykobo API (https://api-dev.mykobo.app/docs/) - -### Base URLs -- Prod: `https://api.mykobo.app/v1` -- Dev: `https://api-dev.mykobo.app/v1` - -### Auth -Bearer token. Acquire via `POST /v1/auth/token` with `{access_key, secret_key}` → `{subject_id, token, refresh_token}`. Refresh via `POST /v1/auth/refresh`. Token TTL is unspecified in docs → lazy refresh on 401. - -### Required scopes (all on one token) -- `transaction:read` — list/get transactions, fees -- `transaction:write` — create intents -- `user:write` — create/get profiles (later session, but we'll request the scope now) - -### Endpoints we use in this session - -| Endpoint | Method | Purpose | -|---|---|---| -| `/v1/auth/token` | POST | Acquire bearer + refresh | -| `/v1/auth/refresh` | POST | Refresh bearer | -| `/v1/transactions/intent` | POST | Create `WITHDRAW` intent → returns `instructions.address` (Mykobo's receivables wallet) and `transaction.id` | -| `/v1/transactions/{id}` | GET | Poll status until `COMPLETED` (or fail states) | -| `/v1/fees` | GET `?value=X&kind=withdraw&client_domain=Y` | Returns fee in EURC (already correct currency) | - -### Endpoints used in later session (KYC) -- `POST /v1/profiles` (multipart, KYC docs) -- `GET /v1/profiles?email=` (lookup profile by email) - -### Critical Mykobo semantics - -- **Intent body fields**: `transaction_type="WITHDRAW"`, `wallet_address` (ephemeral 0x), `email_address` (persistent identity — auto-binds new ephemeral on each ramp), `value`, `currency="EURC"`, `ip_address`, optional `client_domain`. -- **WITHDRAW response** contains `instructions.address` = the **destination address we must send EURC to** (Mykobo's receivables wallet). It is **not** the user's IBAN. The user's IBAN is on their KYC'd profile; Mykobo pays out from their side. -- **Profile resolution errors**: - - `404 profile_not_found` — surface as registration error - - `403 kyc_required` — surface with `kyc_status` field to route to KYC flow - - `409 wallet_email_mismatch` — should not happen in our flow because Mykobo auto-binds on first use; if it does, surface -- **Fees**: returns `{total, asset: "EURC", details: [...]}`. When `client_domain` is set, fees come back in EURC for both deposit and withdraw kinds. - ---- - -## Locked Design Decisions - -| Decision | Choice | Reason | -|---|---|---| -| Source chains | All EVM chains with `supportsRamp: true` | Matches AlfredPay model | -| USDC → EURC swap venue | Nabla EURC pool on Base (`NABLA_ROUTER_BASE_EURC` / `NABLA_QUOTER_BASE_EURC`, selected via `getNablaBasePool()`) | Dedicated EURC<>USDC pool, separate from the BRLA<>USDC pool used by BRL flows | -| `wallet_address` on Mykobo intent | Ephemeral 0x | Mykobo auto-binds email→ephemeral; identity is email-based | -| When to create intent | At ramp **registration** (`prepareEvmToMykoboOfframpTransactions`) | Lets us presign the final EURC transfer to Mykobo's receivables address (BRL-EVM style) | -| Email source | Frontend reads the Supabase-authenticated user's email and passes it as the `email` query param to `GET /v1/mykobo/profiles`; backend cross-checks the param against `req.userEmail` and queries Mykobo by email via `MykoboApiService.getProfileByEmail` | Aligns with Supabase-auth profile model; avoids leaking wallet→profile linkage | -| Identity persistence | JSONB only on `RampState.state` (no new `MykoboCustomer` table yet) | "No over-engineering" rule; KYC session can normalize later | -| Mykobo client style | Singleton class mirroring `BrlaApiService` | Repo convention; easy mocking | -| Token strategy | Single shared bearer with all 3 scopes, lazy init, 401→refresh→re-acquire | Simplest robust model; matches docs | -| Fee currency | Returned as EURC directly from Mykobo (no conversion) | Confirmed by user with live API output | -| Anchor record | New migration file `0XX-mykobo-anchor.ts` inserting `mykobo_eurc` | Migrations are append-only | -| Permit pattern (cross-chain) | Reuse AlfredPay's `squidRouterPermitExecute` phase + `TokenRelayer.execute()` | TokenRelayer at `0xC9ECD03c89349B3EAe4613c7091c6c3029413785` (Polygon); for EUR offramp, Squidrouter brings funds onto **Base** ephemeral. If source chain doesn't support permit, fall back to `squidRouterApprove + squidRouterSwap` (same as AlfredPay no-permit fallback) | -| Stellar code | **Do not touch** in this session | Remove after Mykobo flow verified end-to-end | - ---- - -## Phase Sequence (EUR-EVM Offramp via Mykobo on Base) - -Mirror of BRL-EVM (`evm-to-brl-base.ts`) with USDC→EURC and Mykobo payout. - -``` -[User wallet, source EVM chain] - squidRouterApprove (nonce 0, source chain) — user approves squid router for input token - squidRouterSwap (nonce 1, source chain) — user swaps via squid → USDC lands on Base ephemeral - ─ OR (when permit supported) ─ - squidRouterPermitExecute — executor calls TokenRelayer.execute(permit + payload) - ─ OR (when permit NOT supported AND same chain) ─ - squidRouterNoPermitTransfer — direct transfer to ephemeral (Base only) - -[Backend executor / Base ephemeral] - fundEphemeral — backend sends ETH for gas - distributeFees (nonce 0, Base) — USDC fee slice to fee wallet - nablaApprove (nonce 1, Base) — approve Nabla router for USDC - nablaSwap (nonce 2, Base) — USDC → EURC on Base Nabla - mykoboPayoutOnBase (nonce 3, Base) — EURC transfer to Mykobo receivables address - → backend polls GET /v1/transactions/{id} until COMPLETED - complete - -[Cleanup — post-process worker] - baseCleanupUsdc (nonce 4, Base) — approve funding account to sweep residual USDC - baseCleanupEurc (nonce 5, Base) [NEW] — approve funding account to sweep residual EURC - baseCleanupAxlUsdc (nonce 6, Base) — approve funding account to sweep axlUSDC slippage -``` - -**Special case**: if user is already on Base with USDC, skip the squidrouter leg entirely (same shortcut as BRL-EVM line 73). - ---- - -## Quote Engine Pipeline - -New strategy `offrampToSepaEvmStrategy`, mirrors `offrampToPixEvmStrategy`: - -``` -[StageKey.Initialize] OffRampFromEvmInitializeEngine(Networks.Base) [squid quote → Base USDC] -[StageKey.NablaSwap] OffRampSwapEngineEvm(EvmToken.EURC) [USDC → EURC on Base Nabla] -[StageKey.Fee] OffRampFeeMykoboEngine [NEW] [GET /v1/fees → EURC fee] -[StageKey.Discount] OffRampDiscountEngine -[StageKey.MergeSubsidy] OffRampMergeSubsidyEvmEngine -[StageKey.Finalize] OffRampFinalizeEngine -``` - -Route resolver dispatch (in `route-resolver.ts`): - -```ts -case "sepa": - return ctx.from !== Networks.AssetHub - ? offrampToSepaEvmStrategy // EVM source → Mykobo - : offrampToStellarStrategy; // substrate source → Stellar (unchanged) -``` - -This preserves the Stellar EUR path for AssetHub sources (we'll remove it in a later session). - ---- - -## File Inventory - -### New files (8) - -1. `packages/shared/src/services/mykobo/types.ts` — request/response types -2. `packages/shared/src/services/mykobo/mykoboApiService.ts` — singleton HTTP client -3. `packages/shared/src/services/mykobo/index.ts` — re-exports -4. `apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts` — presigned tx builder -5. `apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts` — payout handler -6. `apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts` — fee engine -7. `apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm.strategy.ts` — strategy -8. `apps/api/src/database/migrations/0XX-mykobo-anchor.ts` — anchor seed migration - -### Modified files (~12) - -1. `packages/shared/src/tokens/types/evm.ts` — add `EURC = "EURC"` to `EvmToken` -2. `packages/shared/src/tokens/evm/config.ts` — EURC entry for `Networks.Base` (`0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42`, 6 decimals); optionally `BaseSepolia` -3. `packages/shared/src/constants/constants.ts` or `apps/api/src/constants/vars.ts` — add `MYKOBO_BASE_URL`, `MYKOBO_ACCESS_KEY`, `MYKOBO_SECRET_KEY`, `MYKOBO_CLIENT_DOMAIN` env vars -4. `apps/api/src/api/services/phases/meta-state-types.ts` — add `mykoboEmail`, `mykoboTransactionId`, `mykoboReceivablesAddress`, `mykoboPayoutTxHash`, `mykoboTransactionReference` -5. `apps/api/src/api/controllers/ramp.controller.ts` + `apps/api/src/api/services/ramp/ramp.service.ts` — accept optional `email` on `POST /v1/ramp/register`; thread to `prepareOfframpTransactions` -6. `apps/api/src/api/services/transactions/offramp/index.ts` — add dispatch branch for EUR EVM -7. `apps/api/src/api/services/ramp/ramp-transaction-preparation.ts` — add `OfframpMykobo` discriminator (next to `OfframpBrl`) -8. `apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts` — extend `getRequiresBaseEphemeralAddress()` for EUR offramp -9. `apps/api/src/api/services/phases/register-handlers.ts` — register `MykoboPayoutOnBasePhaseHandler` -10. `apps/api/src/database/seeders/phase-metadata.seeder.ts` (or equivalent) — register `mykoboPayoutOnBase` and `baseCleanupEurc` phases + valid transitions -11. `apps/api/src/api/services/quote/routes/route-resolver.ts` — add EVM-source branch for `sepa` case -12. `apps/api/src/api/services/quote/core/quote-fees.ts` — use `mykobo_eurc` anchor identifier when `to=sepa` and source is EVM -13. `apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts` — add `baseCleanupEurc` to cleanup sweep - -### Touch validation -- `apps/api/src/api/services/transactions/onramp/common/validation.ts:122` — currently enforces `inputCurrency === FiatToken.EURC` for onramp. **Onramp path is unaffected**; we're only adding offramp. Do not touch. -- `apps/api/src/api/services/transactions/offramp/index.ts:33` — currently `outputCurrency === FiatToken.EURC && moneriumAuthToken` routes to Monerium. Our new branch is `outputCurrency === FiatToken.EURC && isEvmSource && !moneriumAuthToken → Mykobo`. Monerium path stays as fallback for clients that still pass `moneriumAuthToken`. - ---- - -## State Metadata Fields (new on `StateMetadata`) - -```ts -mykoboEmail?: string; // persistent identity passed by frontend -mykoboTransactionId?: string; // UUID returned by POST /v1/transactions/intent (for polling) -mykoboTransactionReference?: string; // human reference from intent response -mykoboReceivablesAddress?: `0x${string}`; // instructions.address from intent (the destination of mykoboPayoutOnBase) -mykoboPayoutTxHash?: `0x${string}`; // on-chain hash of the EURC transfer (recovery support) -``` - ---- - -## Anchor record - -Insert into `Anchor` table: - -```ts -{ - identifier: "mykobo_eurc", - name: "Mykobo (EUR via Base)", - // ... whatever other fields the model requires (TBD by reading model) -} -``` - -`OffRampFeeMykoboEngine` will look it up via the same path as `offramp-avenia` does for `avenia` anchor. - ---- - -## Env Vars (new) - -| Var | Required | Default | Purpose | -|---|---|---|---| -| `MYKOBO_BASE_URL` | yes | — | `https://api-dev.mykobo.app/v1` (dev) or `https://api.mykobo.app/v1` (prod) | -| `MYKOBO_ACCESS_KEY` | yes | — | from Mykobo dashboard | -| `MYKOBO_SECRET_KEY` | yes | — | from Mykobo dashboard | -| `MYKOBO_CLIENT_DOMAIN` | no | (Mykobo defaults to `.mykobo.app`) | client domain for fee scope (e.g. `satoshipay.io`) | - ---- - -## Existing infrastructure reused (no changes needed) - -- ✅ `Networks.Base` configured with `supportsRamp: true` -- ✅ `NABLA_ROUTER_BASE_EURC` + `NABLA_QUOTER_BASE_EURC` constants (EURC pool) and `getNablaBasePool()` selector -- ✅ `calculateNablaSwapOutputEvm()` quote-time helper -- ✅ `addNablaSwapTransactionsOnBase()` tx builder -- ✅ `getEvmFundingAccount(Networks.Base)` for ephemeral derivation -- ✅ `FundEphemeralPhaseHandler.fundEvmEphemeralAccount(state, Networks.Base)` (needs `getRequiresBaseEphemeralAddress` extension) -- ✅ `BaseChainPostProcessHandler` cleanup sweep -- ✅ `createOfframpSquidrouterTransactionsToEvm()` for cross-chain bridge -- ✅ `SquidrouterPermitExecuteHandler` for permit + TokenRelayer -- ✅ `prepareBaseCleanupApproval()` for cleanup approvals -- ✅ `addEvmFeeDistributionTransaction()` for distributing protocol fees - ---- - -## Out of scope for this session - -- KYC / profile creation (`POST /v1/profiles`) — separate session -- Frontend changes -- SDK changes -- Removing Stellar EUR code — explicitly deferred to avoid merge conflicts during build-out -- ARS offramp (still goes through Stellar; Mykobo doesn't replace it) - ---- - -## Verification at end of session - -1. `bun build:shared` -2. `bun typecheck` clean -3. `bun lint:fix` clean -4. Manual: can a quote request with `from=Base, inputCurrency=USDC, to=sepa, outputCurrency=EUR` produce a quote routed through `offrampToSepaEvmStrategy`? -5. Manual: does `POST /v1/ramp/register` accept `email` and call Mykobo intent API? -6. Integration test with Mykobo dev credentials (deferred to a follow-up — needs credential setup). diff --git a/docs/features/session-id-tracking.md b/docs/features/session-id-tracking.md deleted file mode 100644 index b56e63d6f..000000000 --- a/docs/features/session-id-tracking.md +++ /dev/null @@ -1,513 +0,0 @@ -# Session ID Tracking - -## Overview - -Vortex supports session tracking to enable integrators to correlate transactions initiated through the widget with their own internal systems. This is accomplished through a session identifier that flows through the entire ramp process. - -## Key Concept - -**`externalSessionId` (Frontend) = `sessionId` (Backend)** - -These are the **same value** with different naming conventions: -- **Frontend**: Uses `externalSessionId` to indicate the ID originates from an external integrator // Meld requirement -- **Backend**: Uses `sessionId` as it represents a session identifier within the system - -## Use Cases - -Session tracking enables integrators to: -- **Correlate Transactions**: Match widget transactions with their internal order/session IDs -- **Receive Targeted Webhooks**: Filter webhook notifications by session ID -- **Track User Journeys**: Monitor the complete lifecycle of a transaction -- **Support Multiple Concurrent Sessions**: Handle multiple users/transactions simultaneously - -## Data Flow - -``` -┌─────────────────┐ -│ Integrator │ -│ System │ -└────────┬────────┘ - │ Creates session: "partner-tx-123" - │ - ▼ - Opens Widget URL: - ?externalSessionId=partner-tx-123 - │ - ▼ -┌─────────────────────────────┐ -│ Frontend (Widget) │ -│ │ -│ State Machine Context: │ -│ externalSessionId: "..." │ -└────────┬────────────────────┘ - │ - │ User creates quote - ▼ - POST /quotes - { sessionId: "partner-tx-123" } - │ - ▼ -┌─────────────────────────────┐ -│ Backend API │ -│ │ -│ QuoteTicket: │ -│ metadata.sessionId: "..." │ -└────────┬────────────────────┘ - │ - │ User registers ramp - ▼ - POST /ramp/register - { additionalData: { - sessionId: "partner-tx-123" - } - } - │ - ▼ (Validation: sessionId matches?) -┌─────────────────────────────┐ -│ Backend Validation │ -│ │ -│ ✓ Match → Proceed │ -│ ✗ Mismatch → Error 400 │ -└────────┬────────────────────┘ - │ - ▼ -┌─────────────────────────────┐ -│ Database │ -│ │ -│ RampState: │ -│ state.sessionId: "..." │ -└────────┬────────────────────┘ - │ - │ Status changes - ▼ - Webhook Notifications - { sessionId: "partner-tx-123" } - │ - ▼ -┌─────────────────┐ -│ Integrator │ -│ Webhook │ -│ Handler │ -└─────────────────┘ -``` - -## Integration Methods - -### Method 1: URL Parameter (Recommended) - -Pass the session ID directly in the widget URL: - -``` -https://www.vortexfinance.co/widget?externalSessionId=partner-tx-123&rampType=BUY&... -``` - -**Frontend automatically:** -1. Reads `externalSessionId` from URL -2. Stores in state machine context -3. Includes in quote creation -4. Includes in ramp registration - -### Method 2: Session Widget URL API - -Use the session endpoint to generate a widget URL with embedded session ID: - -```typescript -POST /session - -// Locked (with existing quote) -{ - "quoteId": "quote-uuid", - "externalSessionId": "partner-tx-123", - "walletAddressLocked": "0x..." // optional -} - -// Refresh (creates new quote) -{ - "externalSessionId": "partner-tx-123", - "rampType": "BUY", - "inputAmount": "100", - "inputCurrency": "EUR", - "outputCurrency": "USDC", - "from": "sepa", - "to": "polygon" -} - -// Response -{ - "url": "https://widget.vortex.com?externalSessionId=partner-tx-123&..." -} -``` - -## Storage Locations - -### Frontend Storage (Temporary) - -**Location**: `apps/frontend/src/machines/ramp.machine.ts` - -```typescript -context: { - externalSessionId?: string; // In-memory only -} -``` - -- ❌ NOT persisted to localStorage -- ❌ NOT persisted to database -- ✅ Only exists during widget session -- ✅ Lost on page refresh - -### Backend Storage (Persistent) - -#### 1. Quote Metadata (Optional) - -**Location**: `apps/api/src/models/quoteTicket.model.ts` - -```typescript -interface QuoteTicketMetadata { - sessionId?: string; // Stored in JSONB 'metadata' column - // ... other fields -} -``` - -**When stored**: If `sessionId` is provided in `POST /quotes` request - -#### 2. Ramp State Metadata (Always, if provided) - -**Location**: `apps/api/src/api/services/phases/meta-state-types.ts` - -```typescript -interface StateMetadata { - sessionId?: string; // Stored in JSONB 'state' column - // ... other fields -} -``` - -**When stored**: When ramp is registered with `sessionId` in `additionalData` - -## Validation Logic - -### SessionId Matching - -**Location**: `apps/api/src/api/services/ramp/ramp.service.ts` - -When registering a ramp, the backend validates that the sessionId in the request matches the sessionId stored in the quote: - -```typescript -// Validate sessionId if both are provided -const requestSessionId = additionalData?.sessionId; -const quoteSessionId = quote.metadata.sessionId; - -if (requestSessionId && quoteSessionId && requestSessionId !== quoteSessionId) { - throw new APIError({ - message: `SessionId mismatch. Quote has sessionId '${quoteSessionId}' but request provided '${requestSessionId}'`, - status: httpStatus.BAD_REQUEST - }); -} -``` - -### Validation Scenarios - -| Quote SessionId | Request SessionId | Result | -|----------------|-------------------|--------| -| ✅ Present | ✅ Present & Match | ✅ Valid - Use the value | -| ✅ Present | ✅ Present & Different | ❌ Error 400 - Mismatch | -| ✅ Present | ❌ Not provided | ✅ Valid - Use quote value | -| ❌ Not present | ✅ Present | ✅ Valid - Use request value | -| ❌ Not present | ❌ Not provided | ✅ Valid - No session tracking | - -## API Response Fields - -### Quote Response - -```typescript -POST /quotes -GET /quotes/:id - -Response: -{ - "id": "quote-uuid", - "sessionId": "partner-tx-123", // ← If provided - // ... other fields -} -``` - -### Ramp Status Response - -```typescript -GET /ramp/:id - -Response: -{ - "id": "ramp-uuid", - "quoteId": "quote-uuid", - "sessionId": "partner-tx-123", // ← Always included if available - "status": "PENDING", - // ... other fields -} -``` - -## Webhook Integration - -### Webhook Registration - -Register webhooks filtered by session ID: - -```typescript -POST /webhooks - -{ - "url": "https://integrator.com/webhook", - "sessionId": "partner-tx-123", // ← Filter by this session - "events": ["TRANSACTION_CREATED", "STATUS_CHANGE"] -} -``` - -### Webhook Payload - -All webhook payloads include the session ID: - -```typescript -{ - "eventType": "STATUS_CHANGE", - "timestamp": "2025-10-14T12:00:00Z", - "payload": { - "quoteId": "quote-uuid", - "sessionId": "partner-tx-123", // ← Match to your session - "transactionStatus": "COMPLETE", - "transactionType": "BUY" - } -} -``` - -## Complete Integration Example - -### Step 1: Create Session in Your System - -```typescript -// Your backend -const userSession = { - id: "order-2024-10-14-001", - userId: "user-123", - amount: 100, - currency: "EUR" -}; - -await db.sessions.create(userSession); -``` - -### Step 2: Generate Widget URL - -```typescript -// Option A: Direct URL -const widgetUrl = `https://widget.vortex.com?` + - `externalSessionId=${userSession.id}` + - `&rampType=BUY` + - `&inputAmount=100` + - `&fiat=EUR` + - `&crypto=USDC` + - `&network=polygon`; - -// Option B: Via API -const response = await fetch('https://api.vortex.com/session', { - method: 'POST', - body: JSON.stringify({ - externalSessionId: userSession.id, - rampType: 'BUY', - inputAmount: '100', - inputCurrency: 'EUR', - outputCurrency: 'USDC', - from: 'sepa', - to: 'polygon' - }) -}); - -const { url: widgetUrl } = await response.json(); -``` - -### Step 3: Register Webhook - -```typescript -await fetch('https://api.vortex.com/webhooks', { - method: 'POST', - body: JSON.stringify({ - url: 'https://your-api.com/webhooks/vortex', - sessionId: userSession.id, // Filter for this session only - events: ['TRANSACTION_CREATED', 'STATUS_CHANGE'] - }) -}); -``` - -### Step 4: Handle Webhooks - -```typescript -app.post('/webhooks/vortex', async (req, res) => { - const { payload } = req.body; - - // Find your session by sessionId - const session = await db.sessions.findOne({ - id: payload.sessionId - }); - - if (!session) { - return res.status(404).send('Session not found'); - } - - // Update your session status - await db.sessions.update(session.id, { - vortexStatus: payload.transactionStatus, - vortexQuoteId: payload.quoteId, - updatedAt: new Date() - }); - - // Notify your user - await notifyUser(session.userId, { - status: payload.transactionStatus - }); - - res.status(200).send('OK'); -}); -``` - -### Step 5: Query Transaction Status - -```typescript -// Later, check status using your session ID -const ramps = await fetch( - `https://api.vortex.com/ramp/history/${session.walletAddress}` -); - -const userRamp = ramps.transactions.find( - tx => tx.sessionId === userSession.id -); - -console.log('Ramp status:', userRamp.status); -``` - -## Code References - -### Frontend Files - -- **URL Parameter Parsing**: `apps/frontend/src/hooks/useRampUrlParams.ts` -- **State Machine Context**: `apps/frontend/src/machines/ramp.machine.ts` -- **Register Actor**: `apps/frontend/src/machines/actors/register.actor.ts` - -### Backend Files - -- **Session Controller**: `apps/api/src/api/controllers/session.controller.ts` -- **Ramp Service (Validation)**: `apps/api/src/api/services/ramp/ramp.service.ts` -- **Quote Model**: `apps/api/src/models/quoteTicket.model.ts` -- **State Metadata Types**: `apps/api/src/api/services/phases/meta-state-types.ts` - -### Shared Types - -- **Session Endpoints**: `packages/shared/src/endpoints/session.ts` -- **Ramp Endpoints**: `packages/shared/src/endpoints/ramp.endpoints.ts` -- **Quote Endpoints**: `packages/shared/src/endpoints/quote.endpoints.ts` - -## Troubleshooting - -### Error: "SessionId mismatch" - -**Cause**: The sessionId in the ramp registration request doesn't match the sessionId stored in the quote. - -**Solution**: -1. Ensure you're using the same `externalSessionId` throughout the flow -2. Check that the widget URL includes the correct `externalSessionId` parameter -3. Verify the quote was created with the sessionId included - -### SessionId Not Appearing in Webhooks - -**Cause**: SessionId was not provided during quote creation or ramp registration. - -**Solution**: -1. Ensure `externalSessionId` is in the widget URL -2. Verify it's being read correctly in the frontend -3. Check that it's included in the quote creation request -4. Confirm it's in the ramp registration `additionalData` - -### Webhooks Not Filtering by SessionId - -**Cause**: Webhook registration didn't include sessionId filter. - -**Solution**: -```typescript -// Register webhook WITH sessionId -POST /webhooks -{ - "url": "...", - "sessionId": "your-session-id", // ← Include this - "events": ["STATUS_CHANGE"] -} -``` - -## Best Practices - -### 1. Use Unique Session IDs - -Generate unique, non-guessable session IDs: - -```typescript -// ✅ Good -const sessionId = `${userId}-${Date.now()}-${randomUUID()}`; - -// ❌ Bad (predictable) -const sessionId = `user-${userId}`; -``` - -### 2. Track Sessions in Your Database - -```typescript -interface Session { - id: string; // Your session ID - userId: string; - vortexQuoteId?: string; // Store Vortex IDs - vortexRampId?: string; - status: string; - createdAt: Date; - updatedAt: Date; -} -``` - -### 3. Include SessionId in All Requests - -Always include the sessionId when available: - -```typescript -// Quote creation -await createQuote({ - sessionId: mySessionId, // ← Include - // ... other params -}); - -// Ramp registration -await registerRamp(quoteId, accounts, { - sessionId: mySessionId, // ← Include - // ... other data -}); -``` - -### 4. Handle Missing SessionIds Gracefully - -Not all transactions will have session IDs (direct widget access): - -```typescript -if (webhook.payload.sessionId) { - // Match to your session - await updateSession(webhook.payload.sessionId, status); -} else { - // Handle sessionless transaction - // Maybe match by wallet address or quote ID - await logUnmatchedTransaction(webhook.payload); -} -``` - -## Summary - -- **SessionId enables integrators to track transactions** through the entire ramp lifecycle -- **Same value, different names**: `externalSessionId` (frontend) = `sessionId` (backend) -- **Flows through**: URL → Frontend → Quote → Ramp → Webhooks -- **Validated on ramp registration** to ensure consistency -- **Stored persistently** in database for later retrieval -- **Optional but recommended** for production integrations -- **Webhook filtering** enables targeted notifications per session - -For questions or issues, please refer to the code references above or contact the Vortex development team. diff --git a/docs/operations-api-credential-rollout.md b/docs/operations-api-credential-rollout.md new file mode 100644 index 000000000..ad8187078 --- /dev/null +++ b/docs/operations-api-credential-rollout.md @@ -0,0 +1,99 @@ +# API Credential Production Rollout + +Status: current for the credential implementation presently in PR #1298. Its pricing, +partner-owned credential/webhook, and managed-profile steps describe the behavior now on +that branch and must be revised before rollout if +[`proposal-headless-profiles-and-pricing-plans.md`](proposal-headless-profiles-and-pricing-plans.md) +is implemented. + +## Purpose + +Cut production from legacy `api_keys` halves to one `api_credentials` row per public/secret credential without runtime fallback, ambiguous pairing, or ownerless subjects. + +## Non-Negotiable Rules + +- Do not infer a pair, owner, partner, or profile from a display name, `(Public)`/`(Secret)` suffix, prefix similarity, creation time, or list position. +- Use immutable legacy row IDs, partner IDs, profile IDs, and external user IDs in every migration decision. +- Do not export secret plaintext from the database; it is not stored there. +- Do not deploy until active legacy, unpaired, ambiguous, and ownerless counts are all zero. +- Keep credential creation disabled during the final inventory, copy, verification, and cutover window. + +## 1. Inventory And Freeze + +1. Disable self-service and admin credential creation for the migration window. +2. Export an inventory of active legacy rows containing immutable row ID, key type, partner ID, profile ID, environment, expiry, digest/hash form, and safe prefix only. +3. Record every intended production credential in an explicit reviewed manifest. Each entry must name exactly one public-row ID, one secret-row ID, one profile ID, and optional partner ID. +4. Reject duplicate row IDs, missing halves, environment/expiry disagreement, ambiguous candidates, inactive partners, missing profiles, or one row assigned to multiple manifest entries. + +The repository migration accepts an array whose entries have exactly this shape: + +```json +{ + "publicKeyId": "immutable-public-row-uuid", + "secretKeyId": "immutable-secret-row-uuid", + "profileId": "immutable-profile-uuid", + "partnerId": null, + "name": "production backend", + "expiresAt": "2027-07-31T00:00:00.000Z" +} +``` + +Display names may be included for operator readability but must never drive matching. + +## 2. Backfill Secret Digests First + +For credentials whose existing secret value must be preserved, run `bun backfill:api-key-digests` from `apps/api` with the original plaintext supplied from the partner's secret manager. Verify that each selected secret has a valid SHA-256 digest and 16-character lookup prefix before constructing unified rows. + +If plaintext is unavailable, the credential cannot be safely preserved. Exclude it from the migration manifest, reissue a unified credential after its subject is ready, distribute the new secret through the approved secure channel, and explicitly revoke both old rows before preflight. Never add a bcrypt, broad scan, old-prefix, or old-table runtime fallback. + +Digest backfill establishes secret verifiability only. It does not establish which public half, subject, or partner belongs to that secret; the reviewed immutable-ID manifest is authoritative for those relationships. + +## 3. Provision Managed Profiles + +Before migrating partner-managed credentials, ensure every manifest entry has a genuine Supabase identity and Vortex profile for the real individual, business, or technical subject. + +- Provision idempotently by immutable `(partner_id, external_user_id)`. +- Create or select the subject's customer entity and preserve provider/KYC ownership. +- Isolate associations between partners. +- Support later identity claiming without duplicate profiles, entities, or provider accounts. +- Never directly insert fake profiles or reuse a shared dummy profile across customers. +- Do not issue a ramp-capable credential until the profile owns the required eligible entity/provider account. + +Entries without an unambiguous valid subject remain blocked; they are not migrated as ownerless credentials. + +## 4. Materialize Unified Credentials + +1. Run `bun credentials:preflight --manifest ` from `apps/api`. It must prove every active legacy row is explicitly mapped exactly once or already revoked, every immutable profile/partner exists, each pair has the correct types/ownership/environment, and every secret has a SHA-256 digest. +2. Run `bun credentials:migrate --manifest `. In one database transaction it creates each unified row from the explicit pair and revokes exactly those mapped legacy rows. +3. Verify the transaction result and record each new immutable credential ID in the deployment record. +4. Do not synthesize relationships from names if the manifest is incomplete; stop and correct or reissue the entry. + +## 5. Cutover Gates + +All gates must be recorded as zero before deployment: + +- Active legacy `api_keys` rows: zero. +- Active legacy public or secret halves not represented by exactly one reviewed manifest entry: zero. +- Active unpaired or ambiguous credentials: zero. +- Active credentials without a valid profile: zero. +- Active partner-managed credentials without a valid partner: zero. +- Duplicate public values or secret digests: zero. +- Invalid/missing secret digests or non-16-character lookup prefixes: zero. +- Manifest entries inferred from display names rather than immutable IDs: zero. + +Also verify every `api_credentials` row has matching environment/expiry for both capabilities by construction, a non-null profile, and either a null partner (profile-managed) or valid partner FK (partner-managed). + +## 6. Deploy And Verify + +1. Deploy migrations and the credential-aware API with no legacy request-path reader. +2. Confirm startup passes the schema/index/constraint checks and the active-legacy-row assertion before the listener starts. +3. Smoke-test a public quote, public sanitized `GET /v1/ramp-info`, secret ramp registration, secret webhook management, secret-only operation with no public key, matching public+secret pair, and `403 CREDENTIAL_MISMATCH` for different pairs. +4. Revoke a test credential by credential ID and confirm both public and secret values fail immediately. +5. Confirm list responses never expose secret values and active creation cannot exceed five credentials per profile. +6. Re-enable credential creation only after smoke tests and telemetry review pass. + +## 7. Monitor And Roll Back Safely + +Monitor safe credential IDs/prefixes for invalid-key failures, mismatches, missing subjects, public attempts on secret-only routes, and startup assertion failures. Never emit full key values. + +Rollback may restore the previous application release only if it does not reactivate or depend on migrated legacy rows. Do not bypass startup checks or revive old-table runtime auth to recover traffic. Correct the manifest/data or reissue affected credentials, then redeploy. diff --git a/docs/testing-strategy.md b/docs/operations-testing.md similarity index 95% rename from docs/testing-strategy.md rename to docs/operations-testing.md index dc90085e1..f6dd95a2a 100644 --- a/docs/testing-strategy.md +++ b/docs/operations-testing.md @@ -22,7 +22,7 @@ together with the shared test harness (`apps/api/src/test-utils`) — see "How t | 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. The dashboard runs its own Playwright config covering auth, account selection, onboarding/KYC/KYB, recipient invitations, the MXN offramp journey, and BRL/MXN/USD/COP/ARS onramps | `apps/frontend/e2e/`, `apps/dashboard/e2e/` | Playwright (non-blocking) | +| 6. E2E | Critical Playwright journeys with a mock wallet: BRL on/offramp plus parameterized Alfredpay journeys for all four currencies in both directions. The dashboard runs its own Playwright config covering auth, account selection, onboarding/KYC/KYB, recipient invitations, the MXN offramp journey, and BRL/MXN/USD/COP/ARS onramps. The nightly job also smoke-tests deployed staging and production BUY/SELL quotes through a cross-chain Squid corridor. | `apps/frontend/e2e/`, `apps/dashboard/e2e/`, `apps/api/src/tests/deployed-quotes.e2e.test.ts` | Playwright + Bun (non-blocking) | | 7. External API contracts | Consumed-contract zod schemas (`packages/shared/src/services/*/schemas.ts`, plus `apps/api/.../priceFeed.schemas.ts`) validated against the fakes (PR-blocking) and against the real partner APIs (live, nightly, non-blocking); SquidRouter, Alfredpay, Avenia/BRLA, CoinGecko | `apps/api/src/tests/contracts/` | `bun test` / nightly `contracts.yml` | ### The invariants the suite protects @@ -245,11 +245,16 @@ Tests that hit real RPCs or sandboxes (e.g. XCM dry-runs in `packages/shared`) a `RUN_LIVE_TESTS=1` via `describe.skipIf`. They are for local debugging and optional nightly runs, never PR-blocking. +The e2e workflow sets `VORTEX_QUOTE_SMOKE_URLS` for `deployed-quotes.e2e.test.ts`, which requires +successful cross-chain BUY and SELL quotes from both staging and production. Ordinary local and +PR-blocking API runs leave the variable unset, so this deployment smoke test does not make them +network-dependent. + ### External API contracts (`apps/api/src/tests/contracts/`) The fakes and the production code share TypeScript types, but nothing else verifies those types against what partners actually return — the real clients cast `response.json()` unvalidated. The -contract suites close that gap (full design: `docs/features/contract-tests.md`): per service, a +contract suites close that gap: per service, a zod schema in `packages/shared/src/services//schemas.ts` models the raw wire JSON of the **consumed** fields, and the same schema is parsed against the fake's output (hermetic, part of the PR-blocking api suite) and against the real partner API (`RUN_LIVE_TESTS=1`, nightly @@ -349,4 +354,10 @@ cd apps/api && RUN_LIVE_TESTS=1 bun test src/api/services/phases/ cd apps/api && RUN_LIVE_TESTS=1 bun test src/tests/contracts/ ``` +For local manual BRLA and AlfredPay flow tests, set `MOCK_ANCHOR_OPERATIONS=true` on the API. Onramp mint executors +wait only for the quoted anchor token amount on the EVM ephemeral: BRLA on Base for Avenia and the AlfredPay token on +Polygon. The API log prints the exact raw amount and address. Offramps pause recoverably at `brlaPayoutOnBase` or +`alfredpayOfframpTransfer`, before any anchor-bound transfer or partner API call, so the SDK-stored ephemeral key can +be used to recover the funds. The switch is active only when `NODE_ENV=development`. + (Scripts are defined in the root `package.json`; see there for the authoritative list.) diff --git a/docs/plans/dashboard-followup-plan.md b/docs/plans/dashboard-followup-plan.md deleted file mode 100644 index 4b2bfadfb..000000000 --- a/docs/plans/dashboard-followup-plan.md +++ /dev/null @@ -1,85 +0,0 @@ -# Dashboard follow-up plan - -Deferred, non-blocking work items surfaced while wiring the dashboard to the real -backend. Each item is self-contained: it states the finding, the rationale, the concrete -change set, and how to verify. This is the queue for unresolved follow-up work, not a diff. - -Companion to `dashboard-full-product-connection.md` (the main product-connection plan). - ---- - -## 1. Drop `provider_customers.tax_reference_masked` — mask at query time instead - -**Status:** planned, not started. - -### Finding - -`provider_customers` (migration `040-create-provider-customers-kyc-cases.ts`) carries three -tax columns: - -| Column | Purpose | Read today? | -| :-- | :-- | :-- | -| `tax_reference` | Raw normalized (digits-only) tax id, avenia only. Join/aggregation key for in-flight ramp state (`ramp_states.state.taxId`, `getPendingBrlVolume`). | Yes | -| `tax_reference_hash` | sha256 of the normalized id. Backs the `ux_provider_customers_tax_hash` unique index (the "one tax id globally" dedup guard) and all runtime lookups (`findAveniaCustomerByTaxId`). | Yes | -| `tax_reference_masked` | `***…####` display string. | **No — written only, never read.** | - -`tax_reference_masked` is populated at four sites and backfilled once, but nothing consumes -it: - -- `apps/api/src/api/controllers/brla.controller.ts:230, 389, 416` — three `ProviderCustomer` create/update writes. -- `apps/api/src/test-utils/factories.ts:213` — test factory. -- `apps/api/src/database/migrations/040-…​.ts:83, 318, 325` — column definition + backfill. -- `apps/api/src/models/providerCustomer.model.ts:36, 54, 75, 161` — attribute/model wiring. - -A repo-wide grep for `.taxReferenceMasked` (reads) returns **zero** hits. The onboarding -status controller/aggregator does not read it. - -### Rationale for removal - -Because the **raw** `tax_reference` is already retained on the same row (a deliberate, -documented deviation from the unified doc's "no raw tax IDs" non-goal — it is the ramp-state -join key and cannot be dropped while legacy ramp state carries `taxId`), storing a -precomputed masked copy buys nothing: it is a lossy projection of a value we already hold. -Any masked display can be produced at read time from `tax_reference` via the existing pure -helper `maskTaxReference()` (`apps/api/src/api/services/avenia/avenia-customer.service.ts:10`). - -Keeping a second, denormalized copy of the same PII only adds write paths to keep in sync and -a second column to reason about in the security spec. - -> Note: this removes only `tax_reference_masked`. `tax_reference_hash` **stays** — it backs a -> unique index and the hashed-lookup path, and cannot be derived at query time against an -> index without a generated column (out of scope here). - -### Change set - -1. **New forward migration** (next number, `044-…`): `removeColumn("provider_customers", "tax_reference_masked")`. Its `down` re-adds the column and repopulates from `tax_reference` (`repeat('*', GREATEST(length(tax_reference)-4,0)) || right(tax_reference,4)`) so the migration is reversible without data loss. Do **not** edit `040` — it has shipped. -2. **Model** `providerCustomer.model.ts`: remove `taxReferenceMasked` from `ProviderCustomerAttributes`, the `Optional<>` creation union, the `declare`, and the `init()` column map. -3. **Write sites** `brla.controller.ts` (×3): delete the `taxReferenceMasked: maskTaxReference(...)` lines. Check whether `maskTaxReference` remains imported/used there — after this it is only used by the (future) query-time display path and possibly tests; keep the helper exported, but drop the now-unused import from `brla.controller.ts` if nothing else there uses it (surgical-changes rule). -4. **Test factory** `test-utils/factories.ts:213`: drop the field; keep the `maskTaxReference` import only if still used. -5. **Query-time masking (only where a consumer actually needs it):** none exists today, so add nothing speculatively. When the dashboard onboarding view eventually needs a masked tax display, derive it in that DTO/mapper via `maskTaxReference(row.taxReference)`. Record this as the intended pattern; do not pre-build the mapper. -6. **Keep `maskTaxReference()`** in `avenia-customer.service.ts` — it becomes the single query-time masking primitive. - -### Spec updates (same change set — Security Spec Sync) - -- `docs/architecture/unified-user-management-schema.md:91,195`: drop the `tax_reference_masked` row/field from the `provider_customers` table and the ER diagram; add a one-line note that masked display is derived at read time from `tax_reference` via `maskTaxReference`. -- `docs/security-spec/05-integrations/brla.md` ("Provider-customers cutover" section): the tax-column description currently names hash + raw; adjust to reflect that no masked copy is persisted and masking is derived on read. - -### Verification - -- `bun typecheck` (api) — the removed attribute must not break any reader (there are none). -- `grep -rin "taxReferenceMasked\|tax_reference_masked" apps/ packages/` returns only the new down-migration. -- `cd apps/api && bun migrate` then `bun migrate:revert-last` round-trips cleanly on a seeded DB; spot-check that `down` repopulates the masked column identically to the `040` backfill expression. -- Existing avenia/brla controller + `notifications-onboarding.integration.test.ts` suites still pass. - -### Risks / caveats - -- **External DB consumers.** Removal assumes nothing outside this repo (analytics job, Supabase view, BI export) reads `provider_customers.tax_reference_masked`. Confirm before shipping; if an external reader exists, expose a masked value through a view/DTO rather than the base column. -- Purely additive-then-drop; no fund-flow or auth path touches the masked column, so runtime risk is low. - ---- - -## 1.1: normalize `status` from kyc_cases and provider_costumers to a single enum across rails. -Currently Mykobo flow at least, sets to approved not SUCCESS, Avenia to accepted ... - -Also, for Avenia, add changes to store name of Companies on table (provider_customer) for quick lookup. + backend change to fill it up on kyb creation flow (right after the user has sent us the data and we relayed it, we can now query from Avenia the user and fetch -fill the table with the business name. Also add this same functionality (if name not in table ) to status endpoint to fill existing ones. ---- diff --git a/docs/plans/dashboard-full-product-connection.md b/docs/plans/dashboard-full-product-connection.md deleted file mode 100644 index 8f39e0590..000000000 --- a/docs/plans/dashboard-full-product-connection.md +++ /dev/null @@ -1,689 +0,0 @@ -# [DEPRECATED] Plan — Connect `apps/dashboard` to the live backend, realizing the full unified schema - -**Deprecated (2026-07-10).** Superseded by `docs/dashboard-app-spec.md` for purpose, user -stories and strategy. Retained for its migration phasing (§5), schema inventory (§4) and -open decisions (§12), which the spec does not duplicate. - -**Status:** Approved plan, not yet implemented. -**Goal:** Turn `apps/dashboard` into a real consumer of the same API + shared package the -frontend uses, **and** realize the complete target data model from the two architecture docs: -- `docs/architecture/unified-user-management-schema.md` -- `docs/architecture/recipient-transfers-schema.md` - -Those two documents are the **schema source of truth** (column-level detail lives there). This -plan is *how we realize them* — the phasing, the code that must change in lockstep, the -dashboard wiring, and the product decisions layered on top. - ---- - -## 0. Table of contents -1. Guiding properties -2. Decisions & refinements (what we settled) -3. Compatibility — why the frontend & SDK survive -4. The full target schema (both docs) -5. Phased migration plan (Phase 0 connects the core; Phase 1 recipient product; 2–6 identity cleanup) -6. Onboarding — sender in the dashboard, recipient via the widget -7. Recipient routes + transfer eligibility -8. Notifications & email (beyond the docs) -9. `packages/shared` + `apps/dashboard` workstreams -10. Security-spec sync -11. Sequencing -12. Risks & still-open decisions - ---- - -## 1. Guiding properties - -- **Additive, migrate-once, cut over.** Every phase adds the new tables, backfills them with a - **one-time data migration**, then points production reads at them (`add → backfill → cut over - reads`). **No dual-write and no parity-gated drop:** legacy tables are left in place as a - read-only **backup** (not kept in sync, not dropped), so a bad cut-over can be re-migrated - rather than rolled forward. -- **Frontend & SDK safe throughout.** Responses are hand-built field-by-field in - `apps/api/src/api/services/quote/engines/finalize/index.ts` (`buildQuoteResponse`) and - `apps/api/src/api/services/ramp/ramp.service.ts` (`GetRampStatusResponse`); they emit computed - values, never the raw columns we change. `partnerId`/`apiKey`/`taxId` are **outbound** request - fields, never read from a response. So even the identity refactor is invisible to consumer - apps. The **internal** code (resolution, provider services, admin endpoints) changes in - lockstep — that is the real risk, not consumer breakage. -- **Migration mechanics.** Umzug v3 + Sequelize v6 (`apps/api/src/database/migrator.ts`), - files `apps/api/src/database/migrations/NNN-kebab.ts` with `up`/`down`, camelCase attrs + - `field:` snake_case. Migrations **034–037 landed since this plan was written** (api-key - `user_id`, nullable `partner_name`, subsidy-token changes) — new files start at **038**. A - proxy already makes `createTable`/`addColumn` idempotent. Every new table must - `ENABLE ROW LEVEL SECURITY` — Supabase's `ALTER DEFAULT PRIVILEGES` grants - anon/authenticated ALL on future public tables, and RLS-with-no-policies is what keeps - PostgREST out. -- **Dashboard ships first — and fast.** Phase 0 connects the dashboard's core flow to the - existing backend with **no migrations**; Phase 1 adds the recipient product (additive); the - identity cleanup (Phases 2–6) follows without holding the dashboard back. - ---- - -## 2. Decisions & refinements (what we settled) - -These refine or resolve open questions in the two docs. - -| # | Topic | Decision | -| :-- | :-- | :-- | -| D1 | **Invite model** | **Link-based, not email.** `token_hash` is the primary redemption key; `invitee_email` is optional metadata; acceptance is **token-bound** (match email only if one was recorded). Refines `recipient-transfers-schema.md` (which was email-first) to match the shipped dashboard (`apps/dashboard/src/domain/recipient.ts`). | -| D2 | **Recipient onboarding** | A recipient is a full `customer_entity` and onboards through the **frontend widget** via `?kybLocked=` — link-out, **for now** (§6.2). Resolves recipient-doc open-Q "onboarding reusable across senders" → **yes** (recipient owns their own entity/onboarding). | -| D2b | **Sender onboarding** | **Revised 2026-07: in the dashboard**, not the widget (§6.1). The original plan link-ed senders out to `?kybLocked=`; that bounced an authenticated sender to another origin with no return path. The dashboard owns the sender KYC/KYB wizard; it is **mocked today** and will be unmocked by reusing/porting the widget's KYC machines. No new onboarding backend either way. | -| D3 | **Payout instrument** | `recipient_payout_references` is a **thin pointer** — `provider_instrument_id` + masked label to a provider-side instrument (AlfredPay `fiatAccounts`, BRLA PIX key). No reusable payout PII stored. Resolves recipient-doc open-Q "multiple refs per corridor" → single verified reference per corridor for v1. | -| D4 | **Multi-account** | **Not in v1.** One `customer_entity` per profile (individual *or* business). Switching accounts = logout/login. Resolves unified-doc open-Q "multiple customer_entities per profile" → **no, for v1** (schema stays capable via a non-unique `profile_id`). | -| D5 | **Per-corridor status** | A single read surface (`GET /v1/onboarding/status`) that returns `[{country, rail, status}]`. Pre-Phase-3 it fans out over the existing provider tables; post-Phase-3 it's a clean query over `provider_customers` + `kyc_cases`. | -| D6 | **Status column type** | New status columns use **`VARCHAR` + `CHECK`**, not Postgres `ENUM` — these product states will gain values and `CHECK` is far easier to evolve. (Deliberate, documented deviation from the older ENUM tables.) | -| D7 | **Email** | **Supabase**, matching the frontend. Note: Supabase's mailer is auth-template-oriented, so transactional/status emails go via its SMTP config or an edge function — not a one-liner. | -| D8 | **Subaccount ownership** | The unified doc's ownership invariant is **in scope** (Phase 5), not a separate side-fix. | - -**Still open (need a human — flagged in §12):** tax-ID global-uniqueness semantics; KYC/failure-payload retention; retention when a profile is deleted but compliance records must remain; whether payout details are editable pre-approval. - ---- - -## 3. Compatibility — why the frontend & SDK survive - -| Consumer | Reaches the API via | Verdict | Evidence | -| :-- | :-- | :-- | :-- | -| **apps/dashboard** | replacing its own mocks | Safe by construction | owns the changed code | -| **apps/frontend** | HTTP; `partnerId`/`apiKey`/`taxId` outbound only | **Safe** | responses DTO-mapped; never exposes `partner_name`/`buy_partner_id`/`tax_id`/provider columns | -| **packages/sdk** | same shared DTOs | **Safe** | no provider/partner/tax columns anywhere in `packages/sdk/src` | -| **/v1/admin/\*** | serializes raw columns (`partnerName`, `buyPartnerId`…) | **Changes in lockstep** | Phase 2/4 update these; not called by either app | - -The only consumer-visible change is the **new** `@vortexfi/shared/types` export (additive; `"."` -untouched) and **new** routes. Every existing endpoint keeps its response shape. - ---- - -## 4. The full target schema (both docs) - -Column-level definitions are in the two architecture docs; this is the inventory with our -refinements. Legend: **KEEP · SPLIT · FOLD · REFACTOR · NEW**. - -### From `unified-user-management-schema.md` -| Table | Action | Notes / refinement | -| :-- | :-- | :-- | -| `profiles` | KEEP | login identity only | -| `customer_entities` | **NEW** | owner anchor; one per profile in v1 (D4) | -| `partners` | **SPLIT** | → `partners` (unique `name`) + `partner_pricing_configs` | -| `partner_pricing_configs` | **NEW** | per `ramp_type`; `UNIQUE(partner_id, ramp_type)` | -| `profile_partner_assignments` | REFACTOR | collapse `buy/sell_partner_id` → one `partner_id` | -| `api_keys` | REFACTOR | drop `partner_name`; add `partner_id` FK + `profile_id` | -| `mykobo_customers` | **FOLD** | → `provider_customers` (`provider=mykobo`) | -| `alfredpay_customers` | **FOLD** | → `provider_customers` (`provider=alfredpay`) | -| `tax_ids` | **SPLIT** | Avenia subaccount → `provider_customers`; KYC workflow → `kyc_cases` | -| `kyc_level_2` | **REPLACE** | → `kyc_cases` (note: `kyc_level_2` is dead in `apps/api` — replace, don't migrate data unless a read is found) | -| `provider_customers` | **NEW** | unified rail account; `customer_entity_id` NOT NULL; uniques per doc | -| `kyc_cases` | **NEW** | unified KYC/KYB attempts; `type` ∈ {kyc, kyb} | -| `quote_tickets` | KEEP | `partner_id`/`pricing_partner_id` now point at new `partners` | -| `ramp_states` | KEEP | — | - -Apply **D6** (`VARCHAR`+`CHECK`) to the new status columns on `customer_entities`, -`provider_customers`, `kyc_cases`. - -### From `recipient-transfers-schema.md` -| Table | Action | Notes / refinement | -| :-- | :-- | :-- | -| `recipient_invitations` | **NEW** | **link-based** (D1): `token_hash` primary, email optional | -| `sender_recipients` | **NEW** | `UNIQUE(sender, recipient)` | -| `recipient_payout_references` | **NEW** | thin pointer (D3) | -| `transfer_eligibility` | **NEW** | view/function *or* service — see §7 | - -### Beyond the docs (dashboard need) -| Table | Action | Notes | -| :-- | :-- | :-- | -| `notifications` | **NEW** | in-app feed (§8) | -| `notification_preferences` | **NEW** | per-profile prefs (§8) | - -### 4.1 Invariants, principles & non-goals carried from the docs - -**Ownership invariant (unified doc):** an Avenia subaccount (`provider_customers`, -`provider=avenia`) is owned by exactly one `customer_entity` and usable only by a principal that -owns it. **Principal resolution** — UI: Supabase token → `profile` → `customer_entity`; SDK/API: -secret key → `api_keys.profile_id` → `customer_entity` (a key binds to one customer). Enforced in -Phase 5, which therefore **depends on Phase 4's `profile_id`** for SDK principals. - -**Recipient principles (recipient doc):** -- Recipients are normal customers (`profile` + `customer_entity`), not a separate identity model. -- The sender owns the *relationship*, not the recipient's identity — **one recipient can be - linked to many senders** (`sender_recipients`, `UNIQUE(sender, recipient)`). -- Provider-host payout instruments — provider references + masked metadata only, never reusable - PIX/IBAN/ACH/CLABE/CBU PII locally. - -**Non-goals for the first implementation (unified doc) — carried verbatim:** -- Don't remove legacy tables — keep them as a read-only **backup** after cut-over (amends the - doc's expand/contract: migrate once and cut over, no dual-write, no parity-gated drop). -- **Don't collapse `partner_id` and `pricing_partner_id`** — Phase 2 keeps both on `quote_tickets`. -- A profile↔partner assignment grants **pricing only, not ownership**. -- Don't store raw API secrets or raw tax IDs. -- Don't auto-assign an owner to an unclear subaccount during migration. - -**Deferrals (unified doc "resolved in review"):** a secret key binds to exactly one customer; -per-provider detail tables and an `environment`/sandbox column are **not** added now; the -dashboard onboarding-status projection is a **view** (D5), not a stored table. From the folds: -`tax_ids` quote-provenance columns are dropped (unless a live read is found), and the phantom -`alfredpay_customers.email` index is dropped. - -### 4.2 Consolidated relationship graph - -Both docs' models joined into one picture, with `customer_entities` as the shared anchor between -the identity model and the recipient graph (neither source doc shows this join). This is the -**target end-state** — see "Current structure (today)" below for how the live DB differs before -the phases run. Relationship-level only — **full columns live in the two architecture docs**. - -```mermaid -erDiagram - profiles |o--o{ customer_entities : owns - profiles |o--o{ api_keys : owns - profiles ||--o{ quote_tickets : owns - profiles ||--o{ ramp_states : owns - profiles ||--o{ profile_partner_assignments : has - profiles ||--o{ recipient_invitations : created - profiles ||--o{ notifications : receives - profiles ||--o| notification_preferences : has - - customer_entities ||--o{ provider_customers : owns - customer_entities ||--o{ kyc_cases : verifies - provider_customers ||--o{ kyc_cases : may_have - - customer_entities ||--o{ recipient_invitations : sends - customer_entities ||--o{ sender_recipients : sender - customer_entities ||--o{ sender_recipients : recipient - customer_entities ||--o{ recipient_payout_references : payee - recipient_invitations |o--o| sender_recipients : becomes - sender_recipients ||--o{ recipient_payout_references : has - - partners ||--o{ partner_pricing_configs : prices - partners ||--o{ api_keys : attributed_to - partners ||--o{ profile_partner_assignments : assigned - partners ||--o{ quote_tickets : owns - partners ||--o{ quote_tickets : prices - quote_tickets ||--o| ramp_states : creates - - profiles { - UUID id PK - TEXT email UK - } - customer_entities { - UUID id PK - UUID profile_id FK - TEXT type "individual|business" - TEXT status - } - provider_customers { - UUID id PK - UUID customer_entity_id FK - TEXT provider "mykobo|alfredpay|avenia" - TEXT rail - TEXT provider_subaccount_id - } - kyc_cases { - UUID id PK - UUID customer_entity_id FK - UUID provider_customer_id FK - TEXT type "kyc|kyb" - TEXT status - } - recipient_invitations { - UUID id PK - UUID sender_customer_entity_id FK - UUID created_by_profile_id FK - UUID accepted_by_profile_id FK - TEXT token_hash UK - TEXT invitee_email "optional" - TEXT status - } - sender_recipients { - UUID id PK - UUID sender_customer_entity_id FK - UUID recipient_customer_entity_id FK - UUID invitation_id FK - TEXT relationship_status - } - recipient_payout_references { - UUID id PK - UUID sender_recipient_id FK - UUID recipient_customer_entity_id FK - TEXT instrument_type - TEXT provider_instrument_id - TEXT status - } - partners { - UUID id PK - TEXT name UK - } - partner_pricing_configs { - UUID id PK - UUID partner_id FK - TEXT ramp_type "BUY|SELL" - } - api_keys { - UUID id PK - UUID profile_id FK - UUID partner_id FK - TEXT key_type - } - profile_partner_assignments { - UUID id PK - UUID user_id FK - UUID partner_id FK - } - quote_tickets { - UUID id PK - UUID user_id FK - UUID partner_id FK - UUID pricing_partner_id FK - } - ramp_states { - UUID id PK - UUID user_id FK - UUID quote_id FK - } - notifications { - UUID id PK - UUID profile_id FK - UUID customer_entity_id FK - TEXT type - TIMESTAMP read_at - } - notification_preferences { - UUID id PK - UUID profile_id FK - BOOL email_enabled - } -``` - -`customer_entities.profile_id` and `api_keys.profile_id` are nullable by design (compliance -records outlive a deleted profile; partner-wide keys have no profile) — hence `|o` on the owner -side. Other owner FKs are drawn `||` for readability. - -**By phase** (mermaid can't colour nodes, so read it here): -- **Phase 1 (new):** `customer_entities`, `recipient_invitations`, `sender_recipients`, - `recipient_payout_references`, `notifications`, `notification_preferences`. -- **Phase 2:** `partners` (→ unique `name`) + new `partner_pricing_configs`. -- **Phase 3 (new):** `provider_customers`, `kyc_cases` (fold mykobo/alfredpay/tax_ids/kyc_level_2). -- **Phase 4:** `api_keys` gains `profile_id` + `partner_id`, drops `partner_name`. -- **Unchanged:** `profiles`, `quote_tickets`, `ramp_states`, `profile_partner_assignments`. - -**Current structure (today, before the refactor).** The ERD above is the target; verified against -the live models, today's DB differs — and **Phase 0/1 run against this current structure**: -- `partners` is keyed `(name, ramp_type)` (**non-unique**) and holds pricing **inline** (markup, - vortex-fee, discounts, payout addresses). `partner_pricing_configs` doesn't exist yet (Phase 2). -- `api_keys` links to a partner by a `partner_name` **string** (no FK) and has **no** - `profile_id`/`partner_id` (added Phase 4). -- `profile_partner_assignments` uses `buy_partner_id` + `sell_partner_id` (two FKs), not a single - `partner_id` (collapsed Phase 2). -- Provider identity lives in **`mykobo_customers`** (`user_id` unique), **`alfredpay_customers`** - (`user_id`+`country`+`type`), **`tax_ids`** (`tax_id` PK, `user_id` **nullable**, - `sub_account_id`) and **`kyc_level_2`** — all keyed by profile/`user_id`, **not** - `customer_entity`. `provider_customers`/`kyc_cases` don't exist until Phase 3, and the legacy - tables aren't dropped until Phase 6. -- **So Phase 1's eligibility service + status aggregator read these legacy provider tables**, not - `provider_customers`. - ---- - -## 5. Phased migration plan - -Phase 0 connects the dashboard's core flow with **no migrations**. Phase 1 adds the recipient -product (additive). Phases 2–6 realize the unified-doc identity model in the exact order its own -"Migration (additive, phased)" section prescribes. Each phase: migrations → backfill → code in -lockstep → verify. - -### Phase 0 — Connect the core flow (no new tables, ship in days) -The fastest path to a live dashboard: the endpoints it needs already exist. **Zero migrations.** -- **`packages/shared` S1** — add the `./types` export (§9). -- **CORS** — add `:5174` (dev) to `config/express.ts` (prod is same-origin under `/dashboard/`). -- **`apps/dashboard` D** — real `api-client` (mirror `apiFetch`); real Supabase OTP auth → - `/v1/auth/*`; `.env.example`; swap the quote/ramp/history mocks for the existing - `/v1/quotes`, `/v1/ramp/*` endpoints; onboarding *status* → `/v1/onboarding/status` (D5). - The sender onboarding **wizard stays in the dashboard and stays mocked** (§6.1) — unmocking - it is follow-up work, not Phase 0. - -**Result:** login, quote, ramp, status, history all run against the real backend; per-corridor -onboarding status is read from the aggregator. No recipient/transfer features yet — but the -dashboard is genuinely connected. - -**Verify:** `bun --cwd apps/dashboard typecheck` / `build`; manual walkthrough — login → quote → -ramp → status → history; corridor cards reflect real `/v1/onboarding/status` on load. - -### Phase 1 — Recipient product (additive migrations) — **landed (2026-07)** -Adds the net-new invite/transfer surface. All additive; nothing existing is altered. -Shipped as migrations `038`/`042`/`043` plus `/v1/recipients`, `/v1/notifications` and -`GET /v1/onboarding/status` (reading `provider_customers`+`kyc_cases` directly, since Phase 3 -landed first). Still open from this phase: the §7.1 payout-instrument mechanism (A vs B) and -email dispatch (D7 transport). - -**Migrations** (`034`–`036`): -- `034-create-customer-entities.ts` — `customer_entities`; backfill one `individual`/`active` - entity per existing profile (idempotent `LEFT JOIN … IS NULL`, UUIDs via `gen_random_uuid()`). - New profiles get one via a `findOrCreate` in the `/v1/auth/verify-otp` handler - (`auth.controller.ts`). -- `035-create-recipient-tables.ts` — `recipient_invitations` (D1), `sender_recipients`, - `recipient_payout_references` (D3). One file, atomic revert; all FK to `customer_entities`. -- `036-create-notifications.ts` — `notifications` + `notification_preferences` (§8). Independent - of the recipient tables; can land in any order within this phase. - -**Code:** recipient routes + eligibility service (§7); notifications routes + dispatch (§8); -`GET /v1/onboarding/status` aggregator (D5, initially fanning out over existing provider tables). - -**Why it's safe without the identity refactor:** recipients onboard via the widget into the -**existing** `mykobo_customers`/`alfredpay_customers`/`tax_ids` tables keyed by profile; the -eligibility service and status aggregator read those. `provider_customers` is not required to -ship the dashboard. - -**Verify:** `bun migrate` clean; backfill count == `profiles`; end-to-end invite → widget -onboarding → verified payout → transfer allowed. - -### Phase 2 — Split `partners` (unified-doc step 1) -- Migration: create `partners` (unique `name`) + `partner_pricing_configs`; dedup the current - 1–2-rows-per-name into a canonical partner; move pricing columns into configs keyed by - `(partner_id, ramp_type)`. -- Repoint FKs: `quote_tickets.partner_id`/`pricing_partner_id`, - `profile_partner_assignments` (`buy/sell_partner_id` → `partner_id`), api-key resolution. -- **Code in lockstep:** `partner-resolution.ts`, `profilePartnerAssignments.controller.ts`, - `ramp.service.ts`, `feeDistribution.ts`, and the admin controllers - (`admin/profilePartnerAssignments.controller.ts`, `admin/partnerApiKeys.controller.ts`). -- Verify: pricing resolves identically before/after for a fixed quote set; `bun test`. - -### Phase 3 — `customer_entities` provider/KYC unification (unified-doc step 2) -- Migration: create `provider_customers` + `kyc_cases` (uniques per doc). Backfill from - `mykobo_customers`, `alfredpay_customers`, the Avenia half of `tax_ids`; attach each to its - owning `customer_entity`. `kyc_level_2` is dead (§4), so `kyc_cases` supersedes it with **no - data conversion** — `kyc_level_2` is left in place as a dead backup, not dropped. -- **Code in lockstep:** `mykobo/mykobo-customer.service.ts`, `alfredpay.controller.ts` (~20 - sites), `brla.controller.ts` (many), the BRLA phase handlers, `ramp.service.ts` taxId reads. -- **Payoff:** the D5 aggregator and the eligibility check swap their internals to a single clean - query over `provider_customers` + `kyc_cases`; `transfer_eligibility` can become a real view. -- Verify: every provider read returns the same result via the new table; ownership backfill - quarantines any unclear owner (no auto-assign). - -### Phase 4 — Refactor `api_keys` (unified-doc step 3) — **half already landed** -- **Already shipped** (migrations 034/035 + user-scoped-key work): the doc's `profile_id` - exists as `api_keys.user_id`; `partner_name` is nullable; user-scoped keys (NULL - `partner_name`, `user_id` set) authenticate purely as the linked user via - `getEffectiveUserId`. Do NOT add a duplicate `profile_id` column or rename. -- Remaining migration: add `partner_id` FK (backfill from `partner_name`) + `scopes` + - `revoked_at`; cut authorization over to `partner_id`, leaving `partner_name` in place as a - backup column (no dual-write, not dropped). Existing partner-wide keys keep a null - `user_id` until re-keyed (SDK ownership check applies only to keys that have one). -- **Code:** `apiKeyAuth.helpers.ts` (`validateSecretApiKey`/`validatePublicApiKey`), - `dualAuth.ts`, `enforcePartnerAuth`, `validatePartnerMatch`, admin `partnerApiKeys.controller`. -- Verify: name-equality authorization is preserved through the FK; key auth integration tests. - -### Phase 5 — Enforce subaccount ownership (unified-doc step 4) — **mostly landed; 3 gaps remain** -- **Already shipped** (dualAuth/effectiveUser/ownershipAuth work): `getAveniaUser` AND - `getAveniaUserRemainingLimit` both require an effective user and 403 on non-owned taxIds; - `createSubaccount` conflict-checks and claims; every alfredpay customer/fiat-account query - filters by the effective user. (The unified doc's claims here are stale.) -- **Remaining gaps** to close during the provider cutover: `fetchSubaccountKycStatus` - (`GET /v1/brla/getKycStatus` — no ownership check, and it *writes* status transitions), - `getSelfieLivenessUrl`, and `getKybAttemptStatus` (attemptId passed upstream with no - tenancy check). Reject quote/ramp creation targeting a `provider_customer` the - authenticated principal doesn't own. -- **Principal resolution** per §4.1 — SDK-key principals resolve via `api_keys.profile_id`, so - this phase depends on Phase 4. No lazy null-owner adoption. -- Verify: a principal cannot use a `provider_customer` it doesn't own (both UI and SDK-key - principals); regression tests. - -### Phase 6 — Finalize cut-over; keep legacy as backup (unified-doc step 5) -- Confirm no code still reads the legacy tables/columns and **retain them as a read-only backup — - do not drop** (a manual drop can happen later, out of band, once the backup is no longer - wanted). Security-spec updated in the same change set (§10). - ---- - -## 6. Onboarding — sender in the dashboard, recipient via the widget - -**Revised 2026-07.** An earlier revision of this plan routed *both* sender and recipient -onboarding to the widget via `?kybLocked=`, and Phase 0 deleted the dashboard's mocked wizards on -that basis. That decision is **reversed for senders**: a sender who logged into the dashboard -should not be bounced to a different origin, lose their session context, and have no way back — -the widget's redirect carries no `returnUrl`. Sender onboarding is a first-class dashboard -surface. Recipient onboarding stays on the widget **for now** (§6.2). - -### 6.1 Sender onboarding = in the dashboard - -The sender completes KYC/KYB inside `apps/dashboard`, on the Onboarding (overview) page. The -wizard is `OnboardingWizard` → `HeadlessFlow` | `ExternalFlow`, routed per corridor by -`routeFor(corridorId, kind)` in `apps/dashboard/src/domain/corridors.ts`: - -| Route | Corridors | Surface | -| :-- | :-- | :-- | -| `headless` | BR, EU (individual), MX, CO, AR | In-dashboard stepped form (`HeadlessFlow`) | -| `google_form` | EU **company** KYB | External Google Form, then confirm in-dashboard | -| `redirect` | US (both kinds) | Partner redirect, then confirm in-dashboard | - -Headless steps come from `getOnboardingSteps(corridorId, kind)`: company KYB collects company -details → authorized representative → documents; individual KYC collects personal details → -documents, with Brazil (Avenia) additionally running a liveness selfie step. - -**Status today: the wizard is a mock.** `WizardStepFields` renders uncontrolled inputs, the -dropzones send no file, and nothing is submitted — the `verifying → in_review → approved` tail is -simulated latency in `headlessOnboarding.machine.ts` / `externalOnboarding.machine.ts`. Because -the wizard submits nothing, `GET /v1/onboarding/status` (D5) cannot see it, so a session-only -`useOnboardingOverrideStore` overlays wizard-advanced statuses on top of the aggregator's real -ones (`useActiveAccount`). A reload drops back to real provider status. **This overlay is -scaffolding and must be deleted when the wizard drives real KYC** — tracked in the follow-up -plan (`dashboard-followup-plan.md`, "Unmock sender onboarding"). - -**Unmocking it** means reusing or porting the widget's KYC/KYB state machines and provider calls -(`apps/frontend/src/machines/{brlaKyc,alfredpayKyc,mykoboKyc}.machine.ts`, `kyc.states.ts`, and -the `Avenia*` / `Alfredpay*` form components) rather than re-implementing them. The provider -endpoints already exist; **no new onboarding backend is required** — that part of the original -§6 still holds. See the follow-up plan for the reuse-vs-port analysis. - -### 6.2 Recipient onboarding = widget link-out (for now) - -Unchanged, and still the right call for v1: a recipient has no dashboard account, no wallet, and -no session — sending them to the widget is the shortest path to a `customer_entity` with an -approved provider account. The sender creates an invite (`POST /v1/recipients/invite`) and shares -`inviteUrl(token)`, which opens `/widget?kybLocked=&invite=`. After OTP -authentication, the widget redeems the token before entering the locked-region KYC/KYB flow. -Status returns via the D5 aggregator exactly as before. - -Verified real: `apps/frontend/src/types/searchParams.ts:26` — `?kybLocked=BR` pins the KYB region -and skips the selector (also `?kyb=` for the non-locked case), driving the widget's existing -KYC/KYB flow, which already calls the AlfredPay/BRLA/Mykobo endpoints and handles external -provider redirects internally. `apps/dashboard/src/lib/widget.ts` (`onboardingUrl`) holds the -deep-link construction; it is retained for this path. - -**Known gap — EU only.** `KYB_REGIONS` (`apps/frontend/src/constants/kybRegions.ts`) now covers -**BR, MX, CO, AR, US**. AR was previously absent from that list purely by omission — the -`alfredpayKyc` machine already handled it everywhere it handled MX/CO (plus an AR-only selfie -upload), `ArKycFormScreen` existed, and `KYC_CHILD_BY_FIAT[ARS]` already routed to it; adding the -region entry was a one-line fix (2026-07). - -**EU remains genuinely blocked**, for the reason `kybRegions.ts` gives: Mykobo is individual KYC -only and requires a connected wallet, so it cannot complete a quote-less KYB deep link. -`onboardingUrl("EU")` falls back to the widget home. EU **company** KYB is the sharp edge — the -dashboard routes it to a Google Form (§6.1) and the widget has no equivalent destination. Recipient -onboarding in EU is therefore **not yet reachable end-to-end**; see §12.9. - ---- - -## 7. Recipient routes + transfer eligibility - -Mounted under `/v1/recipients`, guarded by `requireAuth` (sender). Redemption is **token-bound** -(D1): the recipient presents the link token; if the invite recorded an email, additionally match -it. - -| Method & path | Purpose | -| :-- | :-- | -| `POST /v1/recipients/invite` | create invite (`country`, `rail`, `payout_currency`, `amount`; `invitee_email` optional); returns the link, stores only `token_hash` | -| `POST /v1/recipients/invite/:token/accept` | recipient accepts → resolves/creates their `customer_entity`, creates `sender_recipients`, marks accepted | -| `GET /v1/recipients` | sender lists recipients + relationship/onboarding status | -| `PATCH /v1/recipients/:id` | nickname / block / archive (updates `sender_recipients`) | -| `GET /v1/recipients/:id/eligibility` | `{ canCreateTransfer, blockingReasonCode }` | - -**Eligibility** (`transfer-eligibility.service.ts`) returns `canCreateTransfer` only when: invite -accepted **and** relationship active **and** recipient onboarding approved for country/rail -**and** a `recipient_payout_references` row is `verified` **and** provider status allows payouts; -else a `blockingReasonCode` (`invite_not_accepted`, `recipient_onboarding_pending`, -`provider_payout_reference_unverified`, `provider_restricted`). Enforced at quote/ramp creation -**only when the request carries a recipient context** — existing frontend ramps bypass it. Pre- -Phase-3 it reads existing provider tables; post-Phase-3 it (or a `transfer_eligibility` view) -reads `provider_customers` + `kyc_cases`. - -### 7.1 Recipient payout instrument — **Widget receive mode selected** - -**Decision:** option (A). Invited recipients add their payout instrument in the same widget session -as their locked-corridor onboarding. The implementation still blocks the eligibility gate from -flipping to `verified`, but its product location is no longer open. - -The dashboard separately owns Alfredpay fiat-account setup for the authenticated sender's **self -offramps**. That account-management UI does not create `recipient_payout_references` and does not -solve invited-recipient payout capture. - -**The problem.** A recipient "receiving" needs KYC/KYB **plus a payout account** (PIX key / -CLABE / IBAN / bank account) — the thing `recipient_payout_references.provider_instrument_id` -points at. The widget already captures payout accounts for a self-offramp, so the machinery -exists; the open question is *where we run it* for a recipient (who has no wallet/quote/ramp). - -Provider endpoints that already create/validate payout instruments: AlfredPay -`POST /v1/alfredpay/fiatAccounts` (driven by `GET /fiatAccountRequirements`) → durable -fiat-account id; BRLA `GET /v1/brla/validatePixKey` → validated PIX key; Mykobo IBAN in the -profile flow. - -**Decision detail.** -- **(A) — selected — Widget "receive" mode.** Extend the existing `?kybLocked=` hand-off so - the same widget session also runs the provider's payout-account step (skipping wallet/quote/ - ramp), pinned to the invite's corridor. On provider confirmation, a webhook/poll writes a thin - `recipient_payout_references` (id + masked label, `pending → verified`). Keeps all payout - capture in one place (the widget); no recipient PII in the sender's hands. **Cost:** a bounded - widget change (decouple payout-account setup from a ramp). -- **(B) — fallback — Dashboard payout form.** The dashboard renders a thin payout form driven by - the existing `fiatAccountRequirements` / `validatePixKey` endpoints, calls them directly, then - the API records the reference. No widget change, but the dashboard re-implements a - provider-specific payout form (the fragmentation we're otherwise avoiding). Timeline hedge only. -- **(C) — rejected** — capture payout details at transfer time from the sender: conflicts with - the invite model (puts recipient bank PII in the sender's hands). - -**Per-provider wrinkle to resolve with the choice.** Some providers give a durable payout- -instrument object (AlfredPay `fiatAccounts`), others take payout details per-ramp (BRLA PIX). So -`recipient_payout_references` stores a durable id where one exists, else a masked reference + -validation status re-validated at transfer time — **never** raw PIX/IBAN/CLABE PII locally. - ---- - -## 8. Notifications & email (beyond the architecture docs) - -The one dashboard need not covered by either doc. No notifications table or transactional email -exists today (only Supabase OTP, a Slack ops alert, and a Google-Sheet lead capture). - -- **Migration `036`:** `notifications` (`profile_id` FK CASCADE, `customer_entity_id` nullable, - `type`, `title`, `body`, `metadata` JSONB, `read_at`, `created_at`; index `(profile_id, - created_at)`); `notification_preferences` (`profile_id` UNIQUE, `email_enabled`, `prefs` - JSONB). Status/type columns use `VARCHAR`+`CHECK` (D6). -- **Routes** (`/v1/notifications`, `requireAuth`): feed, mark-read, read-all, get/put preferences. -- **Dispatch:** `NotificationService.emit(profileId, event)` writes an in-app row and, if prefs - allow, sends email via **Supabase** (D7 — SMTP/edge function). Triggers: onboarding status - change, invite created (email the link when present), ramp completion. - ---- - -## 9. `packages/shared` + `apps/dashboard` workstreams - -**`packages/shared` — S1: OBSOLETE (2026-07).** The transfer flow was ported from the widget's -ramp machine, so the dashboard needs shared's *runtime* signing helpers -(`signUnsignedTransactions`, ephemeral creation, `ApiManager`) — it now depends on the full -`"."` export like the frontend does, and a types-only entry no longer buys anything. The -hand-copied wire `types.ts` stays for UI-facing DTO shapes (accurate string-date wire types); -route-level code splitting keeps the blockchain graph out of non-transfer pages. - -**`apps/dashboard` — D (partially landed 2026-07):** ✅ real `api-client` + Supabase OTP auth → -`/v1/auth/*` + `.env.example`; ✅ quote/ramp mocks swapped (payout-driven quote inversion; -`transfer.machine.ts` = ported widget ramp core: register → ephemeral presign → user wallet -sign → start → poll); ✅ onboarding *status* → `/v1/onboarding/status`. Still mocked: recipients -page → `/v1/recipients/*`, notifications → `/v1/notifications`, transactions history, and the -**sender onboarding wizard** (§6.1 — screens are real, submission is not). Original scope: real -`api-client` (mirror the frontend's `apiFetch`); real Supabase OTP auth → `/v1/auth/*`; -`.env.example`; swap **all** mocks — quote/ramp → existing endpoints, recipients/transfers → -`/v1/recipients/*`, notifications → `/v1/notifications`; adopt `@vortexfi/shared/types`. No -backend logic — replaces the app's mocks. - ---- - -## 10. Security-spec sync - -Update in the same change set as the relevant phase (per both docs' "Security-spec impact"): -`01-auth/api-keys.md` (Phase 4), `05-integrations/brla.md` (Phase 5 ownership), `mykobo.md`, -`alfredpay.md` (Phase 3), `03-ramp-engine/profile-partner-pricing.md` (Phase 2), plus a new -`03-ramp-engine/recipient-transfers.md` (invite token generation/hashing/expiry/revocation, -token-bound redemption, sender↔recipient authorization, transfer gating) and -`07-operations/notifications.md` (PII redaction, server-side-triggered email). - ---- - -## 11. Sequencing - -1. **Phase 0** — connect the core flow (S1 shared types + CORS + dashboard wiring, **no - migrations**). Verify: login → quote → ramp → status → history walkthrough; corridor cards - reflect `/v1/onboarding/status`. -2. **Phase 1** — recipient product (migrations `034`–`036` + routes + eligibility + notifications). - Verify: invite → widget onboarding (recipient, §6.2) → verified payout → transfer allowed; - `bun migrate` clean. -3. **Phase 2** (partners split). Verify: pricing parity, `bun test`. -4. **Phase 3** (provider_customers + kyc_cases). Verify: provider-read parity; swap aggregator + - eligibility internals. -5. **Phase 4** (api_keys). Verify: key-auth parity. -6. **Phase 5** (ownership enforcement). Verify: ownership regression tests. -7. **Phase 6** (finalize cut-over; keep legacy as backup, no drop) + security-spec finalization. - -Phase 0 makes the dashboard live on its own. Phases 2–6 don't block it (Phase 1 already shipped -the product surface) and are individually additive. - ---- - -## 12. Risks & still-open decisions - -1. **Phase 2/3 data migrations are the real risk** — dedup partners by name and fold three - heterogeneous provider tables (keyed by `user_id` / `user_id+country+type` / normalized - `taxId`) into `provider_customers`. Consumer apps are safe (DTO-mapped), but internal - resolution/provider code and admin endpoints change in lockstep; cover with parity tests. With - no dual-write safety net, those tests are the gate — and the retained legacy tables are the - fallback: if a parity test fails after cut-over, re-run the migration from the backup. -2. **Unified-doc open questions need product/compliance sign-off before Phase 3:** tax-ID - global-uniqueness semantics; KYC/failure-payload retention; retention on profile deletion. -3. **Supabase transactional email** — confirm the SMTP/edge-function path for non-auth emails - (D7). -4. **Payout-reference entry flow — TBD (see §7.1)** — how the recipient's payout instrument is - created provider-side: widget "receive" mode (A, recommended) vs. a dashboard payout form (B). - Blocks the eligibility "payout verified" gate, not Phase 0. Also open (recipient-doc): whether - details are editable pre-approval; default single verified reference per corridor, changes via - re-verify. -5. **Production dashboard origin** — assumes same-origin under `app.vortexfinance.co/dashboard/`; - a dedicated subdomain later needs a new CORS entry + security-spec update (no wildcards). -6. **Shared split shape** — `./types` subpath vs. a separate package (S1 fallback); resolve with - a dependency audit before building. -7. **PRESSING — recipient-context ramp registration (to be defined, 2026-07).** Verified against - the code: registration is structurally a *self-offramp* — payout destinations are already - sender-bound on mykobo (anchor-side IBAN via the sender's profile) and alfredpay - (`fiatAccountId` provider-scoped to the server-derived customer); only BRL accepts a - third-party destination (`pixDestination` + `receiverTaxId`, consistency-checked against the - pix key owner, defaulting to self). So sender→recipient transfers need a **second principal** - in registration, not an added check: request carries the `sender_recipients` id; server - verifies relationship ownership + `getTransferEligibility`, then resolves the payout side - from the *recipient's* provider identity / verified payout reference per corridor (BRL: - inject recipient pix key + tax id and narrow the free destination; alfredpay: order against - the recipient's customer + fiat account — provider design question; mykobo: withdraw intent - under the recipient's profile). Couples to §7.1; BRL is the cheapest first corridor. See - `docs/security-spec/03-ramp-engine/recipient-transfers.md`. -8. **Sender onboarding is mocked (§6.1).** The dashboard renders every KYC/KYB screen but submits - nothing; `useOnboardingOverrideStore` fakes the status advance for the session. Until it drives - the real KYC machines, **no sender can actually onboard from the dashboard** — they must still - use the widget directly. Unmocking (reuse vs. port of the widget's KYC machines) is specified in - `dashboard-followup-plan.md`. This is the highest-priority dashboard follow-up. -9. **Recipient onboarding is unreachable for EU (§6.2).** AR was fixed by adding it to - `KYB_REGIONS` (the machine already supported it). EU cannot be fixed the same way: Mykobo is - individual-KYC-only and needs a connected wallet, so it cannot complete a quote-less KYB deep - link, and EU **company** KYB has no widget destination at all (the dashboard routes it to a - Google Form). Decide: teach the widget a wallet-less EU flow, or give the dashboard a - recipient-facing EU equivalent. Couples to §7.1, since a recipient needs a payout instrument - regardless. - ---- - -*Sources: `docs/architecture/unified-user-management-schema.md` and -`docs/architecture/recipient-transfers-schema.md` (schema source of truth, verified against -commit `4df3ed03`); prior plan `docs/plans/api-shared-dual-app.md`; and direct code investigation -of `apps/api`, `apps/frontend`, `apps/dashboard`, `packages/{shared,sdk}`.* diff --git a/docs/dashboard-app-spec.md b/docs/product-dashboard.md similarity index 98% rename from docs/dashboard-app-spec.md rename to docs/product-dashboard.md index dd3d20593..2a6b380ba 100644 --- a/docs/dashboard-app-spec.md +++ b/docs/product-dashboard.md @@ -291,7 +291,6 @@ provider-shaped rather than UI-shaped. --- -*Schema detail: `docs/architecture/unified-user-management-schema.md`, -`docs/architecture/recipient-transfers-schema.md`. Migration phasing and open decisions: -`docs/plans/dashboard-full-product-connection.md` (deprecated) and -`docs/plans/dashboard-followup-plan.md`.* +Architecture: [`docs/architecture-identity-model.md`](architecture-identity-model.md). +Security-sensitive recipient behavior: +[`docs/security-spec/03-ramp-engine/recipient-transfers.md`](security-spec/03-ramp-engine/recipient-transfers.md). diff --git a/docs/proposal-headless-profiles-and-pricing-plans.md b/docs/proposal-headless-profiles-and-pricing-plans.md new file mode 100644 index 000000000..7a42ad565 --- /dev/null +++ b/docs/proposal-headless-profiles-and-pricing-plans.md @@ -0,0 +1,877 @@ +# Proposal: Headless Profiles and Pricing Plans + +Status: proposed. The product direction is accepted; exact migration ordering and final +internal route shapes remain implementation-review decisions. Last updated: 2026-08-03. + +Related decisions and specifications: + +- [`ADR 0001: User-Gated Ramp Registration`](adr-0001-user-gated-ramp-registration.md) +- [`Identity, Customer, and Partner Model`](architecture-identity-model.md) +- [`API Credential Authentication`](security-spec/01-auth/api-keys.md) +- [`Monerium Integration`](security-spec/05-integrations/monerium.md) +- [`API Credential Production Rollout`](operations-api-credential-rollout.md) +- [PR #1298](https://github.com/pendulum-chain/vortex/pull/1298) + +## Decision sought + +Approve this deliberately smaller model: + +1. A headless profile is a permanently local Vortex access principal. It has no Supabase + identity, login email, OTP flow, or later claiming lifecycle. +2. Every headless profile is attached to exactly one existing `customer_entities` row. + Entity-less technical managed profiles are removed because Vortex has no current use + for partner-wide operations or webhooks across profiles. +3. Every API credential belongs to exactly one profile. Credentials do not select + pricing, represent an organization, or own resources as a second principal. +4. Quotes, ramps, and webhooks are profile-owned. The credential ID additionally binds a + public-key quote to the corresponding secret key where required. +5. The current `partners` subsystem is renamed to describe what it actually stores: + reusable pricing plans, corridor rules, and time-bounded profile assignments. +6. Self-service and headless profiles use the same pricing-resolution path. Pricing never + depends on which credential or login method the profile used. +7. A managed profile records a normalized source plus an external subject ID solely for + provenance and idempotency. That source is not an authentication or pricing entity. +8. Interactive provider onboarding is not expanded for headless profiles. Their customer + entities and KYC/KYB/provider records are created through manual operational processes. + +When implemented, replace this proposal with an accepted ADR, update the canonical +architecture and security specifications, and remove this proposal. Git history is the +implementation record. + +## Context + +PR #1298 correctly moves Vortex toward one public/secret credential row acting for one +profile. Its managed-profile implementation, however, extends the overloaded `partners` +concept into identity and authorization. + +Today one `partner_id` can mean several different things: + +```text +pricing template +credential manager +quote owner +webhook tenant +managed-profile namespace +``` + +This is extraneous complexity, not inherent domain complexity. The clearest evidence is +the recipient-invitation discount flow: it creates a dedicated `partners` row named after +a profile email only to hold pricing configuration. Such a row is a pricing plan, not a +commercial organization. + +The product requirements are narrower: + +- some profiles receive reusable custom fees, markups, discounts, and rates; +- pricing may be time-bounded and its previous assignments are operationally useful; +- legacy and manually imported customers sometimes need API access without login; +- all new credentials act for one profile; +- all sensitive customer operations derive their entity from that profile; +- Vortex admins, not external partner tenants, provision headless profiles and their + credentials; +- partner-wide webhooks across multiple profiles are not currently used or required. + +The schema should model those facts directly. It should not preserve organization-level +authorization or claimable identities for hypothetical future requirements. + +## Design sacrifices + +This proposal intentionally does not support: + +- later claiming of a headless profile; +- partner/organization principals in API authentication; +- one credential operating across multiple profiles; +- partner-wide webhooks across multiple profiles; +- entity-less technical managed profiles; +- pricing selected by an API credential or request body; +- entity-specific pricing beneath one profile; +- an integration-account, tenant, or organization table; +- a public partner-management API. + +Those sacrifices reduce the runtime model from an overloaded graph (`🤯`) to four +independent concepts (`🧠`): + +```text +access identity compliance identity +profile customer_entity +api_credential provider_customer / kyc_case + +commercial terms import provenance +pricing_plan managed_profile.source +pricing_plan_rule external_subject_id +profile_pricing_assignment +``` + +If organization-wide login, permissions, billing, or multi-profile webhooks become real +requirements later, introduce an explicit integration-account model then. Do not reuse a +pricing plan as an authorization principal. + +## Vocabulary and invariants + +### Profile + +A profile is the principal that owns API credentials and runtime resources. It may be: + +- self-service: backed by Supabase and a non-null email; or +- headless: local to Vortex, with a null email and one managed-profile record. + +Every authenticated request resolves exactly one profile. If a Supabase session and API +credential are presented together, they must resolve to the same profile or the request +fails with `CREDENTIAL_SUBJECT_MISMATCH`. + +### Customer entity + +A customer entity is the legal KYC/KYB subject. It owns provider customers and +verification cases. It may exist without a profile after a manual import. + +A headless profile is created only when API access is required, then attached to one +existing eligible customer entity. It never creates a blank entity lazily. + +### API credential + +One credential contains one public value and one secret value and belongs to one profile. +Both values have one environment, expiry, revocation, and subject lifecycle. + +The credential carries no pricing-plan, partner, source, or customer-entity foreign key. +Those facts are resolved from the profile and its associations. + +### Pricing plan + +A pricing plan is a reusable named collection of commercial terms. It is not a person, +organization, authentication principal, or provider. + +One active profile-pricing assignment selects a plan for a profile. A profile without an +active assignment receives the default Vortex plan. Truly anonymous quotes also receive +the default plan. + +### Managed-profile source + +`managed_profiles.source` is a normalized namespace such as `legacy-client-a` or +`monerium-import`. Combined with `external_subject_id`, it makes provisioning idempotent +and records provenance. + +It grants no permissions and selects no pricing. Vortex-admin authentication protects all +managed-profile and managed-credential routes. + +## Target model + +### Self-service profile + +```text +Supabase Auth user + -> profiles(id = Supabase UUID, email = login email) + -> optional active profile_pricing_assignment + -> customer_entities + -> provider_customers / kyc_cases + -> api_credentials(profile_id) + -> profile-owned quotes, ramps, and webhooks +``` + +Self-service OTP, profile creation, provider onboarding, and dashboard credential +management remain unchanged apart from the removal of partner-principal branches. + +### Manually imported entity + +```text +customer_entities(profile_id = NULL, type = business) + -> provider_customers(provider = monerium, provider_customer_id = corporate profile ID) + -> kyc_cases(type = kyb, normalized status and provider evidence) +``` + +This is a complete compliance identity. It needs neither a profile nor pricing until API +access is required. + +### Headless profile + +```text +source + external subject ID + -> managed_profiles + -> local profiles row(email = NULL) + -> attach one existing customer_entity and make it active + -> optional profile_pricing_assignment + -> api_credentials(profile_id) + -> profile-owned quotes, ramps, and webhooks +``` + +Pricing assignment is separate from provisioning. An administrator may assign any active +pricing plan after provisioning; without one, the profile uses the default plan. + +### No technical managed-profile variant + +The former `technical` subject existed to support partner-operational credentials and +partner-wide webhooks. Those capabilities are not required. Every managed profile in this +proposal therefore represents an individual or business customer through its attached +entity. + +If a machine-to-machine principal without a customer identity becomes necessary, design +it explicitly around the concrete capability. Do not create an entity-less profile that +can accidentally enter customer/provider paths. + +## Proposed database changes + +### `profiles` + +Change `email` from non-null to nullable. PostgreSQL's existing unique email index may +remain because multiple null values do not conflict. + +Required invariants: + +- every `profiles.email IS NULL` row has exactly one `managed_profiles` row; +- no managed profile has a non-null email; +- every self-service OTP/session profile has a non-null email; +- no API or admin path can create an unassociated null-email profile. + +Do not add a `profile_kind` discriminator. The unique managed-profile association is the +source of truth. Update the misleading model comment that every profile ID is a Supabase +UUID; headless IDs are generated locally. + +### Rename `partners` to `pricing_plans` + +Target columns: + +| Column | Meaning | +|---|---| +| `id` | UUID primary key | +| `code` | Stable unique machine identifier, replacing `name` | +| `display_name` | Operator-facing name | +| `is_default` | Marks the single fallback plan | +| `is_active` | Whether new quotes may resolve this plan | +| timestamps | Standard audit timestamps | + +Remove `logo_url` after confirming there is no current consumer. Do not preserve legacy +inline pricing/ramp columns as runtime model attributes; pricing belongs only in rules. + +Rename the existing `vortex` row to a stable code such as `vortex-default`, with a clear +display name such as `Vortex Default`, and set `is_default = true`. Add a partial unique +index allowing only one default row and a startup assertion requiring exactly one active +default. Resolution must use that invariant, not scattered string fallbacks. + +### Rename `partner_pricing_configs` to `pricing_plan_rules` + +Rename `partner_id` to `pricing_plan_id`. Retain the existing commercial fields: + +- ramp direction and optional fiat-corridor scope; +- markup type, value, and currency; +- Vortex fee type and value; +- target discount and dynamic-difference/subsidy bounds; +- payout addresses required for markup distribution; +- active state and timestamps. + +Keep the unique scope: + +```text +(pricing_plan_id, ramp_type, COALESCE(fiat_currency, '*')) +``` + +A corridor-specific rule continues winning over the plan's wildcard rule. + +### Rename `profile_partner_assignments` to `profile_pricing_assignments` + +Target columns: + +| Column | Meaning | +|---|---| +| `id` | UUID primary key | +| `profile_id` | Profile receiving the plan | +| `pricing_plan_id` | Selected reusable pricing plan | +| `is_active` | Current administrative selection | +| `expires_at` | Optional time boundary | +| timestamps | Assignment history | + +Remove `partner_name`, `buy_partner_id`, and `sell_partner_id`. They are redundant or +legacy representations. + +Retain the partial unique invariant that a profile has at most one active assignment. +Resolution additionally requires `expires_at IS NULL OR expires_at > now`. + +The assignment table earns its existence by retaining time bounds and previous inactive +assignments. If product later confirms neither is useful, it may be collapsed into +`profiles.pricing_plan_id`; that simplification is not required for this change. + +### Rename `partner_managed_profiles` to `managed_profiles` + +Target columns: + +| Column | Meaning | +|---|---| +| `id` | UUID primary key | +| `source` | Normalized provenance/idempotency namespace | +| `external_subject_id` | Immutable subject identifier within the source | +| `profile_id` | Unique headless profile | +| `customer_entity_id` | Unique existing legal entity attached to the profile | +| timestamps | Standard audit timestamps | + +Remove `partner_id`, `subject_type`, and `claimed_at`. + +- The attached customer entity supplies individual/business type; do not duplicate it. +- There is no claiming lifecycle. +- There is no organization principal. + +Normalize `source` to a lowercase slug at the request boundary. Trim the external subject +ID without lowercasing it unless the source contract explicitly declares case-insensitive +IDs. Enforce bounded lengths before database access. + +Required uniqueness: + +```text +UNIQUE (source, external_subject_id) +UNIQUE (profile_id) +UNIQUE (customer_entity_id) +``` + +Add a composite relationship ensuring `(customer_entity_id, profile_id)` references the +same pair on `customer_entities`. This makes it impossible for the managed record to name +an entity owned by a different profile. Provisioning also sets +`profiles.active_customer_entity_id` to this entity; startup assertions verify it remains +the active entity for every managed profile. + +### `api_credentials` + +Keep the unified public/secret representation and non-null `profile_id`. Remove +`partner_id` and its index/FK. + +The credential context becomes: + +```ts +interface CredentialContext { + credentialId: string; + environment: "live" | "test"; + profileId: string; + strength: "public" | "secret"; +} +``` + +### `quote_tickets` + +Keep profile ownership and `api_credential_id`. Rename `pricing_partner_id` to +`pricing_plan_id`. Remove the ownership `partner_id` column. + +The quote snapshots the plan used at creation so later assignment changes do not alter +already quoted commercial terms. Its fee metadata remains the monetary execution record. + +### `webhooks` + +Remove `partner_id`. Require profile ownership for every new webhook. A global webhook +means all matching events owned by that one profile, never multiple profiles. + +The production migration must verify that no active organization/partner-owned webhook +requires multi-profile behavior. The product owner has confirmed the feature is unused; +the database preflight remains necessary to catch unexpected rows. + +### Observability + +Stop using generic `partnerId` and `partnerName` event dimensions. Record only safe, +unambiguous identifiers relevant to the event: + +- profile ID; +- credential ID and safe prefix; +- pricing plan ID/code for quote pricing; +- managed source for provisioning operations; +- quote/ramp ID. + +Never log external subject IDs when they may contain PII, key values, emails, tax +references, or provider payloads. + +## Pricing resolution + +Use one pricing path for anonymous, self-service, and headless requests: + +```text +request + -> resolve effective profile (session or credential) + -> reject session/credential profile mismatch + -> load active, unexpired profile pricing assignment + -> load active pricing plan rule for direction + corridor + -> otherwise use active default plan rule + -> persist pricing_plan_id on quote +``` + +Do not accept a pricing-plan, partner, or rate identifier from public quote bodies. An +administrator changes pricing through the profile-pricing assignment API, not by issuing +a different credential. + +Consequences: + +- every credential for one profile produces identical pricing; +- public key, secret key, and Supabase session produce identical pricing; +- managed-profile source never affects pricing implicitly; +- changing a profile's plan does not require credential rotation; +- disabling a pricing plan prevents new resolution but does not rewrite existing quotes; +- an expired assignment falls back to the default plan. + +## Proposed internal API contracts + +### Provision a headless profile + +```http +POST /v1/admin/managed-profiles +Authorization: Bearer +Content-Type: application/json +``` + +Request: + +```json +{ + "source": "monerium-import", + "externalSubjectId": "corporate-profile-4821", + "customerEntityId": "existing-customer-entity-uuid" +} +``` + +Response: + +```json +{ + "managedProfile": { + "id": "managed-profile-uuid", + "profileId": "headless-profile-uuid", + "source": "monerium-import", + "externalSubjectId": "corporate-profile-4821", + "customerEntityId": "existing-customer-entity-uuid", + "created": true + } +} +``` + +Idempotency behavior: + +- the same normalized source, external subject ID, and entity returns the existing result; +- the same source/external ID with a different entity returns `409`; +- an inactive, blocked, missing, or already-owned entity is rejected; +- no email, subject type, pricing plan, or claim state is accepted. + +### Assign profile pricing + +Rename the internal admin route family to use pricing vocabulary. The create/replace +operation accepts: + +```json +{ + "profileId": "profile-uuid", + "pricingPlanCode": "enterprise-latam", + "expiresAt": null +} +``` + +It locks the profile, deactivates any current assignment, validates an active plan, and +creates the new assignment in one transaction. This endpoint works identically for +self-service and headless profiles. + +### Manage credentials for a headless profile + +Replace partner-based admin routes with source-based managed-profile lookup. For example: + +```http +POST /v1/admin/managed-profiles/:source/:externalSubjectId/api-credentials +GET /v1/admin/managed-profiles/:source/:externalSubjectId/api-credentials +DELETE /v1/admin/managed-profiles/:source/:externalSubjectId/api-credentials/:credentialId +``` + +Each route resolves: + +```text +normalized source + external subject ID + -> managed profile + -> profile ID + -> profile-owned credentials +``` + +These routes remain Vortex-admin-only. Do not add them to public OpenAPI unless product +later supports an external administrative contract. + +## Service behavior + +### Headless provisioning transaction + +Implement one database transaction: + +1. Validate and normalize source and external subject ID. +2. Lock/read the managed record by `(source, external_subject_id)`. +3. For an existing record, verify the attached entity and return it idempotently. +4. Lock the requested customer entity. +5. Require it to be active and currently unowned, or already owned by the idempotently + resolved profile. +6. Generate a profile UUID locally. +7. Insert the null-email profile. +8. Attach the entity to the profile and set it as the profile's active entity. +9. Insert the managed-profile record. +10. Commit and return the result. + +The service must not call Supabase, create Auth metadata, send email, create a blank +customer entity, select pricing, or perform provider onboarding. + +### Customer-entity resolution + +Centralize managed-profile eligibility in the customer-entity service: + +- self-service profiles retain current create/select behavior; +- managed profiles use only their pre-attached active entity; +- a managed profile missing that entity fails explicitly; +- managed profiles never lazily create another entity. + +Because technical managed profiles are removed, there is no technical-profile branch or +special error to remember. + +### OTP authentication + +Remove managed-profile claim handling from OTP verification. OTP continues upserting only +the Supabase-authenticated self-service profile. There is no merge or conversion behavior +between an email login and a headless profile. + +### Credential validation and resource ownership + +Public and secret validation resolve only the credential and profile. They do not load a +partner/pricing row. + +- revocation and expiry disable both values atomically; +- when both values are present, they must resolve to the same credential ID; +- when a session and credential are present, they must resolve to the same profile; +- a public-key quote persists credential and profile IDs; +- secret registration of that quote requires the same credential ID; +- quote, ramp, limits, provider, and webhook authorization compare profile ownership; +- webhook queries never use pricing plans or managed sources as owners. + +Remove `authenticatedPartner`, `enforcePartnerAuth`, partner branches in ownership +middleware, and partner-aware webhook ownership. + +## Manual provider/KYB import boundary + +The headless-profile API does not perform KYC/KYB onboarding. Manual import remains a +separate operational lifecycle. + +A future Monerium import should materialize only the runtime records Vortex requires: + +```text +customer_entities + type = business + profile_id = NULL until API access is provisioned + +provider_customers + provider = monerium + rail = eur + customer_type = business + provider_customer_id = immutable Monerium corporate profile ID + normalized status + original status_external + +kyc_cases + provider = monerium + type = kyb + provider profile/case reference + submitted/approved/rejected timestamps when evidenced +``` + +Do not import OAuth access/refresh tokens, authorization codes, raw identity documents, +or complete provider payloads. Import immutable provider references, normalized state, +minimal company display data required by the product, and evidence timestamps. + +Do not implement a production bulk importer until the actual Monerium source/export +contract and operator/audit requirements are provided. When available, use a +provider-specific offline script with: + +- JSON manifest validation; +- dry-run/preflight mode; +- idempotency by `(provider, provider_customer_id)`; +- rejection of a provider profile already attached to another entity; +- one transaction per manifest or explicitly documented batch; +- no destructive update of an approved binding; +- an operator-reviewed reconciliation report containing IDs/counts, not PII; +- focused tests using synthetic company data. + +The existing interactive Monerium OAuth endpoints remain Supabase-session-only. Manual +imports must not weaken state, PKCE, email-match, token-custody, or profile-selection +invariants. + +## Scope + +### In scope + +- pricing-plan terminology and schema migration; +- one profile-pricing assignment path for all authenticated profiles; +- removal of credential/request-selected pricing; +- removal of partner principals from credentials, quotes, ownership, and webhooks; +- profile-only unified public/secret credential context; +- nullable profile email for headless profiles; +- simplified managed-profile source/external-ID association; +- attachment of one existing entity during headless provisioning; +- removal of Supabase provisioning, claiming, and technical managed profiles; +- migration/preflight changes required by the new identity and pricing models; +- focused migration, service, authorization, pricing, and integration tests; +- canonical architecture, security, API, skill, and operations documentation updates. + +### Explicitly out of scope + +- claimable managed identities or conversion to Supabase users; +- synthetic or placeholder login emails; +- partner/organization authentication, tenancy, billing, or permissions; +- partner-wide or cross-profile webhooks; +- machine credentials without a customer profile; +- credential-level or entity-level pricing overrides; +- public selection of pricing plans or rates; +- a public KYC/KYB submission API for headless subjects; +- changing interactive provider onboarding to accept headless credentials; +- rebinding credentials directly to customer entities; +- guessing the Monerium bulk-import format; +- storing Monerium OAuth credentials or raw KYB documents; +- a repository-wide rename of the legacy `User` model in the same change. + +## Implementation sequence + +### Phase 0: deployment and data gates + +Determine which pricing and credential migrations have run in every durable environment. + +- Existing deployed pricing tables require a forward rename/cleanup migration. +- If migrations 055-058 have not run outside disposable databases, revise them in place + so partner-principal columns/tables are never introduced. +- If any have run durably, add forward migrations; do not rewrite applied history. +- Inventory active partner-owned webhooks and require zero multi-profile dependencies. +- Inventory null-email profiles, managed associations, legacy credentials, active pricing + assignments, and pricing rows using immutable IDs. +- Never delete Supabase users automatically from a database migration. + +**Gate:** the implementation PR documents deployed migration state and the chosen forward +or in-place strategy. + +### Phase 1: rename and clean the pricing model + +- Rename pricing tables, models, services, controllers, routes, and identifiers. +- Rename partner/config FKs to pricing-plan terminology. +- Remove redundant assignment columns and migrate active/history rows. +- Establish one explicit active default pricing plan. +- Change invitation-seeded discounts to create non-PII pricing-plan codes derived from an + immutable invitation/profile ID, never an email. +- Rename quote pricing provenance to `pricing_plan_id`. +- Update fee calculation, discount state, payout distribution, admin configuration, and + quote persistence without changing monetary behavior. + +**Gate:** golden quote tests show identical amounts/fees before and after for default, +custom, corridor-specific, expired, and invitation-seeded pricing. + +### Phase 2: make profiles the only access/resource principal + +- Remove `partner_id` from `api_credentials` and credential context. +- Remove request-body partner selection and partner authentication middleware. +- Remove quote ownership `partner_id`; keep profile and credential ownership. +- Migrate webhooks to profile-only ownership and remove partner branches. +- Replace partner observability fields with explicit profile/credential/pricing fields. +- Enforce session/credential profile consistency. + +**Gate:** authorization code has no pricing-plan or managed-source ownership branch, and +no profile can operate another profile's quote, ramp, or webhook. + +### Phase 3: simplify the headless schema + +- Make profile email nullable. +- Replace partner-managed records with source-based managed records. +- Remove claimed state and subject type. +- Require one existing entity for every managed profile. +- Add startup/schema assertions for orphan null-email profiles and invalid entity links. +- Keep existing RLS posture on customer and credential tables. + +**Gate:** the database cannot represent a managed record without exactly one profile and +one matching attached customer entity, and startup rejects an inconsistent active entity. + +### Phase 4: implement headless provisioning and admin credential routes + +- Replace Supabase reconciliation with the single transaction above. +- Resolve managed subjects by source/external subject ID. +- Keep pricing assignment as an independent admin operation. +- Preserve five active credentials per profile and one-row public/secret lifecycle. +- Remove partner API-key route naming and arbitrary profile-UUID credential selection. + +**Gate:** tests prove no Supabase call occurs, requests are idempotent/concurrency-safe, +and a source string grants no runtime permission. + +### Phase 5: centralize entity eligibility + +- Prevent managed profiles from lazy entity creation or selection changes. +- Require their attached active entity across provider, limits, recipient, and ramp paths. +- Keep self-service behavior unchanged. +- Remove technical-profile conditionals made obsolete by the model. + +**Gate:** a malformed managed profile cannot enter a provider call, while an attached and +approved managed entity can complete the same ramp path as a self-service profile. + +### Phase 6: migrate credentials and pricing assignments + +- Backfill SHA-256 secret digests for every preserved secret using + `backfill-api-key-digests.ts`; reissue any unavailable plaintext secret. +- Use an explicit immutable-ID manifest for legacy key pairs and profile ownership. +- Convert legacy key partner references into profile-pricing assignments, not credential + ownership. +- If a profile has no active pricing assignment and all its selected legacy keys reference + one pricing plan, create that assignment. +- If existing assignments and key references disagree, or selected keys reference several + plans for one profile, stop for operator review. +- Provision/attach headless profiles before creating their unified credentials. +- Require zero active legacy keys and zero partner-owned credentials before startup. + +**Gate:** one profile has one effective pricing plan regardless of credential, and every +credential/profile pair in the manifest is unambiguous. + +### Phase 7: documentation and contract synchronization + +When implementation is complete: + +1. Replace this proposal with `adr-0002-headless-profiles-and-pricing-plans.md`. +2. Update `architecture-identity-model.md` with profile ownership, entity separation, + source-based managed profiles, and pricing terminology. +3. Update `security-spec/01-auth/api-keys.md`, admin auth, webhook ownership, and API + surface specifications to remove partner principals. +4. Update public quote/authentication docs to remove request-selected partner pricing. +5. Update the production credential rollout with pricing-assignment and webhook-zero + preflight gates. +6. Update the Vortex integration skill so credentials are profile-linked and pricing is + not described as credential/partner attribution. +7. Update `security-spec/05-integrations/monerium.md` only if an import path is actually + implemented. +8. Update `docs/README.md` and remove this proposal. + +**Gate:** no maintained document describes pricing plans as partners, managed profiles as +Supabase identities, or credentials/webhooks as partner-owned. + +## Test plan + +### Pricing migration and model tests + +- Existing partner pricing rows migrate one-to-one to plans/rules. +- Corridor-specific rules still win over wildcard rules. +- The default plan is unique and required at startup. +- A profile has at most one active assignment. +- Expired assignments resolve to default pricing. +- Inactive plans/rules cannot price new quotes. +- Assignment rows contain no legacy names or buy/sell partner FKs. +- Invitation discounts create non-PII plan codes and preserve fee/subsidy behavior. + +### Pricing resolution tests + +- Supabase session, public key, and secret key resolve the same plan for one profile. +- Two credentials for one profile cannot produce different pricing. +- Managed source has no effect on pricing. +- Changing an assignment changes new quotes without rotating credentials. +- Existing quotes retain their stored plan and fee metadata after assignment changes. +- Anonymous and unassigned-profile quotes use the default plan. +- Public requests cannot submit a pricing-plan/partner override. + +### Credential and ownership tests + +- Credential rows/context contain no partner ID. +- Session plus credential for different profiles fails. +- Public and secret halves still resolve one credential ID. +- Mismatched public/secret values fail immediately. +- Quote and ramp operations are profile-scoped. +- Registration of a credential-origin quote requires the same credential ID. +- Webhooks are profile-owned and never deliver another profile's event. +- No partner-wide webhook registration or lookup path remains. + +### Managed-profile tests + +- Provisioning takes source, external subject ID, and existing entity only. +- It creates no Supabase user, email, blank entity, pricing row, or claim state. +- Repeating the exact request is idempotent. +- Repeating with a different entity returns conflict. +- Missing, inactive, blocked, or already-owned entities are rejected. +- Concurrent requests cannot create two profiles or attach one entity twice. +- Source normalization is deterministic; external IDs retain source-defined case. +- Every null-email profile has one managed record and attached entity. + +### Entity and provider tests + +- A managed profile resolves only its attached active entity. +- An imported Monerium company may exist with `profile_id = NULL` and approved + provider/KYB records. +- Attaching that entity later preserves provider-customer and KYB record IDs. +- Interactive Monerium OAuth remains session-only. +- Ramp registration succeeds for an attached headless profile only when its existing + provider/KYC state satisfies the corridor. + +### Migration/preflight tests + +- Legacy key pairs are selected only by immutable IDs. +- Key partner references backfill profile pricing, not credential ownership. +- Conflicting plan references for one profile fail preflight. +- Unmappable partner-owned webhooks fail preflight. +- Missing secret plaintext forces reissue; there is no bcrypt/runtime fallback. +- Startup rejects active legacy keys, partner credential columns, or incomplete schemas. + +### Suggested verification commands + +Run focused tests first, then repository checks appropriate to touched workspaces: + +```bash +cd apps/api +bun test partner-resolution.test.ts +bun test partner-pricing.service.test.ts +bun test profilePartnerAssignments.controller.test.ts +bun test managed-profile.service.test.ts +bun test apiCredential.service.test.ts +bun test api-credential-migration.test.ts +bun test ownershipAuth.test.ts +bun test webhook.service.test.ts +bun test auth.invariants.test.ts +bun test http-surface.invariants.test.ts + +cd ../.. +bun verify +bun typecheck +``` + +Rename the focused commands alongside the implementation so the final PR uses pricing +terminology consistently. + +## Rollout and recovery + +1. Inventory pricing rows, assignments, credentials, headless candidates, entities, + webhooks, and Supabase identities using immutable IDs. +2. Confirm no active webhook requires organization-wide delivery across profiles. +3. Migrate pricing terminology and validate golden quote outputs. +4. Backfill each profile's pricing assignment using the conflict rules above. +5. Materialize customer entities/provider records through reviewed manual processes. +6. Provision headless profiles and attach entities in a dry-run/reviewed batch. +7. Backfill secret digests or reissue unavailable secrets. +8. Run credential manifest preflight and require all zero-count gates. +9. Cut over during the credential maintenance window. +10. Smoke-test default/custom pricing through session/public/secret auth, one headless ramp, + exact limits, sanitized ramp info, quote/credential ownership, profile webhook + isolation, and atomic revocation. +11. Monitor safe profile, credential, pricing-plan, quote, and ramp IDs plus stable error + codes. Never log secrets or identity/provider payloads. + +Rollback must not reactivate legacy keys, restore partner-principal authorization, or +recreate claimable Supabase managed identities. Restore the previous application only +when its schema/auth behavior remains compatible; otherwise correct data and roll forward. + +## Acceptance criteria + +The change is complete when: + +- `partners`, `partner_pricing_configs`, and `profile_partner_assignments` no longer name + the pricing subsystem; +- pricing plans/rules/assignments are the only source of custom commercial terms; +- public, secret, and session requests for one profile always resolve the same plan; +- credentials have no partner/pricing/source ownership field; +- quotes and webhooks have no partner owner and are profile-scoped; +- no multi-profile webhook capability remains; +- managed-profile creation takes no partner, email, subject type, pricing plan, or claim + state; +- every managed profile has one source/external ID, one null-email profile, and one + attached existing customer entity; +- no entity-less technical managed profile can be created; +- manually imported entities may exist without profiles; +- self-service signup, OTP, entity selection, and credentials remain behaviorally intact; +- no interactive provider onboarding route was broadened for headless profiles; +- migration tooling rejects ambiguous pricing, credential ownership, or webhook data; +- canonical docs and the integration skill use the final vocabulary consistently. + +## Inputs required before a bulk Monerium importer + +The headless-profile and pricing work is not blocked by these inputs. A later importer +must not guess them: + +- actual Monerium export/API source format; +- immutable corporate/profile deduplication key; +- authoritative provider-to-Vortex verification-status mapping; +- company display fields required at runtime; +- evidence timestamps and operator/audit identity; +- expected batch size and transaction/retry requirements; +- secure source-data location and retention policy. diff --git a/docs/proposal-mcp-server.md b/docs/proposal-mcp-server.md new file mode 100644 index 000000000..d8b936e37 --- /dev/null +++ b/docs/proposal-mcp-server.md @@ -0,0 +1,248 @@ +# Proposal: A Vortex MCP Server + +**Status:** Discussion draft — not yet an ADR. +**Audience:** Vortex tech team. +**TL;DR:** We propose shipping an official Vortex MCP server so that AI coding agents +(Claude Code, Cursor, claude.ai, etc.) can integrate and operate Vortex directly. Two +variants share one codebase: a **hosted server** (docs, quotes, status, integration +recipes — no credentials) and a **local npm package** (wraps `@vortexfi/sdk`, holds the +partner's keys, can execute ramps). We recommend starting with the **hosted server**. + +--- + +## 1. What is MCP, in one minute + +The [Model Context Protocol](https://modelcontextprotocol.io) is an open standard +(originated by Anthropic, now industry-wide) that lets AI agents call external +capabilities. An MCP **server** exposes: + +- **Tools** — functions the agent can call (JSON-schema input, structured output). +- **Resources** — readable content (e.g. docs pages). +- **Prompts** — invocable templates (e.g. "walk me through my first offramp"). + +Clients include Claude Code, claude.ai, Cursor, Windsurf, Copilot, ChatGPT dev tools — +i.e. the tools our integration partners' developers already use. Two transports matter: + +| Transport | Where it runs | Typical use | +|---|---|---| +| **stdio** | Spawned as a child process on the user's machine (e.g. via `npx`) | Developer tooling; credentials stay local | +| **Streamable HTTP** | Hosted by us at a URL | No install; reachable from web clients (claude.ai etc.); we update it for everyone at once | + +(The older HTTP+SSE transport is deprecated — new remote servers use Streamable HTTP.) + +## 2. Why Vortex should ship one + +- **Our funnel is developer integrations.** Every partner goes through + quotes → register → sign → start → webhooks, with real gotchas (string decimals, + 5 presigned tx variants, `FiatToken.EURC` vs `EUR`, deprecated `taxId`, user-linked + `sk_*` keys). Today an AI agent only gets this right if the developer happens to feed + it our docs. MCP makes Vortex *operable* by the agent, not just readable. +- **We already committed to this channel.** The `vortex-integration` skill and the + "AI Agent Integration" docs page exist. MCP is the client-agnostic distribution + surface for the same content — the skill is Claude-Code-shaped; MCP works everywhere. +- **Live feedback loop.** The killer feature is not docs search: the agent can call + sandbox *while writing integration code* — get a real quote, register a test ramp, + poll status, see the actual error payload. That collapses the integrate–debug loop. +- **Discoverability.** The official [MCP Registry](https://registry.modelcontextprotocol.io/) + is becoming how AI-native developers find integrable services (Stripe, Cloudflare, + PayPal ship servers). A verified `co.vortexfinance/*` listing is distribution and + marketing, and domain-verified namespacing prevents someone publishing a fake + "Vortex" server. + +## 3. The two variants + +Both variants are **public** and target the same audience — partner developers +integrating Vortex into *their* applications. The difference is where the server runs +and, consequently, what it is allowed to do. They are **one codebase with two entry +points**: the MCP SDK makes the transport swappable, so tool definitions are written +once. The hosted server is a strict subset (read-only + recipes); the local package +adds the credentialed tools on top. + +### 3.1 Hosted server — `https://mcp.vortexfinance.co/mcp` (recommended first) + +Streamable HTTP, **no credentials, no secrets ever transit it**. Its job is +zero-friction discovery, integration guidance, and safe read operations. + +**Tool catalog:** + +| Tool | What it does | +|---|---| +| `list_corridors` / `get_corridor` | Live corridors: currencies, rails, networks, limits, availability | +| `create_quote` | Price discovery (safe unauthenticated — a quote moves no funds) | +| `get_ramp_status` | Read-only status by ramp ID, with a human-readable rendering of the phase state machine ("stuck at `nablaSwap`, which means …") | +| `explain_error` | Error code → cause → fix (support deflection) | +| `search_docs` | Search over the integration docs | +| `get_integration_recipe` | **The instruction pattern — see below** | + +Plus **resources** (each docs page exposed as a readable resource) and **prompts** +(e.g. an "integrate your first offramp" walkthrough). + +**The instruction pattern.** The hosted server cannot hold keys or sign, so for +anything transactional it returns a *plan for the calling agent to execute locally* +with the user's own credentials. Example response from +`get_integration_recipe({ task: "offramp-brl-pix" })`: + +```markdown +# Recipe: SELL USDC → BRL via PIX + +You (the agent) will implement this in the user's project. Their keys never come +to this server. + +1. `npm i @vortexfi/sdk` — do NOT call the REST API raw; the SDK handles the + presigned tx variants. +2. Read VORTEX_SECRET_KEY from env. Never hardcode it. Must be a user-linked sk_* key. +3. Quote first — amounts are STRINGS, never JS Number. +4. Check quote.expiresAt before registering; re-quote if stale. +5. registerRamp → submitUserTransactions for wallet-owned txs → startRamp. + +Common pitfalls: EUR is FiatToken.EURC; do not send taxId for BRL (derived server-side). +``` + +The hosted server is effectively **living documentation plus a planner**: it answers, +prices, and diagnoses directly; for privileged operations it hands back current, +correct instructions. This is the `vortex-integration` skill served over the network — +always current, reachable from any MCP client including claude.ai. + +**Why hosted first:** + +- Zero install; works from web clients (claude.ai, ChatGPT) that cannot spawn processes. +- **No version skew**: we deploy once, every user is current at their next session. + Clients fetch the tool list at runtime, so we can add/change/retire tools and every + connected client sees it immediately. Users never "keep up with" our releases. +- Eligible for curated directories (e.g. the claude.ai connector directory, which only + accepts remote servers). + +### 3.2 Local npm package — `@vortexfi/mcp` (phase 2) + +A stdio server published to npm, spawned by the developer's MCP client. It is a thin +MCP layer over `@vortexfi/sdk`: keys come from env vars and **never leave the +developer's machine**; the SDK does what it already does (ephemeral key generation, +the 5-presigned-variant construction, signing). Filesystem access is used for one +thing: persisting ephemeral keys and ramp state to `~/.vortex/` so a crashed session +can recover a ramp (mirrors the SDK's storage concept). + +**Additional tools on top of the hosted set:** + +| Tool | What it does | +|---|---| +| `register_ramp` | Register from a non-expired quote; generates ephemerals, builds presigned variants, persists recovery state; returns `rampId` + any transactions the *user's wallet* must sign | +| `start_ramp` | Start a registered ramp | +| `recover_ramp` | Resume from persisted state in `~/.vortex/` | +| sandbox variants | Same flows against `api-sandbox.vortexfinance.co` for integration testing | + +**Developer setup** is one config block: + +```json +{ "mcpServers": { "vortex": { + "command": "npx", "args": ["-y", "@vortexfi/mcp@latest"], + "env": { "VORTEX_SECRET_KEY": "sk_test_..." } +} } } +``` + +**Example agent session:** + +```text +Agent → create_quote {"direction":"SELL","inputCurrency":"usdc", + "outputCurrency":"brl","inputAmount":"100","network":"polygon"} + ← {"quoteId":"q_8f2...","outputAmount":"512.34","expiresAt":"..."} + +Agent → register_ramp {"quoteId":"q_8f2...","destinationAddress":"0xAb5..."} + ← {"rampId":"r_c91...","userActionsRequired":1} + (ephemerals generated + presigned locally, state saved to disk) + +Agent → start_ramp → {"phase":"prepareTransactions"} +Agent → get_ramp_status (polls; renders phase in plain English) +``` + +The security property: all dangerous mechanics (`sk_*` custody, ephemeral keys, the +presigned-variant rule) happen inside a process on the user's machine, invisible to +the agent and never sent to us. + +**Code sketch** (illustrative — real names match the `VortexSdk` surface: +`createQuote`, `registerRamp`, `startRamp`, `getRampStatus`, `submitUserTransactions`): + +```ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { VortexSdk } from "@vortexfi/sdk"; +import { z } from "zod"; + +const vortex = new VortexSdk({ + apiBaseUrl: process.env.VORTEX_API_URL ?? "https://api.vortexfinance.co", + publicKey: process.env.VORTEX_PUBLIC_KEY!, + secretKey: process.env.VORTEX_SECRET_KEY! // never leaves this machine +}); + +const server = new McpServer({ name: "vortex", version: "0.1.0" }); + +server.registerTool("create_quote", { + description: "Price an onramp/offramp. Amounts are decimal strings. EUR uses EURC.", + inputSchema: { + direction: z.enum(["BUY", "SELL"]), + inputCurrency: z.string(), outputCurrency: z.string(), + inputAmount: z.string(), network: z.string() + } +}, async (args) => { + const quote = await vortex.createQuote({ rampType: args.direction, ...args }); + return { content: [{ type: "text", text: JSON.stringify(quote) }] }; +}); + +// register_ramp, start_ramp, get_ramp_status, recover_ramp, list_corridors ... +await server.connect(new StdioServerTransport()); +``` + +**Staleness — how bad is it?** Less bad than it sounds: + +- The recommended config uses `npx @vortexfi/mcp@latest`, which pulls the newest + version at launch — most users are effectively evergreen (caveats: pinned versions, + npx cache lag, offline machines). +- Design the server **thin**: don't bundle recipes/corridor data — fetch them from the + API at runtime, so an old package still returns current answers. Only code (tool + schemas, bundled SDK, signing logic) can ossify. +- That code-layer skew is exactly the compat obligation we already carry for pinned + `@vortexfi/sdk` versions. The local MCP server inherits it; it doesn't create a new one. + +## 4. Distribution + +1. Hosted server goes live at `mcp.vortexfinance.co` (Streamable HTTP). +2. Publish metadata to the official **MCP Registry** + (self-serve via their publisher CLI; API is stability-frozen at v0.1). Claim the + `co.vortexfinance/*` namespace via domain verification. +3. Later: publish `@vortexfi/mcp` to npm and add it to the same registry entry. +4. Optional: submit the hosted server to curated directories (claude.ai connectors). + +## 5. Risks and constraints + +- **Surface sync.** This adds a fourth surface that must stay in sync: SDK, api-docs + (Apidog/OpenAPI), the `vortex-integration` skill, and MCP. Mitigation: generate tool + schemas from the OpenAPI spec / SDK types, and source recipe content from the same + files as the skill. Extend the existing "keep the skill in sync" rule in + `packages/sdk/CLAUDE.md` to cover MCP. +- **Whitelabeling.** The api-docs whitelabel rule applies to everything the MCP server + returns (tool output, recipes, error explanations) — provider names must not leak. +- **Money-moving tools and agent safety.** MCP clients enforce safety rules around + fund transfers; agents will (correctly) require human confirmation for transactional + tools. Design for it: idempotency keys, explicit confirmation-oriented tool + descriptions, sandbox-by-default. Production transactional tools are a deliberate + phase-2/3 decision, not a default. +- **Prompt injection.** Tool outputs are consumed by agents; anything user-influenced + that we echo back (e.g. error messages containing user input) should be treated as + data, not instructions, and sanitized where feasible. + +## 6. Recommended phasing + +| Phase | Scope | Effort | +|---|---|---| +| **1 — Hosted server** | `list_corridors`, `create_quote`, `get_ramp_status`, `explain_error`, `search_docs`, `get_integration_recipe` + resources/prompts; registry listing | Small — thin layer over existing API + skill content | +| **2 — Local npm package** | Same codebase + credentialed tools (`register_ramp`, `start_ramp`, `recover_ramp`), sandbox-first | Small–medium — mostly SDK wiring + key/state handling | +| **3 — Production transactional (evaluate)** | Real-money ramps via the local server with confirmation UX | Decide after observing phase 1–2 usage | +| **Side quest — internal ops server** | Private server over admin endpoints (ramps stuck in a phase, partner volume, rebalancer state) for our own support/on-call Claude sessions | Independent; possibly the fastest ROI | + +## 7. Open questions + +- Primary audience for phase 3: partner developers, or end-user agents ("agentic + payments")? These imply different auth models (API keys vs OAuth). +- Should the hosted server require a lightweight (free) key for `create_quote` to + enable rate limiting/attribution, or stay fully open? +- Where does the server live — `packages/mcp` in this monorepo (shares SDK + skill + sources directly) or a separate repo? diff --git a/docs/qa/payments-pr-1245/desktop.png b/docs/qa/payments-pr-1245/desktop.png deleted file mode 100644 index 854a169d5..000000000 Binary files a/docs/qa/payments-pr-1245/desktop.png and /dev/null differ diff --git a/docs/qa/payments-pr-1245/form-success.png b/docs/qa/payments-pr-1245/form-success.png deleted file mode 100644 index 2b3f91586..000000000 Binary files a/docs/qa/payments-pr-1245/form-success.png and /dev/null differ diff --git a/docs/qa/payments-pr-1245/mobile-menu.png b/docs/qa/payments-pr-1245/mobile-menu.png deleted file mode 100644 index 18ad61aca..000000000 Binary files a/docs/qa/payments-pr-1245/mobile-menu.png and /dev/null differ diff --git a/docs/qa/payments-pr-1245/mobile.png b/docs/qa/payments-pr-1245/mobile.png deleted file mode 100644 index f6ea4538d..000000000 Binary files a/docs/qa/payments-pr-1245/mobile.png and /dev/null differ diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md deleted file mode 100644 index 404a516dd..000000000 --- a/docs/refactoring/README.md +++ /dev/null @@ -1,127 +0,0 @@ -# Ramp Components Refactoring - -## Overview - -This refactoring aims to improve the architecture of the RampForm and Swap components by following SOLID principles. The main goals are: - -1. Extract state management into Zustand stores -2. Move business logic to custom hooks -3. Simplify components to focus on rendering and user interaction - -## Directory Structure - -The refactored code follows this structure: - -``` -/stores - /ramp - useQuoteStore.ts # Manages quote data - useQuoteFormStore.ts # Manages form state - useTokenStore.ts # Manages token selection - -/hooks - /ramp - useRampForm.ts # Connects React Hook Form with store - useQuoteService.ts # Handles quote fetching logic - useRampValidation.ts # Handles form validation - useRampSubmission.ts # Handles submission process - useRampNavigation.ts # Handles navigation between states - useTokenSelection.ts # Handles token selection logic - -/components - /Swap - RefactoredSwap.tsx # Simplified Swap component - -/pages - /ramp-form - RefactoredRampForm.tsx # Simplified RampForm component -``` - -## Key Components - -### Stores - -- **useQuoteStore**: Manages quote data, loading states, and errors -- **useQuoteFormStore**: Manages form values, token selection, and related state -- **useRampProcessStore** (existing): Enhanced to better handle ramp process flow - -### Hooks - -- **useRampForm**: Connects React Hook Form with the Zustand store -- **useQuoteService**: Handles quote fetching and processing -- **useRampValidation**: Provides form validation and error messages -- **useRampSubmission**: Handles the submission process -- **useRampNavigation**: Manages navigation between different states -- **useTokenSelection**: Handles token selection and modal state - -### Components - -- **RefactoredSwap**: Simplified component focused on rendering the swap form -- **RefactoredRampForm**: Top-level component that uses hooks and stores - -## How to Use - -### Using the Form Store and Hook - -```tsx -// Get form state and methods -const { form, fromAmount, from, to } = useRampForm(); - -// Use form in your component - - - -``` - -### Using the Quote Service - -```tsx -// Get quote data -const { outputAmount, exchangeRate, loading, error } = useQuoteService(fromAmount, from, to); - -// Display in your component -
{loading ? 'Loading...' : `Exchange rate: ${exchangeRate}`}
-``` - -### Using Validation - -```tsx -// Get validation state -const { getCurrentErrorMessage, initializeFailedMessage } = useRampValidation(); - -// Display errors -

{getCurrentErrorMessage()}

-``` - -### Handling Submission - -```tsx -// Get submission handlers -const { onSwapConfirm, handleOfframpSubmit } = useRampSubmission(); - -// Use in your component - -``` - -## Benefits of This Architecture - -1. **Separation of Concerns**: Each piece of code has a clear responsibility -2. **Testability**: Business logic can be tested independently -3. **Reusability**: Hooks and stores can be reused across components -4. **Maintainability**: Smaller, focused modules are easier to understand and update -5. **Performance**: More granular updates to avoid unnecessary re-renders - -## Migration Path - -1. Create the stores and hooks -2. Create refactored components that use the new hooks and stores -3. Test the refactored components -4. Gradually replace the old components with the refactored ones -5. Remove the old code once the migration is complete - -## Further Improvements - -1. Add comprehensive tests for all hooks and stores -2. Enhance type safety for events and parameters -3. Add more detailed documentation for each hook and store -4. Consider splitting larger hooks into smaller, more focused ones \ No newline at end of file diff --git a/docs/refactoring/fee-handling-refactor-summary-2025-04-30.md b/docs/refactoring/fee-handling-refactor-summary-2025-04-30.md deleted file mode 100644 index 1f44ec7cd..000000000 --- a/docs/refactoring/fee-handling-refactor-summary-2025-04-30.md +++ /dev/null @@ -1,57 +0,0 @@ -# Fee Handling Refactoring Summary (2025-04-30) - -## Overview - -This document summarizes the refactoring applied to the fee calculation and handling logic within the ramp service, aligning it with the process detailed in `docs/architecture/ramp-journey-and-fees.md`. The primary goals were to improve accuracy, use database configurations, handle dynamic network fees, introduce a fee distribution phase, and ensure correct fee representation. - -## Key Changes Implemented - -1. **Fee Sources & Calculation (`quote.service.ts`):** - * Vortex Foundation fee is now sourced from the `partners` table ('vortex_foundation' record). - * Anchor fees are sourced from `fee_configurations` (`feeType: 'anchor_base'`). - * Partner markup is sourced from the quote's specific partner record. - * The static `network_estimate` fee type was removed from `FeeConfiguration`. - * Network fees (`networkFeeUSD`) are now dynamically calculated in `calculateGrossOutputAndNetworkFee` (using a stub GLMR->USD rate for EVM on-ramps, '0' otherwise). - * All fee components (Network, Vortex, Anchor, Partner Markup) are calculated in USD. - -2. **Fee Representation & Storage (`quote.service.ts`, `quoteTicket.model.ts`):** - * A helper (`getTargetFiatCurrency`) determines the relevant fiat currency for the transaction (input for on-ramp, output for off-ramp). - * A placeholder function (`convertUSDtoTargetFiat`) converts the calculated USD fees into the target fiat currency. - * The `QuoteTicket.fee` field now stores the detailed fee breakdown denominated in the **target fiat currency**. - * The `QuoteTicket.metadata` field was enhanced to store `grossOutputAmount`, `anchorFeeFiat`, `distributableFeesFiat`, and `targetFiat` for use by downstream processes. - -3. **Final Output Calculation (`quote.service.ts`):** - * The final `outputAmount` (net amount user receives) is calculated by subtracting the **total USD fee** (converted to the *output currency* via the `convertFeeToOutputCurrency` placeholder) from the `grossOutputAmount`. - -4. **Transaction Preparation (`onrampTransactions.ts`, `offrampTransactions.ts`):** - * Logic was updated to use `grossOutputAmount` from metadata for pre-anchor amounts and swap minimums. - * On-ramp logic now calculates the input amount *after* the anchor fee deduction (using placeholder conversions `convertFiatToUSD` and `convertUSDToTokenUnits`). - * Final XCM transfers now use the final net `outputAmount` from the quote. - -5. **Fee Distribution Phase (`distribute-fees-handler.ts`):** - * Implements pre-signed transaction flow: - - Transaction services (`onrampTransactions.ts`, `offrampTransactions.ts`) prepare and pre-sign batched fee distribution transactions - - Transactions are stored in `state.presignedTxs` with `phase: 'distributeFees'` - * Distribution handler: - - Retrieves transaction using `this.getPresignedTransaction(state, 'distributeFees')` - - Submits pre-signed transaction rather than constructing locally - * Maintains existing fee destination logic: - - Network and Vortex fees go to Vortex payout address - - Partner fees go to Partner's payout address if applicable - -6. **State Machine Integration:** - * The `DistributeFeesHandler` was registered. - * Transitions were updated: - * On-Ramp: `nablaSwap` -> `distributeFees` -> `subsidizePostSwap` -> ... - * Off-Ramp: (EVM/AssetHub transfer) -> `distributeFees` -> `subsidizePreSwap` -> ... - * The `docs/architecture/ramp-journey-and-fees.md` document was updated to reflect the new on-ramp phase order. - -7. **Anchor Fee Handling:** - * Phase handlers interacting with anchors (`brlaTeleport`, `brlaPayoutOnMoonbeam`, `stellarPayment`) will use gross amounts. Subsequent phases handle the amount remaining after the anchor deducts its fee internally. No specific pre-adjustment logic was added to these handlers based on user clarification. - -8. **Cleanup:** Deleted `api/src/api/helpers/quote.ts`. - -## Outstanding TODOs - -- Change output currency from EURC to EUR -- Pass the partnerId to the backend when creating a quote diff --git a/docs/refactoring/ramp-quote-refactor-plan.md b/docs/refactoring/ramp-quote-refactor-plan.md deleted file mode 100644 index 57390a767..000000000 --- a/docs/refactoring/ramp-quote-refactor-plan.md +++ /dev/null @@ -1,258 +0,0 @@ -# Ramp Quote Service Refactor: Architecture Plan - -This document outlines the detailed architectural plan for refactoring the Ramp Quote Service. The goal is to create a modular, readable, and plug-and-play system using a Strategy and Pipeline architecture. - -## 1. Directory and File Structure - -The new structure will be located under `apps/api/src/api/services/ramp/quote.service/`. - -``` -apps/api/src/api/services/ramp/quote.service/ -├── core/ -│ ├── quote-orchestrator.ts -│ └── quote-context.ts -├── routes/ -│ ├── route-resolver.ts -│ └── strategies/ -│ ├── onramp-evm.strategy.ts -│ ├── onramp-assethub.strategy.ts -│ ├── offramp-pix.strategy.ts -│ ├── offramp-sepa.strategy.ts -│ └── offramp-cbu.strategy.ts -├── engines/ -│ ├── input-planner.ts -│ ├── swap-engine.ts -│ ├── bridge-engine.ts -│ ├── fee-engine.ts -│ ├── discount-engine.ts -│ └── finalize-engine.ts -├── adapters/ -│ ├── price-feed-adapter.ts -│ └── persistence-adapter.ts -├── mappers/ -│ └── quote-mapper.ts -└── types.ts -``` - -### File Responsibilities - -| File | Responsibility | Inputs | Outputs | Reuses Existing Helpers | -|---|---|---|---|---| -| **core/quote-orchestrator.ts** | Main coordinator. Creates `QuoteContext`, resolves strategy, runs engines, and persists the result. | `QuoteRequest` | `QuoteResponse` | `route-resolver`, all `engines`, `persistence-adapter` | -| **core/quote-context.ts** | Defines the `QuoteContext` class/interface that carries state through the pipeline. | Initial `QuoteRequest` data | `QuoteContext` object | - | -| **routes/route-resolver.ts** | Selects the appropriate strategy based on the quote request parameters. | `QuoteContext` | A strategy instance (e.g., `OnRampEvmStrategy`) | - | -| **routes/strategies/*.ts** | Defines the ordered pipeline of engines for a specific route. | `QuoteContext` | `QuoteContext` (mutated) | Specific `engines` | -| **engines/input-planner.ts** | Calculates pre-Nabla deductible fees and determines the input amount for the swap. | `QuoteContext` | `QuoteContext` with `preNablaDeductibleFees` and `inputAmountForSwap` | `quote-fees.ts`, `price-feed-adapter.ts` | -| **engines/swap-engine.ts** | Executes the Nabla swap. | `QuoteContext` | `QuoteContext` with `nablaSwapResult` | `gross-output.ts` | -| **engines/bridge-engine.ts** | Handles EVM bridging logic and fees. | `QuoteContext` | `QuoteContext` with `evmBridgeResult` | `gross-output.ts` | -| **engines/fee-engine.ts** | Aggregates all fee components and converts them to USD and the target display fiat. | `QuoteContext` | `QuoteContext` with `feeComponents` | `quote-fees.ts`, `price-feed-adapter.ts` | -| **engines/discount-engine.ts** | Applies partner discounts. | `QuoteContext` | `QuoteContext` with `discountInfo` | - | -| **engines/finalize-engine.ts** | Calculates the final net output, runs validation checks, and formats the final amounts. | `QuoteContext` | `QuoteContext` with `netOutputAmount`, formatted fields | `validation-helpers.ts`, `quote-mapper.ts` | -| **adapters/price-feed-adapter.ts** | Wrapper for `priceFeedService` to centralize currency conversion, rounding, and precision. | Currency pair, amount | Converted amount | `priceFeed.service.ts` | -| **adapters/persistence-adapter.ts** | Wrapper for creating `QuoteTicket` records in the database. | `QuoteContext` | `QuoteTicket` ID | `quoteTicket.model.ts` | -| **mappers/quote-mapper.ts** | Formats numbers and amounts for the final API response. | Numbers, amounts | Formatted strings | `helpers.ts` (e.g., `trimTrailingZeros`) | -| **types.ts** | Contains shared TypeScript types and interfaces for the quote service. | - | Types | - | - -## 2. QuoteContext Data Model - -The `QuoteContext` object will be a class instance passed through each stage of the pipeline. Each engine is responsible for populating its specific fields. - -```typescript -// In core/quote-context.ts -import { QuoteRequest } from '...'; // Import from actual location -import { Partner } from '...'; // Import from actual location - -export class QuoteContext { - // --- Initial Fields --- - public readonly request: QuoteRequest; - public readonly partner?: Partner; - public readonly targetFeeFiatCurrency: 'USD' | 'EUR' | 'BRL'; // etc. - - // --- Pipeline-Populated Fields --- - // from input-planner - public preNablaDeductibleFees?: FeeComponent[]; - public inputAmountForSwap?: BigNumber; - - // from swap-engine - public nablaSwapResult?: { - grossOutputAmount: BigNumber; - // ... other nabla details - }; - - // from bridge-engine - public evmBridgeResult?: { - bridgeFeeUsd: BigNumber; - networkFeeUsd: BigNumber; - // ... other bridge details - }; - - // from fee-engine - public feeComponents?: { - usd: FeeStructure; - displayFiat: FeeStructure; - }; - - // from discount-engine - public discountInfo?: { - discountAmount: BigNumber; - applied: boolean; - }; - - // from finalize-engine - public grossOutputAmount?: BigNumber; - public netOutputAmount?: BigNumber; - public formattedAmounts?: { - input: string; - output: string; - // ... other formatted fields - }; - - // from persistence-adapter - public persistenceIds?: { - quoteTicketId: string; - }; - - constructor(request: QuoteRequest, partner?: Partner) { - this.request = request; - this.partner = partner; - this.targetFeeFiatCurrency = getTargetFiat(request); // from helpers.ts - } -} - -// In types.ts -export interface FeeStructure { - network: BigNumber; - vortex: BigNumber; - anchor: BigNumber; - partnerMarkup: BigNumber; - total: BigNumber; - currency: string; -} -``` - -**Mutability Rules:** -- The `QuoteContext` is mutable. Each engine receives the context and adds or modifies fields. -- All monetary calculations should be performed using a `BigNumber` library to avoid precision loss. -- Currency conversions and rounding should **only** happen within the `price-feed-adapter.ts` and final formatting in `quote-mapper.ts` to prevent drift. - -## 3. RouteResolver and Strategies - -### RouteResolver - -`route-resolver.ts` will contain a `RouteResolver` class. - -```typescript -// In routes/route-resolver.ts -export class RouteResolver { - public static resolve(context: QuoteContext): IQuoteStrategy { - const { rampType, from, to, inputCurrency, outputCurrency } = context.request; - - if (rampType === 'on-ramp') { - // Special case for Monerium EURe on-ramp - if (inputCurrency === 'EUR' && to.network === 'assethub') { - return new OnRampAssetHubStrategy(); - } - if (to.network.startsWith('evm-')) { // Simplified logic - return new OnRampEvmStrategy(); - } - } - - if (rampType === 'off-ramp') { - if (outputCurrency === 'BRL') { - return new OffRampPixStrategy(); - } - if (outputCurrency === 'EUR') { - return new OffRampSepaStrategy(); - } - // ... other off-ramp strategies - } - - throw new Error('Unsupported route'); - } -} -``` - -### Strategies and Pipelines - -Each strategy defines the sequence of engines to run. - -| Strategy | Pipeline Stages | Optional Stages & Conditions | -|---|---|---| -| **On-ramp EVM** | `input-planner` -> `swap-engine` -> `bridge-engine` -> `fee-engine` -> `discount-engine` -> `finalize-engine` | - | -| **On-ramp AssetHub** | `input-planner` -> `swap-engine` -> `fee-engine` -> `discount-engine` -> `finalize-engine` | `bridge-engine` is **not** used. | -| **Off-ramp (PIX/SEPA/CBU)** | `input-planner` -> `swap-engine` -> `fee-engine` -> `discount-engine` -> `finalize-engine` | `input-planner` needs to pre-calculate the bridge fee for non-AssetHub sources to adjust the `inputForSwap`. This can be done by calling a helper from `bridge-engine`. | - -## 4. Engines Responsibilities - -- **input-planner.ts**: - - Wraps `calculatePreNablaDeductibleFees` from `quote-fees.ts`. - - For off-ramps from EVM chains, it will call a method on `bridge-engine` to get the estimated bridge fee to correctly calculate the amount available for the Nabla swap. - - Handles the special Monerium/EUR/AssetHub logic. -- **swap-engine.ts**: - - Wraps `calculateNablaSwapOutput` from `gross-output.ts`. -- **bridge-engine.ts**: - - Wraps `calculateEvmBridgeAndNetworkFee` and `getEvmBridgeQuote` from `gross-output.ts`. - - Exposes a helper function to estimate bridge fees for the `input-planner`. -- **fee-engine.ts**: - - Wraps `calculateFeeComponents` from `quote-fees.ts`. - - Centralizes all fee currency conversions. It will get all fee components, convert them to a baseline currency (USD) via `price-feed-adapter`, sum them, and then convert the total to the `targetFeeFiatCurrency`. -- **discount-engine.ts**: - - Computes and applies partner discounts. For on-ramps, the subsidy is added to the output. For off-ramps, it's added to the final net fiat amount. -- **finalize-engine.ts**: - - Calculates the final `netOutputAmount`. - - On-ramp EVM: `netOutputAmount` is the `grossOutputAmount` from the bridge. - - On-ramp AssetHub: `netOutputAmount` = `nablaSwapResult.grossOutputAmount` - (total fees converted to output token). - - Off-ramp: `netOutputAmount` = `nablaSwapResult.grossOutputAmount` - (total fees converted to output fiat). - - Runs min/max checks using `validation-helpers.ts`. - - Calls `quote-mapper.ts` to format all amounts for the final response. - -## 5. Adapters and Mappers - -- **price-feed-adapter.ts**: - - A thin wrapper around `priceFeedService`. - - All methods will accept `BigNumber` and return `BigNumber`. - - Centralizes rounding rules (e.g., `ROUND_HALF_UP`) and precision for all currency conversions. - - Can implement retry/backoff logic for price feed calls if needed. -- **persistence-adapter.ts**: - - A thin wrapper that takes the final `QuoteContext`. - - Creates the `QuoteTicket` record in the database. - - Returns the `quoteTicketId` to be stored in the context. -- **quote-mapper.ts**: - - Contains pure functions for formatting. - - Example: `formatAmount(amount: BigNumber, currency: string): string`. - - Uses `trimTrailingZeros` and other helpers from `helpers.ts`. - -## 6. Redundancy Simplification List - -- **Fee Conversion**: `fee-engine` will be the single source of truth for fee calculations. It computes fees in USD, then converts to the display fiat currency once. -- **Monerium Path**: The special logic for the Monerium EUR on-ramp will be entirely contained within the `onramp-assethub.strategy.ts` and its associated engines. `quote-orchestrator.ts` will have no specific knowledge of it. -- **Squid Router Fee**: The network fee from Squid Router will be calculated and exposed by `bridge-engine.ts`. -- **Min/Max Checks**: These checks will be performed only once in `finalize-engine.ts`, with clear rules for BUY vs. SELL scenarios. - -## 7. Migration Plan (Incremental) - -1. **PR1: Scaffolding**: Create the new directory structure and files with stubs (`// TODO: Implement`). Define `types.ts` and `quote-context.ts`. No behavior change. -2. **PR2: Isolate Monerium**: Implement `onramp-assethub.strategy.ts` and the necessary parts of the engines. The main `quote.service/index.ts` will delegate to the new orchestrator *only* for this route. Add parity tests. -3. **PR3: Port Core Engines**: Implement `input-planner.ts` and `swap-engine.ts`. Update all strategies to use them. The main service now delegates all routes to the orchestrator. Add parity tests. -4. **PR4: Port Fee Logic**: Implement `fee-engine.ts` and `discount-engine.ts`. Remove redundant fee conversion logic from the old service file. Add parity tests. -5. **PR5: Finalization & Persistence**: Implement `finalize-engine.ts`, `persistence-adapter.ts`, and `quote-mapper.ts`. The old service file should now be a very thin wrapper or completely replaced. Add snapshot tests for the final `QuoteResponse`. -6. **PR6: Cleanup**: Remove the old service file and any unused helpers. Update `docs/architecture/ramp-journey-and-fees.md` with an "Implementation Notes" section referencing the new architecture. - -## 8. Acceptance Criteria - -- No regressions in quote outputs for all supported routes. -- The main `quote.service/index.ts` is reduced to a simple entry point that calls the `QuoteOrchestrator`. -- Unit tests for each engine with mocked adapters. -- Integration tests covering each strategy (e.g., On-ramp EVM, Off-ramp PIX). -- Logging is maintained or improved, with logs providing context about the current pipeline stage. - -## 9. Test Plan - -- **Input Matrix**: - - On-ramp: EUR -> EVM, EUR -> AssetHub, BRL -> EVM, BRL -> AssetHub. - - Off-ramp: AssetHub -> PIX, EVM -> SEPA, EVM -> CBU. -- **Snapshot Tests**: - - Snapshot the entire `QuoteResponse` object, especially the `fee` structure in both USD and the display fiat. -- **Failure Simulation**: - - Mock the `price-feed-adapter` to throw an error to ensure the `bridge-engine` and other components handle it gracefully. - - Test edge cases like zero-amount quotes or unsupported pairs. diff --git a/docs/refactoring/signing-rejection-ui-plan.md b/docs/refactoring/signing-rejection-ui-plan.md deleted file mode 100644 index affa53d47..000000000 --- a/docs/refactoring/signing-rejection-ui-plan.md +++ /dev/null @@ -1,82 +0,0 @@ -# Plan: Signing Rejection UI Updates - -**Date:** 2025-04-25 - -**Goal:** Update the UI (button text and toast notification) when a user rejects a transaction signing during the off-ramp process. - -**Core Idea:** Introduce a state variable in the central `rampStore` to track signing rejection, update it from the `useRegisterRamp` hook when rejection is detected, and use this state in the `RampSummaryButton` and to trigger a toast notification. - -**Affected Files:** - -* `frontend/src/stores/rampStore.ts` -* `frontend/src/hooks/offramp/useRampService/useRegisterRamp.ts` -* `frontend/src/helpers/notifications.ts` -* `frontend/src/components/RampSummaryDialog/RampSummaryButton.tsx` -* Translation files (e.g., `frontend/src/translations/en.json`) - -**Detailed Plan:** - -1. **Update State Management (`frontend/src/stores/rampStore.ts`):** - * **Add State:** Introduce a new boolean state variable `signingRejected` to the `RampZustand` type and the store's initial state (default: `false`). - * **Add Action:** Create a new action `setSigningRejected(rejected: boolean)` in `RampActions` and implement it in the store to update the `signingRejected` state. - * **Update Reset:** Modify the `resetRampState` action to also set `signingRejected` back to `false`. - * **Persistence:** Include `signingRejected` in the `saveState` function if necessary (though defaulting to `false` on load might be sufficient). - * **Export Hook:** Create and export a new hook `useSigningRejected` for components to easily access this state. - -2. **Update Signing Logic (`frontend/src/hooks/offramp/useRampService/useRegisterRamp.ts`):** - * **Import:** Import `useRampActions` from `rampStore` and `useToastMessage` from `helpers/notifications`. - * **Remove Local State:** Delete the `const [userDeclinedSigning, setUserDeclinedSigning] = useState(false);` line. - * **Handle Rejection:** In the `catch` block of the `requestSignaturesFromUser` function: - * Replace `setUserDeclinedSigning(true)` with a call to `actions.setSigningRejected(true)`. - * Instantiate the toast hook: `const { showToast, ToastMessage } = useToastMessage();`. - * Call `showToast(ToastMessage.SIGNING_REJECTED)`. - * **Reset Rejection State:** At the beginning of the `registerRampProcess` async function, add `actions.setSigningRejected(false)` to ensure the rejection state is cleared when a new registration/signing attempt starts. - -3. **Update Notifications (`frontend/src/helpers/notifications.ts`):** - * **Add Enum:** Add `SIGNING_REJECTED` to the `ToastMessage` enum. - * **Add Config:** Add an entry for `ToastMessage.SIGNING_REJECTED` in the `toastConfig` object. Use `type: 'warning'` or `'info'` and define a new translation key (e.g., `toasts.signingRejected`). - * **Add Translation:** Ensure the translation key `toasts.signingRejected` is added to the language files with the value "Request cancelled". - -4. **Update Button UI (`frontend/src/components/RampSummaryDialog/RampSummaryButton.tsx`):** - * **Import:** Import the new `useSigningRejected` hook from `rampStore`. - * **Consume State:** Inside the `useButtonContent` hook, get the rejection state: `const signingRejected = useSigningRejected();`. - * **Conditional Logic:** Add a condition within the `useMemo` block: - ```javascript - if (signingRejected) { - return { - text: t('components.dialogs.RampSummaryDialog.tryAgain'), // New translation key - icon: null, - }; - } - ``` - * **Add Translation:** Ensure the translation key `components.dialogs.RampSummaryDialog.tryAgain` is added to the language files with the value "Try again". - -**Diagram (Simplified State Flow):** - -```mermaid -sequenceDiagram - participant User - participant Wallet - participant RampSummaryButton - participant useRegisterRamp - participant rampStore - participant Notifications - - User->>RampSummaryButton: Clicks 'Confirm'/'Continue' - RampSummaryButton->>useRegisterRamp: Triggers signing process (indirectly via state changes) - useRegisterRamp->>rampStore: actions.setSigningRejected(false) - useRegisterRamp->>Wallet: Request Signature(s) - alt User Rejects - Wallet-->>useRegisterRamp: Rejection Error - useRegisterRamp->>rampStore: actions.setSigningRejected(true) - useRegisterRamp->>Notifications: showToast(SIGNING_REJECTED) - Notifications->>User: Display "Request cancelled" toast - rampStore-->>RampSummaryButton: Update signingRejected=true - RampSummaryButton->>User: Update button text to "Try again" - else User Approves - Wallet-->>useRegisterRamp: Signature(s) - useRegisterRamp->>useRegisterRamp: Process signatures... - useRegisterRamp->>rampStore: Update rampState, etc. - rampStore-->>RampSummaryButton: Update state (no rejection) - RampSummaryButton->>User: Show "Processing" or next step - end diff --git a/docs/refactoring/vortex-fee-refactor-plan.md b/docs/refactoring/vortex-fee-refactor-plan.md deleted file mode 100644 index b94b31322..000000000 --- a/docs/refactoring/vortex-fee-refactor-plan.md +++ /dev/null @@ -1,60 +0,0 @@ -# Vortex Foundation Fee Refactoring Plan - -**Date:** 2025-04-29 - -**Goal:** Move the Vortex Foundation fee configuration from the `fee_configurations` table to the `partners` table to treat it consistently with other partner fees. - -**Assumptions:** - -* Only migration `001-initial-schema.ts` has been executed on the target database. -* Migrations `002-partners-table.ts` and `04-fee-configurations-table.ts` can be modified directly without concern for breaking changes on existing deployments. - -**Plan:** - -1. **Modify `api/src/database/migrations/002-partners-table.ts`:** - * **`up` function:** Add a `queryInterface.bulkInsert('partners', [...])` operation after the `addIndex` call to insert the Vortex Foundation partner record: - * `name`: `'vortex_foundation'` - * `display_name`: `'Vortex Foundation'` - * `markup_type`: `'relative'` - * `markup_value`: `0.01` (representing 0.01%) - * `markup_currency`: `'USD'` - * `payout_address`: `'6emGJgvN86YVYj5jENjfoMfEvX5p8hMHJGSYPpbtvHNEHTgy'` - * `is_active`: `true` - * `created_at`: `new Date()` - * `updated_at`: `new Date()` - * **`down` function:** Add a `queryInterface.bulkDelete('partners', { name: 'vortex_foundation' })` operation before the `dropTable` call to ensure the `down` migration correctly reverses the changes. - -2. **Modify `api/src/database/migrations/04-fee-configurations-table.ts`:** - * **`up` function:** - * In the `queryInterface.createTable('fee_configurations', ...)` call, modify the `fee_type` column definition: Remove `'vortex_foundation'` from the `ENUM` array. The new ENUM should be `('anchor_base', 'network_estimate')`. - * In the `queryInterface.bulkInsert('fee_configurations', [...])` call, remove the entire object corresponding to the `vortex_foundation` fee. - * **`down` function:** No changes are needed in the `down` function for this file. - -**Visual Plan (Mermaid Diagram):** - -```mermaid -graph TD - A[Start: Refactor Vortex Fee] --> B{Read `002-partners-table.ts` Schema}; - B --> C{Read `004-fee-configurations-table.ts` Content}; - C --> D{Confirm Vortex Fee Value: 0.01%}; - D --> E[Develop Modification Plan]; - - subgraph Modify 002-partners-table.ts - direction TB - F[Add `bulkInsert` for Vortex Partner in `up`] - G[Add `bulkDelete` for Vortex Partner in `down`] - end - - subgraph Modify 004-fee-configurations-table.ts - direction TB - H[Remove 'vortex_foundation' from `fee_type` ENUM in `up`] - I[Remove Vortex Fee object from `bulkInsert` in `up`] - end - - E --> F; - E --> H; - F --> G; - H --> I; - - G --> J{Plan Complete}; - I --> J; diff --git a/docs/research/blindpay-report.md b/docs/research/blindpay-report.md deleted file mode 100644 index 86ae4057f..000000000 --- a/docs/research/blindpay-report.md +++ /dev/null @@ -1,422 +0,0 @@ -# BlindPay — Research Report - -Research date: 2026-07-07 -Scope: `https://www.blindpay.com/docs/getting-started/overview` and the linked Essentials pages. -Goal: assess whether BlindPay can fit Vortex's existing headless integration style (the way we do BRLA / Stellar / Nabla onramp+offramp today) for the four questions below. - -> **TL;DR** -> 1. **KYC/KYB is fully headless** — we POST all PII and document URLs ourselves; no hosted UI / iframe / link to send the user to. Same shape as our existing flows. -> 2. **No Sumsub / Persona / Veriff import or sharing.** Verification is done in-house; we cannot reuse an existing Vortex KYC into BlindPay, and we cannot reuse a BlindPay KYC anywhere else. The "RFI" mechanism is for *their* compliance team to ask *our* customer for more info — it is not a 3rd-party KYC handoff. -> 3. **Yes, a signature is required** to bind an *external* destination on-chain address to a customer (recommended "secure" flow). There are **two no-signature workarounds**: (a) the disclaimed "paste the address" non-secure path, and (b) more importantly, **a BlindPay-managed wallet** that we receive the address from BlindPay and never need the user to sign anything. Same applies to AA wallets. -> 4. **Per-customer US virtual accounts exist** (routing+account number, no real "IBAN"). Deposits to them auto-generate a payin server-side. **However**, the payin object still appears to require a prior `payin_quote` per the docs — needs a sales clarification. Non-US rails (Brazil PIX, Mexico SPEI, Argentina, Colombia PSE) are quote-based, not "deposit anytime to a fixed account". -> 5. **No European IBAN / SEPA / SEPA Instant today.** SEPA is officially listed as `Europe (soon)`. Right now the only way to receive money from a European bank is via international SWIFT (5 business days, requires an invoice/PO-style compliance document per transfer). No "fixed IBAN deposit anytime" model for EUR. - ---- - -## 1. Is the KYC/B flow headless? - -**Yes — fully API-driven. No hosted-UI redirect, no iframe, no magic link.** - -### Evidence - -From the Customers page: - -> "For compliance and regulatory requirements, **every customer on your platform must be registered as a customer in BlindPay**. […] Every customer must complete a KYC process to verify their identity before sending or receiving funds." -> — https://www.blindpay.com/docs/essentials/customers - -The "Create a customer" section is a single cURL `POST` per KYC type, with tabs labelled `Standard KYC`, `Enhanced KYC`, `Standard KYB`. All required fields (name, DOB, address, tax ID, ID document type/front/back, selfie, proof-of-address, etc.) are sent as a JSON body. There is no reference anywhere in the page to a redirect URL, hosted form, or iframe for the user to complete. — https://www.blindpay.com/docs/essentials/customers - -The required-fields table confirms the shape (excerpt): - -> | Individual | Business | -> | First name, Last name, Date of birth, Email, Country, Tax ID, Phone, IP, Address 1/2, City, State, Postal code, ID Document – Country / Type / Front / Back, Proof of Address – Type / Document, Selfie File | Legal name, Tax ID, Formation date, Email, Country, Doing business as\*, Website\*, IP, Address, UBOS + Shareholders above 25%, Company Formation Document, Proof of Ownership Document, Proof of Address… | -> — https://www.blindpay.com/docs/essentials/customers - -### Document handling is also headless - -> "Upload generates file URLs from your customers' KYC documents and pictures. BlindPay encrypts them before sharing with vendors and saving them in our database, helping you stay compliant with data protection laws." -> — https://www.blindpay.com/docs/essentials/upload - -So the flow is: collect the file → `POST /v1/upload` → get back an encrypted `file_url` → put that URL in the `POST /v1/.../customers` body. Same shape as we use today for BRLA. - -### AI pre-screen available - -> "Analyze Document reads a PDF, JPG, or PNG with AI, checks it against the rules for its document type, and returns an `approval_rate` of `low`, `medium`, or `high` plus a short reason. Use it to pre-screen customer documents before submitting them for KYC, so you can prompt for a better file early instead of waiting for a rejection." -> — https://www.blindpay.com/docs/essentials/analyze-document - -### RFI (compliance follow-up) is also headless - -> "A Request for Information (RFI) is how BlindPay's compliance team asks for missing or clarifying details when a customer's KYC or KYB review is incomplete. […] 4. **Collect the fields** from your customer through your own UI. 5. **`POST /v1/.../rfi`** to submit the response in a single shot." -> — https://www.blindpay.com/docs/essentials/rfi - -So when BlindPay's compliance team needs more info, we don't redirect the user to a BlindPay page — we `GET` the field list, render it in our own UI, and `POST` the response. - -### Timing - -> "KYC Standard: ~60 seconds" (automated) -> "KYC Enhanced / KYB Standard: 3 hours to 1 business day" (manual review) -> — https://www.blindpay.com/docs/essentials/customers - -### Conclusion - -This matches our Vortex pattern exactly: we own the form, we own the UX, we just hit BlindPay's API with the data and the doc URLs. We never send the user to a BlindPay page. - ---- - -## 2. Does it allow Sumsub or equivalent KYC sharing? - -**No. No third-party KYC provider is mentioned, and there is no documented way to import or share KYC data with/from BlindPay.** - -### Evidence - -- I searched the docs sitemap, knowledge base and the customers / RFI / upload / analyze-document pages. The only names that appear are BlindPay's own "compliance team" and "vendors" (used in the upload encryption copy). -- The verification is done **in-house by BlindPay**: - - > "**KYC Standard** is the default verification. It's automated and typically completes in about 60 seconds." - > "**KYC Enhanced** […] all submissions are manually reviewed by the compliance team." - > — https://www.blindpay.com/knowledge-base/guides/kyc-basics - -- The only data-reuse mechanism in the API surface is **RFI** — and it is one-way: BlindPay compliance → asks our customer → we `POST` the answer back. It does not let us hand them a Sumsub applicant ID, nor let us extract a BlindPay KYC for use elsewhere. - - > "**There can only be one open RFI per customer at a time.** If compliance needs another round, a new RFI is created after the previous one is reviewed." - > — https://www.blindpay.com/docs/essentials/rfi - -- The upload pipeline explicitly states files are encrypted **by BlindPay** before being passed to vendors — implying vendor hops are BlindPay's choice, not ours: - - > "BlindPay encrypts them before sharing with vendors and saving them in our database" - > — https://www.blindpay.com/docs/essentials/upload - -### What this means for Vortex - -- We **cannot** reuse the KYC we already do for BRLA / the rest of our flows when we onboard a customer to BlindPay. Every BlindPay customer has its own BlindPay KYC lifecycle (`verifying` → `approved`/`rejected`/`compliance_request`). -- We also **cannot** reuse a BlindPay-approved KYC in any other provider — the data lives inside BlindPay. -- Net effect: if we put BlindPay behind a partner on the Vortex stack, that partner's end-customers go through a **second, independent KYC** with BlindPay. We must surface BlindPay's status (`verifying`/`approved`/etc.) in our UI, just like we already do for BRLA's KYC status. - -### Worth confirming with BlindPay sales - -- Whether they offer any "re-use existing KYC" partner program (e.g. via a signed attestation) for regulated platforms. The docs do not describe one. - ---- - -## 3. On-chain address (onramp destination) — signature required? - -**Yes for the recommended "secure" path, but the question is more nuanced than just yes/no.** There are three distinct ways to attach a destination address to a customer, and only one of them requires the user to sign. The signature is an off-chain `personal_sign`-style message, not an on-chain transaction. - -### Option A — Secure flow (recommended): user signs a message - -This is the path the docs push you toward. The full flow: - -1. **`GET` the message to sign** from BlindPay for this customer. -2. **User signs the message** in their own wallet. The docs explicitly recommend a standard library: - - > "Use a library like **wagmi** or **ethers.js** to sign the message and get the signature transaction hash." - > — https://www.blindpay.com/docs/essentials/blockchain-wallets - - This is plain `eth_signMessage` / viem's `signMessage` / wagmi's `useSignMessage` — i.e. an **EIP-191 `personal_sign`** string, not EIP-712 typed data, not an on-chain transaction. It is conceptually the same kind of ownership proof used by Sign-In With Ethereum (SIWE). -3. **`POST` the resulting `signature_tx_hash` + the wallet address** to BlindPay to attach the wallet to the customer. - -> "**Add a blockchain wallet (secure)** — This method attaches a wallet without entering the address manually. The steps are: 1. **Get the message to sign** 2. **Sign the message** 3. **Use the signature transaction hash to add the blockchain wallet**" -> — https://www.blindpay.com/docs/essentials/blockchain-wallets - -**What it proves:** the user controls the private key for the address. It does **not** transfer any funds, does **not** authorise future transfers, and is **not** an `approve`/`permit` of any kind. It is purely a one-time ownership attestation. - -**Account Abstraction (AA) is supported:** - -> "**Universal Wallet Support**: Compatible with all types of blockchain wallets, including Externally Owned Accounts (EOA) and Account Abstraction (AA) wallets" -> — https://www.blindpay.com/docs/getting-started/overview - -and - -> "Set the `is_account_abstraction` field to `true` and fill the `address` field with the wallet address." -> — https://www.blindpay.com/docs/essentials/blockchain-wallets (non-secure path doc, but the `is_account_abstraction` flag is wallet-type metadata, not auth-method-specific) - -**Supported chains for the secure flow** (these are the chains whose addresses can be bound to a customer as a payin destination): - -> | Chain | Mainnet chain ID | Testnet | -> | Ethereum | 1 | Ethereum Sepolia (11155111) | -> | Polygon | 137 | PoS Amoy (80002) | -> | Base | 8453 | Base Sepolia (84532) | -> | Arbitrum | 42161 | Arbitrum Sepolia (421614) | -> | Stellar | — | Stellar Testnet | -> | Solana | — | Solana Devnet | -> | Tron | — | — | -> — https://www.blindpay.com/docs/essentials/blockchain-wallets and https://www.blindpay.com/knowledge-base/guides/supported-chains - -No Pendulum/Polkadot/Substrate chain. If our Vortex users want the onramp destination to be on Pendulum, we have to bridge downstream. - -**Hard prerequisite for the onramp:** - -> "You also need a [blockchain wallet](https://www.blindpay.com/docs/essentials/blockchain-wallets#add-a-blockchain-wallet-secure) and a [payin quote](https://www.blindpay.com/docs/essentials/payin-quotes#create-a-payin-quote)." -> — https://www.blindpay.com/docs/essentials/payins - -So the wallet must be signed-for and registered before the payin quote is created. - -### Option B — Non-secure flow: paste the address, no signature - -> "**Add a blockchain wallet (non-secure)** — This method is not recommended because **if the funds are sent to the wrong address, the funds will be lost.**" -> — https://www.blindpay.com/docs/essentials/blockchain-wallets - -You set `is_account_abstraction: true` and pass the `address` directly, with no proof of ownership. The docs only do this for AA wallets in the non-secure example, but the same shape is available for EOAs. - -**Why this exists:** so that you can pre-register a destination address on behalf of a customer when the user isn't connected to a wallet yet (e.g. ops, batch onboarding, custodial address provided by you). The "funds will be lost" warning is about typos / wrong addresses — there is no way to recover to the right address if BlindPay mints to the wrong one. - -### Option C — Use a BlindPay-managed wallet (no signature, no paste — the cleanest "no UX" option) - -This is the one that matters for Vortex if we want to keep the onramp flow signature-free. - -> "A wallet is a BlindPay-managed account that lets your customers store stablecoins." -> "You can also receive stablecoins directly into a [BlindPay-managed wallet](https://www.blindpay.com/docs/essentials/wallets#collect-fiat) by using `wallet_id` instead of `blockchain_wallet_id` when creating the payin quote." -> — https://www.blindpay.com/docs/essentials/wallets and https://www.blindpay.com/docs/essentials/payins - -Mechanics: -1. We call `POST /v1/.../wallets` for the customer. -2. BlindPay returns an Arbitrum or Polygon address (USDC / USDT / USDB) that BlindPay itself controls on-chain. -3. We pass `wallet_id` (instead of `blockchain_wallet_id`) when creating the payin quote. -4. When the payin settles, BlindPay credits the managed wallet internally. - -**The user never signs anything.** We never deal with a wallet library. The user doesn't need a wallet at all to receive USDC/USDT from the onramp. - -Trade-offs: -- It is **custodial** at the BlindPay layer (BlindPay holds the private key of that address). This contradicts BlindPay's general "non-custodial" framing of the onchain side, but it is the explicit "Wallets" product. -- The user **cannot** later sweep those funds themselves without a BlindPay → external transfer. If we want the user to truly custody the funds on Pendulum, we then call a transfer/payout to move the funds onward (which means a quote + a payout, and a fee on the way out). -- Limited to **Arbitrum and Polygon only** (USDC and USDT). No Ethereum mainnet, no Base, no Solana, no Pendulum, no Stellar. (https://www.blindpay.com/docs/essentials/wallets) - -A second managed option is the **Offramp Wallet**: - -> "An offramp wallet is a blockchain wallet that BlindPay creates for you. For every USDC or USDT transaction sent to the wallet, BlindPay automatically converts the funds to fiat and sends them to your bank account." -> — https://www.blindpay.com/docs/essentials/offramp-wallets - -Same idea — BlindPay owns the address, we receive it, the user never signs — but it auto-converts to fiat. Useful only for the offramp direction, not for onramp. - -### Comparison - -| Path | Requires user signature? | Customer controls the private key? | Available chains | Custody model | -|---|---|---|---|---| -| **A. Secure (recommended)** | Yes (EIP-191 `personal_sign`) | Yes | EVM (Eth/Polygon/Base/Arbitrum), Stellar, Solana, Tron | Non-custodial for the user | -| **B. Non-secure** | No | Yes (but address is unverified) | Same as A | Non-custodial, but BlindPay has no proof of ownership | -| **C. BlindPay-managed wallet** | No | No — BlindPay holds the key | Arbitrum, Polygon only (USDC, USDT, USDB) | BlindPay-custodial; onchain funds live with BlindPay until swept | - -### Implications for Vortex - -- If the Vortex onramp needs to land on Pendulum, none of the above is a clean fit: the supported chains (Eth/Polygon/Base/Arbitrum/Stellar/Solana/Tron) do not include Pendulum/AssetHub. The deposit would land on a supported chain and we'd need an extra bridge/XCM leg. -- If the Vortex onramp is fine landing on EVM (e.g. we accept USDC on Polygon or Arbitrum as the user-facing stable), Option A (sign a message) is the idiomatic match for our existing wagmi/ethers-based UX. It's a one-time, off-chain `signMessage` — no extra popup cost beyond a normal "Sign in" flow. -- If we want **zero signature** at the onramp UX layer, Option C (managed wallet) works but introduces a BlindPay-custodial hold. Option B is technically signature-free but is disclaimed and is probably not what compliance wants to see in production. - ---- - -## 4. Dedicated deposit account + auto-mint without a quote? - -**Partially yes.** Per-customer US virtual accounts exist and BlindPay explicitly says deposits auto-generate a payin. But the "no quote" part is ambiguous in the docs and almost certainly only applies to US virtual-account rails; non-US rails are quote-driven. - -### What "dedicated deposit account" actually means - -> "A virtual account is a dedicated bank account that can be generated for each of your customers. US virtual accounts come with their own unique **routing number** and **account number**, enabling customers to send and receive payments throughout the United States banking system. Brazilian virtual accounts support local payment rails such as PIX." -> — https://www.blindpay.com/docs/essentials/virtual-accounts - -Note: BlindPay does **not** use the term "IBAN" anywhere — the US product is `routing + account number`. The Brazil product is PIX-only (no IBAN either; PIX uses a random key / BR Code). If we need real IBAN coverage (e.g. SEPA for EUR), that is **not in scope** here — only USD (US banks) and BRL (PIX). - -Available banking partners: - -> | Banking Partner | Use Case | Payment Methods | Countries | SLA | Cost | -> | US Bank 1 | Individuals and Businesses | ACH, Wire, SWIFT | US and Foreign | 24 hours | $1.50 / mo per account | -> | US Bank 2 | Businesses | ACH, RTP, Wire, SWIFT | Foreign only | 3–5 business days | $1.50 / mo per account | -> | US Bank 3 | Businesses | ACH, Wire, SWIFT | US only | 3–5 business days | $1.50 / mo per account | -> | Blind Pay LTDA | Individuals and Businesses | PIX, TED, Boleto | Brazil and Foreign | Instant | TBD | -> — https://www.blindpay.com/docs/essentials/virtual-accounts - -There is also a two-step approval gate (compliance → bank) and approval is **not guaranteed**: - -> "Issuance SLAs vary by virtual account type — see the table above. **Approval is not guaranteed at either stage**; both BlindPay's compliance team and the banking partner reserve the right to reject any application." -> — https://www.blindpay.com/docs/essentials/virtual-accounts - -### Do deposits auto-mint without a quote? - -**This is the most important sentence in the whole report:** - -> "**All incoming payments to a virtual account automatically generate a payin.** Transaction fees are charged on your invoice at the end of each billing cycle." -> — https://www.blindpay.com/docs/essentials/virtual-accounts - -That sounds like exactly what we want: a fixed account number, the user can deposit USD at any time, and the system will route the funds to their linked blockchain wallet automatically. No per-deposit quote. - -### But the payin doc contradicts this on first read - -> "A payin can only be executed if a payin quote was created previously, and you have 5 minutes to initiate the payin before the quote expires." -> — https://www.blindpay.com/docs/essentials/payins - -Reading the rest of that page clarifies the two paths: - -> "For US payments, customers with enabled [virtual accounts](https://www.blindpay.com/docs/essentials/virtual-accounts) will have their own virtual account details displayed. **For customers without virtual accounts, BlindPay will generate a unique memo code and provide BlindPay's bank account details for the transaction.**" -> — https://www.blindpay.com/docs/essentials/payins - -So my reading is: -- **With a virtual account (US only)**: the user has a fixed routing/account number. Funds arriving there *automatically* create a payin server-side. The 5-minute quote TTL is for the non-virtual-account case where BlindPay gives you a single BlindPay-owned account plus a per-deposit memo code. -- **Without a virtual account (all non-US rails, and US as a fallback)**: you must create a `payin_quote` and then a `payin` within 5 minutes, and the sender uses BlindPay's pooled bank account with a unique `memo_code`. - -This is also consistent with the settlement-time table, which lists `memo_code` and `blindpay_bank_details` separately from the virtual-account flow. - -### The destination still must be a signed blockchain wallet - -Auto-mint is to the customer's registered blockchain wallet — which has to be added (and signed) up front, as established in §3. So the real "deposit anytime, mint anytime" prerequisite chain is: - -1. Customer exists and is `approved`. -2. Customer has a signed blockchain wallet. -3. Customer has an `approved` virtual account (US or Brazil PIX only). -4. User wires/ACHs/PIXes USD/BRL to that account. BlindPay detects the credit and mints USDC/USDT to the wallet. - -### Limits to flag - -- **Per-customer transfer limits** apply even with a virtual account: - - > "Transfer limits are calculated on the stablecoin amount transferred. Each customer has separate limits for payouts (sending) and payins (receiving). | Per transaction: KYC Standard $10k, KYB Standard $30k, KYC Enhanced $50k | Daily: $50k / $100k / $100k | Monthly: $100k / $250k / $500k" - > — https://www.blindpay.com/docs/essentials/customers - - These can be raised via the `…/customers/{customer_id}/limit-increase` endpoint. - -- **Settlement times** for US ACH/Wire are 5 business days, which is a flow-level concern, not a permission concern. (https://www.blindpay.com/docs/essentials/payins) - -### Things to confirm with BlindPay sales / support - -- Confirm that the "auto-generates a payin" path on US virtual accounts does **not** require our backend to call `POST /v1/.../payin-quotes` first — the docs strongly imply this but don't say it explicitly. -- Confirm whether the auto-mint on a virtual-account deposit respects a pre-set "destination wallet" (the one we signed in §3) or whether the user can pick a wallet at deposit time. Reading the docs, only the pre-registered wallet is mentioned. -- Ask whether a US Bank 2/3 virtual account (3–5 business day SLA) can still auto-mint on receipt, or whether that gating is only available on US Bank 1 (24h SLA). -- Ask about EUR / SEPA / SWIFT-only corridors if we need IBAN coverage. The current partner matrix only lists US banks + Brazilian PIX. - ---- - -## 5. Europe — SEPA, IBAN, virtual accounts, EUR deposits - -**Short answer: there is no dedicated European deposit account (no IBAN, no SEPA virtual account) today. SEPA is officially on the roadmap but listed as `(soon)`. Right now, the only way to move EUR into BlindPay is via international SWIFT, which is not a "deposit anytime to a fixed account" model.** - -### What the docs say about SEPA - -From the Supported Countries knowledge-base guide, the supported payment-methods table explicitly lists: - -> | Type | Country/Region | -> | International SWIFT | Global | -> | ACH | United States | -> | Wire | United States | -> | RTP | United States | -> | Pix | Brazil | -> | SPEI | Mexico | -> | PSE | Colombia | -> | Transfers 3.0 | Argentina | -> | **SEPA** | **Europe (soon)** | -> | **Instant Payments** | **United Kingdom (soon)** | -> — https://www.blindpay.com/knowledge-base/guides/supported-countries - -The `(soon)` suffix appears on both SEPA and UK Instant Payments. There is no ETA on the page. - -### Virtual-account matrix confirms: no European banking partner - -The Virtual Accounts page lists the four banking partners BlindPay is integrated with. There is no EU bank in the list: - -> | Banking Partner | Use Case | Payment Methods | Countries | SLA | Cost | -> | US Bank 1 | Individuals and Businesses | ACH, Wire, SWIFT | US and Foreign | 24 hours | $1.50 / mo per account | -> | US Bank 2 | Businesses | ACH, RTP, Wire, SWIFT | Foreign only | 3–5 business days | $1.50 / mo per account | -> | US Bank 3 | Businesses | ACH, Wire, SWIFT | US only | 3–5 business days | $1.50 / mo per account | -> | Blind Pay LTDA | Individuals and Businesses | PIX, TED, Boleto | Brazil and Foreign | Instant | TBD | -> — https://www.blindpay.com/docs/essentials/virtual-accounts - -So there is no per-customer European IBAN to deposit EUR into, full stop. The closest things are: -- the three US banks' "Foreign" rail for US Bank 1 (SWIFT) and US Bank 2 (RTP) — but those are USD USD USD, denominated in USD even if the originator is foreign; -- and Brazil's PIX account (BRL only). - -### What you can do today with European counterparties - -You can add a European bank account as a **payee** for a payout (offramp direction, not onramp). The Bank Accounts page lists the available payout rails: - -> | Type | Country | Estimated time of arrival | -> | international_swift | 🌎 Global | ~5 business days | -> | ach | 🇺🇸 United States | ~2 business days | -> | wire | 🇺🇸 United States | ~1 business day | -> | rtp | 🇺🇸 United States | instant | -> | pix | 🇧🇷 Brazil | instant | -> | spei_bitso | 🇲🇽 Mexico | instant | -> | ach_cop_bitso | 🇨🇴 Colombia | ~1 business day | -> | transfers_bitso | 🇦🇷 Argentina | instant | -> — https://www.blindpay.com/docs/essentials/bank-accounts - -So a European beneficiary can be paid via `international_swift` (USD or the local currency, depending on the account), but the **onramp** side (fiat → USDC) has no European rail. - -### Important friction on the SWIFT payout side (for the offramp-to-EU case) - -Even where SWIFT is available, every B2B SWIFT payout requires a compliance document per transfer: - -> "Every B2B payment sent through SWIFT requires a transaction document showing the **relationship between the sender and the customer**." -> "BlindPay accepts the following transaction documents: Invoice, Purchase Order, Delivery Slip, Contract, Customs Declaration, Bill of Lading, Others." -> "**Important**: If the document doesn't show the relationship between the sender and the customer, the payment will be rejected." -> — https://www.blindpay.com/knowledge-base/guides/swift-deliverability - -And the payout is placed `on_hold` until BlindPay's compliance team approves the document: - -> "When you create a [SWIFT payout](https://www.blindpay.com/docs/essentials/payouts), the flow is: 1. **Payout created** → Status is `on_hold` 2. **Waiting for documents** → `tracking_documents.status: waiting_documents` 3. **Documents submitted** → `tracking_documents.status: compliance_reviewing` 4. **Compliance approved** → Payout proceeds to `processing`, fiat is sent" -> — https://www.blindpay.com/knowledge-base/guides/swift-statuses - -Timeouts: - -> "Document submission: 30 days from payout creation. Compliance review: 8 days from document submission." -> — https://www.blindpay.com/knowledge-base/guides/swift-statuses - -So a EU offramp is not "send USDC, EUR lands in 1 business day" — it is "send USDC, upload an invoice, wait up to 8 days for compliance review, then another ~5 business days for the SWIFT wire to land." - -### EU countries are onboardable, just not funded via SEPA - -All major EU countries (DE, FR, ES, IT, NL, BE, AT, PT, IE, FI, GR, etc.) appear as `Supported` in the country-risk table, so EU-domiciled customers can be KYC'd and onboarded: - -> "Austria — Supported, Belgium — Supported, Bulgaria — Supported, Croatia — Supported, Cyprus — Supported, Czech Republic (Czechia) — Supported, Denmark — Supported, Estonia — Supported, Finland — Supported, France — Supported, Germany — Supported, Greece — Supported, Hungary — Supported, Ireland — Supported, Italy — Supported, Latvia — Supported, Lithuania — Supported, Luxembourg — Supported, Malta — Supported, Netherlands — Supported, Poland — Supported, Portugal — Supported, Romania — Supported, Slovakia — Supported, Slovenia — Supported, Spain — Supported, Sweden — Supported" -> — https://www.blindpay.com/knowledge-base/guides/supported-countries - -The gap is only on the **payment rails**, not on the customer-jurisdiction side. - -### Implication for Vortex - -If we are considering BlindPay as a way to accept EUR from European counterparties into USDC/USDT, **today this is not possible** without going through a US virtual account (USD only) or a Brazilian virtual account (BRL only). The right path forward is one of: - -- Wait for SEPA / UK Instant Payments to ship. Ask BlindPay sales for a concrete ETA, since the docs only say `(soon)`. -- Add a fiat-→-stable leg in front of BlindPay for EUR (e.g. accept SEPA on our side, mint USDC ourselves, then push into BlindPay as a BlindPay-managed wallet credit) — but this defeats the point of using BlindPay for the EUR leg. -- Use BlindPay for non-EUR corridors only (US/Brazil/Mexico/Argentina/Colombia) until SEPA is live. - ---- - -## Summary table - -| Question | Answer | Confidence | Citation | -|---|---|---|---| -| Headless KYC/KYB (we send docs, not a hosted link) | **Yes** | High | customers, upload, rfi pages | -| Sumsub / equivalent KYC reuse or sharing | **No** | High (nothing in docs; needs sales confirmation only for a "partner reuse" program, if one exists privately) | kyc-basics, customers, rfi | -| On-chain address binding needs a signature | **Yes on the recommended (secure) path** — EIP-191 `personal_sign`, one-time, no on-chain tx. **No signature** if you use the disclaimed non-secure path **or** a BlindPay-managed wallet (option C) | High | blockchain-wallets, wallets | -| Dedicated per-customer deposit account that auto-mints without a quote | **Yes for US (ACH/Wire/SWIFT) and Brazil (PIX) virtual accounts**; non-virtual-account path is quote-driven and per-deposit | Medium-High (docs imply it; needs sales confirmation on the "no quote needed" wording) | virtual-accounts, payins | -| Europe — dedicated EUR / SEPA / IBAN account | **No.** SEPA is listed as `(soon)`. Only `international_swift` reaches Europe today, with per-payout compliance documents and ~5 business day ETA | High (explicitly stated in the supported-countries table) | supported-countries, virtual-accounts, bank-accounts, swift-deliverability, swift-statuses | - ---- - -## Sources - -All quotes and citations in this report come from the following BlindPay docs pages (all fetched 2026-07-07): - -- Overview — https://www.blindpay.com/docs/getting-started/overview -- Stable to fiat quickstart — https://www.blindpay.com/docs/getting-started/quick-start -- Fiat to stable quickstart — https://www.blindpay.com/docs/getting-started/quick-start-payin -- Customers — https://www.blindpay.com/docs/essentials/customers -- RFI — https://www.blindpay.com/docs/essentials/rfi -- Instance RFI — https://www.blindpay.com/docs/essentials/instance-rfi -- Upload — https://www.blindpay.com/docs/essentials/upload -- Analyze Document — https://www.blindpay.com/docs/essentials/analyze-document -- Blockchain Wallets — https://www.blindpay.com/docs/essentials/blockchain-wallets -- Wallets (managed) — https://www.blindpay.com/docs/essentials/wallets -- Offramp Wallets — https://www.blindpay.com/docs/essentials/offramp-wallets -- Payin Quote — https://www.blindpay.com/docs/essentials/payin-quotes -- Payins — https://www.blindpay.com/docs/essentials/payins -- Payout Quote — https://www.blindpay.com/docs/essentials/payout-quotes -- Payouts — https://www.blindpay.com/docs/essentials/payouts -- Bank Accounts — https://www.blindpay.com/docs/essentials/bank-accounts -- Virtual Accounts — https://www.blindpay.com/docs/essentials/virtual-accounts -- Transfer Quote — https://www.blindpay.com/docs/essentials/transfer-quotes -- Transfers — https://www.blindpay.com/docs/essentials/transfers -- Supported Chains — https://www.blindpay.com/knowledge-base/guides/supported-chains -- Supported Countries — https://www.blindpay.com/knowledge-base/guides/supported-countries -- KYC Basics — https://www.blindpay.com/knowledge-base/guides/kyc-basics -- SWIFT Deliverability — https://www.blindpay.com/knowledge-base/guides/swift-deliverability -- SWIFT Statuses — https://www.blindpay.com/knowledge-base/guides/swift-statuses -- Smart Contracts — https://www.blindpay.com/knowledge-base/guides/smart-contracts -- Virtual Accounts best practices (KB) — https://www.blindpay.com/knowledge-base/guides/virtual-account-best-practices diff --git a/docs/research/phase-handler-race-incident-2026-07-17.md b/docs/research/phase-handler-race-incident-2026-07-17.md deleted file mode 100644 index 5f16afc39..000000000 --- a/docs/research/phase-handler-race-incident-2026-07-17.md +++ /dev/null @@ -1,855 +0,0 @@ -# Phase-handler race incident report — 2026-07-17 - -Report date: 2026-07-17 -Affected ramp: `eb597373-ed70-4275-a63d-aac172bdfe7a` -Affected flow: BRL onramp, Base-to-destination EVM route -Primary phases: `squidRouterPay`, `finalSettlementSubsidy`, `destinationTransfer` -Status: root cause identified; targeted lock-refresh and `squidRouterPay` polling mitigations implemented after this report - -## Executive summary - -The ramp was processed by multiple overlapping phase executions. The first -`squidRouterPay` execution exceeded the phase processor's 10-minute timeout, but its -internal polling continued. A retry started 30 seconds later and also timed out while -continuing internally. The ramp's database lock was not renewed during this work, so the -recovery worker later treated the active lock as expired and started another execution. - -When the bridge eventually settled, several stale `squidRouterPay` executions completed -at approximately the same time. They independently advanced into -`finalSettlementSubsidy`, where at least two native-to-USDT funding swaps were submitted. -One execution then completed `destinationTransfer` and marked the ramp `complete`. -Another stale execution subsequently persisted an older phase transition and moved the -database state back to `destinationTransfer`. - -The successful destination transfer had already removed the expected token balance from -the ephemeral account. The regressed `destinationTransfer` execution therefore waited -for a balance that could no longer arrive. It exhausted its initial attempt plus eight -retries, remained nonterminal, and was selected again by the recovery worker. This cycle -continued approximately every 45 minutes for the remainder of the supplied logs. - -This was not a database deadlock. It was a phase-state regression followed by an -infinite recovery livelock. - -## Impact - -### Confirmed - -- The intended destination transfer executed successfully once according to the API - logs, and the ramp was temporarily marked `complete`. -- The persisted ramp phase was subsequently regressed to `destinationTransfer`. -- The recovery worker repeatedly executed an impossible balance precondition for more - than six hours. -- The supplied error log contains two `squidRouterPay` timeouts and 81 - `destinationTransfer` balance timeouts through `2026-07-17T18:06:05.842Z`. -- At least two final-settlement native-to-USDT swap transactions were submitted: - - `0xc9572cd1a67a2f50187ca527878319be66f11a8c441af5e853dabc5e3f6e8f2f` - - `0x6bc4adb60cfc770fb66c7a4a98eafdae6734936d2b2246f732b1a65a221792f6` -- Recovery cycles consumed RPC, database, worker, and logging capacity without making - progress. - -### Requiring on-chain reconciliation - -- Whether both final-settlement swap transactions succeeded and their exact output. -- Whether more than one final USDT subsidy transfer succeeded. -- The final token and native balances of the funding and ephemeral accounts. -- Whether the stored `destinationTransferTxHash` was never written, was overwritten by - a stale JSON update, or remained present but was absent from the stale in-memory model. -- The exact net financial loss, if any, from duplicate swap fees, slippage, or duplicate - subsidy transfers. - -The existence of a single `Subsidy` bookkeeping row does not prove that only one -on-chain subsidy occurred. Bookkeeping is written after the side effect, uses a -find-then-create sequence, and is not protected by a unique `(ramp_id, phase)` database -constraint. - -## Expected behavior - -For a non-Base EVM destination, the relevant final phase sequence is: - -```text -squidRouterSwap - -> squidRouterPay - -> finalSettlementSubsidy - -> destinationTransfer - -> complete -``` - -Only one processor should own the ramp. A timed-out execution should stop before a retry -starts. Once `complete` is persisted, no stale execution should be able to move the ramp -back to a nonterminal phase. Financial side effects should be recoverable without being -submitted twice. - -## Relevant implementation - -The report references the source tree as it existed during analysis: - -- Processor and retry loop: - `apps/api/src/api/services/phases/phase-processor.ts` -- Recovery worker: - `apps/api/src/api/workers/ramp-recovery.worker.ts` -- Base handler and phase transition construction: - `apps/api/src/api/services/phases/base-phase-handler.ts` -- Bridge settlement handler: - `apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts` -- Final settlement subsidy handler: - `apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts` -- Destination transfer handler: - `apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts` -- Existing processor findings: - `docs/security-spec/03-ramp-engine/state-machine.md` - -The security specification already records two directly relevant known findings: - -- F-003: database lock acquisition is not atomic. -- F-004: recoverable retry exhaustion leaves the ramp nonterminal, and a later - processing cycle receives a fresh retry budget. - -## Log evidence - -### Normal progression into the affected phase - -The ramp progressed normally through minting, swaps, fee distribution, and the Squid -source transaction. The relevant source and bridge transaction hashes were: - -```text -Nabla approve: -0x4d94497f041714aeb6492d4255a26e923e617c37ec36267d2a03440a87bf76ff - -Nabla swap: -0xc7ca43f2a80471d8cb53f6b70398df4686d9bf920f4ddeaf23f0a570adad6073 - -Fee distribution: -0x947144197d12e4e64857686888112dd1752d68ce19ce1b5f4a04d0d2deb34b50 - -Squid approve: -0x77edcb9fc091ff2d9e2797957c240e32ad60fbd2131ec31bbc513f0fc567f113 - -Squid swap / bridge source transaction: -0x141a0356e0db9ca7cf087918ffff9841f17fcb12a9175e70cc27b64fa9fd520f -``` - -The bridge was detected and additional Axelar gas was funded: - -```text -info [...] SquidRouterPayPhaseHandler: Bridge transaction detected on Axelar. Proceeding to fund gas. -info [...] SquidRouterPayPhaseHandler: Base fund transaction sent with hash: 0x404eea351f96c4243286181ea66c3f30fc0c089fe13980dfc23e745fa45e1294 -info [...] Subsidy created successfully with id 51204980-0deb-4342-b9f4-7c1d5600ac00 for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -``` - -### Two processor timeouts - -The persisted failure log contains these two entries: - -```json -{ - "error": "Phase execution timed out", - "phase": "squidRouterPay", - "timestamp": "2026-07-17T11:19:51.503Z", - "recoverable": true, - "isPhaseError": true -} -``` - -```json -{ - "error": "Phase execution timed out", - "phase": "squidRouterPay", - "timestamp": "2026-07-17T11:30:21.528Z", - "recoverable": true, - "isPhaseError": true -} -``` - -The interval is 10 minutes 30 seconds: the configured 10-minute execution timeout plus -the configured 30-second retry delay. The second entry proves that the retry remained in -the same phase for another complete timeout window. - -The operational log then shows recovery taking over the still-active ramp: - -```text -info Attempting recovery in phase squidRouterPay for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -info [...] Lock for ramp eb597373-ed70-4275-a63d-aac172bdfe7a has expired. Ignoring previous lock and continue processing... -info [...] Processing phase squidRouterPay for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -``` - -### Concurrent late completions - -At bridge settlement, the logs contain repeated success messages from executions that -had started at different times: - -```text -info [...] Phase squidRouterPay executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -info [...] Phase changed from squidRouterPay to finalSettlementSubsidy for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -info [...] Processing phase finalSettlementSubsidy for ramp eb597373-ed70-4275-a63d-aac172bdfe7a - -info [...] Phase squidRouterPay executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -info [...] Phase changed from squidRouterPay to finalSettlementSubsidy for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -info [...] Processing phase finalSettlementSubsidy for ramp eb597373-ed70-4275-a63d-aac172bdfe7a - -info [...] SquidRouterPayPhaseHandler: Transaction 0x141a0356e0db9ca7cf087918ffff9841f17fcb12a9175e70cc27b64fa9fd520f successfully executed on Axelar. -info [...] Phase squidRouterPay executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -``` - -Within a single `squidRouterPay` invocation, `Promise.any()` also leaves its losing -promise running. If the destination balance check wins first, the bridge-status polling -promise can continue and emit another late Axelar success message. This adds noise, but -it does not by itself explain duplicate phase transitions. The duplicate transitions -require multiple handler executions. - -### Duplicate final-settlement activity - -The overlapping executions both observed an 84,160-raw-unit USDT settlement shortfall -and an underfunded funding account: - -```text -info [...] FinalSettlementSubsidyHandler: Subsidizing 84160 raw units of USDT to 0xA778a815623892f25235932116637cA0F3BBc0b9 -info [...] FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to USDT -info [...] FinalSettlementSubsidyHandler: Swapping 1093793475827218534 native units (approx. rate 8.463755e-14) to get required subsidy. -info [...] FinalSettlementSubsidyHandler: Swap transaction sent: 0xc9572cd1a67a2f50187ca527878319be66f11a8c441af5e853dabc5e3f6e8f2f. Waiting for receipt... - -info [...] FinalSettlementSubsidyHandler: Subsidizing 84160 raw units of USDT to 0xA778a815623892f25235932116637cA0F3BBc0b9 -info [...] FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to USDT -info [...] FinalSettlementSubsidyHandler: Swapping 1093793475827218534 native units (approx. rate 8.463755e-14) to get required subsidy. -info [...] FinalSettlementSubsidyHandler: Swap transaction sent: 0x6bc4adb60cfc770fb66c7a4a98eafdae6734936d2b2246f732b1a65a221792f6. Waiting for receipt... -``` - -The handler does not persist the funding-swap hash before waiting for its receipt. Its -idempotency check only covers `finalSettlementSubsidyTxHash`, which is the later subsidy -transfer. It therefore cannot recognize or reconcile a previously submitted funding -swap. - -The log also records a later subsidy-transfer problem: - -```text -error [...] FinalSettlementSubsidyHandler: Transaction 0xac2c732bd0d3af0ea49a6f91c509ea1c6d21f2929839a7d31de5835d725704bd failed or was not found. Retrying... -``` - -This hash must be reconciled on-chain before calculating the incident's financial -impact. - -### Completion followed by state regression - -One execution completed the transfer and the ramp: - -```text -info [...] Phase destinationTransfer executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -info [...] Ramp eb597373-ed70-4275-a63d-aac172bdfe7a completed successfully -info Successfully processed ramp state eb597373-ed70-4275-a63d-aac172bdfe7a -``` - -After that completion, another execution continued and wrote the previous transition -again: - -```text -info [...] Subsidy entry already exists for ramp eb597373-ed70-4275-a63d-aac172bdfe7a in phase finalSettlementSubsidy -info [...] Phase finalSettlementSubsidy executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -info [...] Phase changed from finalSettlementSubsidy to destinationTransfer for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -info [...] Processing phase destinationTransfer for ramp eb597373-ed70-4275-a63d-aac172bdfe7a -``` - -This ordering is direct evidence that a stale execution survived beyond terminal -completion and was allowed to persist a nonterminal phase afterward. - -### Repeated destination balance failures - -Each destination failure has the same shape: - -```json -{ - "error": "DestinationTransferHandler: Error during phase execution - Balance did not meet the limit within 180000ms", - "phase": "destinationTransfer", - "details": "RecoverablePhaseError: DestinationTransferHandler: Error during phase execution - Balance did not meet the limit within 180000ms", - "recoverable": true, - "isPhaseError": true -} -``` - -The first failure was recorded at `11:40:54.397Z`. Since the balance timeout is exactly -180 seconds, that execution began waiting at approximately `11:37:54Z`, matching the -completion and regression window in the operational logs. - -The supplied failure log contains the following complete timestamp series. Each row is -one processing cycle containing the initial attempt plus eight retries: - -| Cycle | Destination-transfer failure timestamps (UTC) | -|---|---| -| Initial regressed execution | `11:40:54.397`, `11:44:24.951`, `11:47:55.432`, `11:51:25.951`, `11:54:56.506`, `11:58:26.945`, `12:01:57.485`, `12:05:27.960`, `12:08:58.610` | -| Recovery 1 | `12:23:00.780`, `12:26:31.446`, `12:30:02.109`, `12:33:32.877`, `12:37:03.464`, `12:40:34.033`, `12:44:04.816`, `12:47:35.443`, `12:51:06.032` | -| Recovery 2 | `13:08:00.844`, `13:11:31.435`, `13:15:01.970`, `13:18:33.224`, `13:22:03.809`, `13:25:34.417`, `13:29:04.917`, `13:32:35.531`, `13:36:06.192` | -| Recovery 3 | `13:53:00.720`, `13:56:31.250`, `14:00:01.764`, `14:03:32.689`, `14:07:03.395`, `14:10:34.101`, `14:14:04.725`, `14:17:35.317`, `14:21:05.916` | -| Recovery 4 | `14:38:00.728`, `14:41:31.433`, `14:45:02.381`, `14:48:33.360`, `14:52:03.910`, `14:55:34.468`, `14:59:05.088`, `15:02:35.655`, `15:06:06.184` | -| Recovery 5 | `15:23:00.711`, `15:26:31.414`, `15:30:01.887`, `15:33:32.441`, `15:37:03.126`, `15:40:33.636`, `15:44:04.177`, `15:47:34.683`, `15:51:05.468` | -| Recovery 6 | `16:08:00.987`, `16:11:31.619`, `16:15:02.368`, `16:18:32.970`, `16:22:03.539`, `16:25:34.226`, `16:29:04.739`, `16:32:35.844`, `16:36:06.547` | -| Recovery 7 | `16:53:00.716`, `16:56:31.304`, `17:00:01.887`, `17:03:32.583`, `17:07:03.111`, `17:10:33.684`, `17:14:04.332`, `17:17:34.832`, `17:21:05.366` | -| Recovery 8 | `17:38:00.588`, `17:41:31.184`, `17:45:01.717`, `17:48:32.232`, `17:52:03.241`, `17:55:34.029`, `17:59:04.731`, `18:02:35.297`, `18:06:05.842` | - -All timestamps are on 2026-07-17. There are 81 destination failures: nine attempts per -cycle across nine cycles. - -Within a cycle, failures are approximately 210 seconds apart: - -```text -180 seconds balance polling -+ 30 seconds retry delay -= 210 seconds per failed attempt -``` - -After the initial cycle, the first failures of later cycles settle into an approximately -45-minute cadence: - -```text -~31 minutes for nine attempts and eight retry delays -+ 10-minute stale-state threshold -+ up to 5 minutes for the recovery cron boundary -= approximately 45 minutes between recovery cycles -``` - -This cadence directly demonstrates that the retry budget is bounded only within one -in-memory `processRamp()` call. It is not bounded across recovery cycles. - -## Reconstructed timeline - -| Time (UTC) | Event | Confidence | -|---|---|---| -| ~11:09:51 | `squidRouterPay` execution A begins. | High, derived from the 11:19:51 timeout. | -| 11:10-11:11 | Axelar bridge is detected; Base gas funding transaction is sent and recorded. | Confirmed by operational logs. | -| 11:19:51 | Execution A reaches the processor's 10-minute timeout. Retry 1 is scheduled. | Confirmed by failure log. | -| ~11:20:21 | `squidRouterPay` execution B begins after the 30-second delay. | High, derived from timeout cadence. | -| ~11:29-11:30 | Recovery sees the unrenewed lock as expired and starts another execution. | Confirmed by operational logs; exact second unavailable. | -| 11:30:21 | Execution B reaches its 10-minute processor timeout. | Confirmed by failure log. | -| ~11:37 | Bridge settlement becomes visible. Multiple abandoned/live checks complete close together. | Confirmed by repeated success logs. | -| ~11:37 | Multiple `finalSettlementSubsidy` executions observe the same shortfall and submit at least two funding swaps. | Confirmed by distinct transaction hashes. | -| ~11:37 | One execution completes `destinationTransfer` and writes `complete`. | Confirmed by operational logs. | -| ~11:37 | A stale execution writes `finalSettlementSubsidy -> destinationTransfer` after completion. | Confirmed by log ordering. | -| ~11:37:54 | Regressed `destinationTransfer` starts waiting for the already-spent ephemeral balance. | High, derived from first 180-second timeout. | -| 11:40:54 | First regressed destination attempt times out. | Confirmed by failure log. | -| 12:08:58 | Initial nine-attempt destination budget is exhausted. Ramp remains nonterminal. | Confirmed by cadence and processor configuration. | -| 12:23:00 onward | Recovery repeatedly grants fresh nine-attempt budgets. | Confirmed by complete timestamp series. | -| 18:06:05 | Last supplied failure entry; the loop was still active. | Confirmed by failure log. | - -## Root-cause analysis - -### 1. Processor timeout did not cancel these production handlers - -`PhaseProcessor` races `handler.execute()` against a timeout and sends an -`AbortSignal` when the timeout wins. Shared EVM balance helpers support cancellation, -but cancellation works only if each handler accepts the signal and forwards it. - -The affected handlers do not propagate it: - -- `SquidRouterPayPhaseHandler.executePhase()` does not accept or forward the signal. -- Its destination balance check calls `checkEvmBalanceForToken()` without `signal`. -- Its initial delay, bridge-status loop, and polling delays are not abortable. -- `FinalSettlementSubsidyHandler.executePhase()` does not forward the signal to any of - its balance waits. -- `DestinationTransferHandler.executePhase()` does not forward the signal to its - balance wait. - -The existing cancellation regression test uses a synthetic handler that explicitly -passes its signal into `waitUntilTrue()`. It proves the processor emits a signal, but it -does not prove that real registered handlers stop after timeout. - -Result: the 10-minute timeout scheduled a retry while the timed-out execution remained -alive and capable of late phase transitions and financial side effects. - -### 2. The fixed lock lease expired during active work - -The processor writes `processingLock.lockedAt` once when processing starts. The lock is -considered expired after 15 minutes, but there is no heartbeat or renewal while: - -- a handler polls; -- the processor waits 30 seconds before a retry; -- recursive processing advances through multiple phases; or -- an external network operation takes longer than expected. - -The first `squidRouterPay` timeout occurred after 10 minutes. Its retry then consumed -another 10-minute window while retaining the original lock timestamp. The lock therefore -became eligible for takeover five minutes into the retry even though processing was -active. - -Result: the recovery worker legitimately followed the current lock rules but incorrectly -classified a live processor as crashed. - -### 3. Lock acquisition and release were not ownership-safe - -The database lock is a JSON flag checked on a previously loaded model instance and then -set using a separate unconditional update. This is not an atomic compare-and-swap. -Multiple API instances can observe an unlocked row and both set it to locked. - -The lock has no owner or fencing token. Release is also unconditional, so an old -execution can clear a lock acquired by a newer execution. - -The in-memory `lockedRamps` set protects only one Node.js process and cannot coordinate -multiple instances or an execution that has been removed from the set after its -processor timeout. - -Result: the lock cannot establish durable single ownership. - -### 4. Phase persistence allowed stale and terminal-state regression - -After a handler returns, the processor unconditionally updates `currentPhase` and -`phaseHistory`. The update does not require that: - -- the database remains in the handler's source phase; -- the ramp remains nonterminal; -- the caller still owns the lock; or -- the caller's lease generation is current. - -An execution that started in `finalSettlementSubsidy` can therefore return later and -write `destinationTransfer` even after another execution has written `complete`. - -Result: terminal state was not monotonic. `complete` was regressed to a nonterminal -phase. - -### 5. Whole-JSON metadata writes allowed lost updates - -Affected handlers write metadata using a stale in-memory spread: - -```ts -state: { - ...state.state, - someTransactionHash: txHash -} -``` - -Two concurrent model instances can each contain a different old snapshot. The later -write replaces the entire JSONB value and can remove hashes written by the earlier -execution. In this incident, a stale final-subsidy write could remove -`destinationTransferTxHash` after the successful destination broadcast. - -Even if the database retained the hash, the stale `RampState` passed recursively into -the regressed destination handler might not contain it. Either condition bypasses the -handler's receipt-based completion check. - -Result: recovery could fail to recognize an already-completed destination transaction. - -### 6. Final-settlement financial operations were not durably idempotent - -The final-settlement handler performs two potentially separate financial operations: - -1. Swap funding-account native token into the required output token when necessary. -2. Transfer the output token subsidy to the ephemeral account. - -The funding-swap hash is held only in a local variable while waiting for the receipt. It -is not persisted as an operation intent or recoverable transaction. Two concurrent -executions can both observe the same funding-account shortfall and submit independent -swaps before either balance update is visible. - -The later subsidy-transfer hash is persisted only after receipt confirmation and after -bookkeeping. A process crash or lost lease between broadcast and persistence leaves the -next execution unable to distinguish "not sent" from "sent but not recorded." - -Result: overlapping execution produced at least two funding swaps and exposed the -subsidy transfer to duplicate-submission risk. - -### 7. Retry exhaustion was not durable - -The retry counter is an in-memory `Map`. After the initial attempt plus eight retries, -the processor logs exhaustion, deletes the counter, and returns without moving the ramp -to a terminal or operator-intervention state. - -`processRamp()` catches or absorbs phase failures, so the recovery worker can log -`Successfully processed ramp state` even when the ramp remains stuck in the same phase. -Once `updatedAt` becomes older than ten minutes, recovery invokes `processRamp()` again -and receives a fresh retry budget. - -Result: the failed phase entered a predictable infinite soft loop. - -## Contributing factors - -- `squidRouterPay` intentionally allows a 15-minute destination balance wait, longer - than the processor's 10-minute handler timeout. -- The lock expiry is measured from the start of the entire processing call, not from - recent processor activity. -- `Promise.any()` does not cancel the losing bridge or balance check. -- Recovery cron executions are not explicitly configured with a durable per-ramp - backoff or manual-review cutoff. -- Logging does not include a processor execution ID, lock owner, lease generation, or - attempt-cycle ID, making overlapping workers difficult to distinguish. -- `Successfully processed ramp state` describes function return, not successful phase - advancement or terminal completion. -- The destination handler requires the full expected balance before checking/broadcasting - unless a usable stored hash is present. Once a successful transfer empties the account - and its hash is missing, the precondition can never recover naturally. - -## Why the destination phase could never recover - -The successful execution transferred the expected destination tokens from the ephemeral -account to the user. The stale execution then re-entered `destinationTransfer` with no -usable successful transaction hash and called the balance precondition first. - -Its required condition was effectively: - -```text -ephemeral destination-token balance >= full quoted output amount -``` - -After a successful destination transfer, the expected steady state is the opposite: - -```text -ephemeral destination-token balance ~= 0 -user destination-token balance increased -``` - -No amount of retrying can make the original precondition true unless the ephemeral is -funded again. Automatically funding it again would be unsafe because the user may -already have received the intended payment. - -## Immediate operational response - -Before manually changing this ramp or replaying any transaction: - -1. Stop automated recovery for this ramp, or place it in an operator-review state that - the recovery query excludes. -2. Derive the deterministic hash of the presigned destination transaction and check its - receipt on the destination chain. -3. Confirm the user destination balance and transfer event. -4. Inspect both final-settlement swap hashes and the subsidy-transfer hash - `0xac2c...704bd`. -5. Reconcile the funding account, ephemeral account, and `subsidies` records. -6. If the destination transaction succeeded, restore the ramp to `complete` without - rebroadcasting or re-funding. -7. Record any duplicate swap fees, slippage, or subsidy transfer as incident loss. - -Do not solve this instance by topping up the ephemeral account until the existing signed -destination transaction and recipient balance have been reconciled. A top-up could make -the stale handler pay the user a second time. - -## Recommended improvements - -### Priority 0: prevent stale phase writes - -Make every phase transition a conditional database update. At minimum, it must require -the expected source phase and reject terminal-state regression: - -```sql -UPDATE ramp_states -SET current_phase = :next_phase, - phase_history = :next_history -WHERE id = :ramp_id - AND current_phase = :expected_phase - AND current_phase NOT IN ('complete', 'failed'); -``` - -With lease ownership, it must additionally require the caller's fencing token. Exactly -one row must be affected. If zero rows are affected, the execution is stale and must stop -without invoking another handler. - -This is the strongest immediate containment because it protects terminal state even if -cancellation or locking fails elsewhere. - -### Priority 0: implement fenced leases - -Replace the Boolean JSON lock with ownership-aware lease fields, for example: - -```text -processing_owner_id -processing_generation -processing_lease_expires_at -``` - -Required properties: - -- Acquisition is one atomic conditional update. -- Every acquisition receives a new, monotonically increasing generation or unique - fencing token. -- Active processors renew the lease before expiry. -- Phase and metadata writes require the current owner/generation. -- Release updates only the row owned by that execution. -- An old execution cannot release or write through a newer owner's lease. - -A long-running database transaction or row lock should not be held across external API -and chain waits. A short atomic lease with heartbeat and fencing is better suited to this -workflow. - -### Priority 0: complete cancellation propagation - -Every long-running production handler must accept the processor's `AbortSignal` and pass -it through all waits: - -- `checkEvmBalanceForToken({ signal })` -- bridge status polling -- initial and retry delays -- receipt waits where the client supports cancellation or bounded polling -- other shared `waitUntilTrue*` helpers - -For `squidRouterPay`, create a child abort controller for the balance and bridge checks. -When either branch establishes settlement, abort the other branch before returning. - -Cancellation is cooperative and should not be the only correctness boundary. Fenced -database writes are still required because an RPC call or external library may not stop -immediately. - -### Priority 1: align timeout and lease semantics - -- A phase's internal maximum wait must not exceed the processor timeout unless the - processor timeout is renewed or disabled for that handler. -- Lock lease expiry must be based on missed heartbeats, not total ramp duration. -- Emit explicit metrics when a handler timeout, lease expiry, or takeover occurs. -- A takeover should include the previous owner and generation in logs. - -### Priority 1: make transaction side effects durably idempotent - -For each backend-funded transaction: - -1. Persist an operation record or reserved nonce before broadcast. -2. Broadcast the transaction. -3. Persist the transaction hash immediately after the node accepts it, before waiting - for a receipt. -4. On recovery, reconcile the existing hash/nonce before submitting another transaction. -5. Mark success only after receipt verification. - -Apply this to both the final-settlement funding swap and subsidy transfer. A single -`finalSettlementSubsidyTxHash` is insufficient to represent both operations. - -Where the transaction is presigned, derive its deterministic transaction hash directly -from the serialized signed transaction. The destination handler can check that receipt -without relying exclusively on mutable state metadata. - -### Priority 1: prevent lost JSON metadata updates - -Do not replace the whole `state` JSONB document from stale model snapshots. Use one of: - -- conditional `jsonb_set` updates for individual fields; -- a normalized transaction-operation table; -- row-version optimistic concurrency; or -- reload, merge, and compare-and-swap under the current lease token. - -Transaction hashes should be monotonic: once a valid hash is written, unrelated updates -must not remove it. - -### Priority 1: persist retry state and recovery eligibility - -Move retry policy out of the process-local `Map`. Persist at least: - -```text -phase_attempt_count -next_retry_at -last_phase_error_at -recovery_status -``` - -After the durable maximum is reached, transition to an explicit state such as -`manualReview` or mark the ramp as recovery-ineligible. Do not automatically grant a new -budget merely because another cron cycle begins. - -The recovery worker should report outcomes accurately: - -- `completed` -- `advanced` -- `retry_scheduled` -- `manual_review_required` -- `skipped_lock_held` -- `failed` - -It should not log `Successfully processed` when the phase remained unchanged after -retry exhaustion. - -### Priority 2: strengthen subsidy bookkeeping - -- Add a database uniqueness constraint appropriate to the intended accounting model, - likely `(ramp_id, phase, operation_type)` rather than only `(ramp_id, phase)` if a phase - can legitimately contain multiple operations. -- Replace `findOne()` followed by `create()` with an atomic insert/upsert. -- Treat bookkeeping as evidence of a reconciled transaction, not as the idempotency - mechanism for the transaction itself. - -### Priority 2: improve observability - -Add structured fields to all processor and handler logs: - -```text -rampId -phase -processorExecutionId -attempt -retryCycle -lockOwner -lockGeneration -expectedPhase -persistedPhase -transactionHash -operationType -``` - -Add alerts for: - -- a transition from `complete` or `failed` to a nonterminal phase; -- more than one active execution ID for the same ramp; -- lease takeover while the previous owner is still producing logs; -- more than one funding transaction for the same ramp/phase/operation; -- repeated retry-budget exhaustion; -- a ramp receiving more than a configured number of errors per hour. - -## Verification plan - -The fixes should include regression tests that reproduce the incident rather than only -testing isolated helpers. - -### Processor concurrency tests - -- Start two processors against the same database row and assert only one atomic lease - acquisition succeeds. -- Let owner A's lease expire, let owner B acquire a new generation, then assert A cannot - transition a phase, update metadata, or release B's lease. -- Complete a ramp in owner B and assert a late return from owner A cannot regress - `complete`. -- Run the same test using separate `PhaseProcessor` instances to model separate API - processes; an in-memory set is insufficient for this test. - -### Cancellation tests - -- Execute the real `SquidRouterPayPhaseHandler` with controlled bridge and balance - adapters, trigger processor timeout, and assert all polling stops. -- Assert the losing branch of the settlement race is cancelled after the other succeeds. -- Repeat for real final-settlement and destination balance polling. - -### Financial idempotency tests - -- Crash or abort after funding-swap broadcast but before receipt persistence; recovery - must reconcile rather than broadcast a second swap. -- Crash after subsidy-transfer broadcast but before phase advancement; recovery must - recognize the existing transaction. -- Run two concurrent final-settlement handlers and assert only one operation intent and - one on-chain submission are produced. - -### Recovery tests - -- Exhaust the durable retry budget and assert subsequent recovery cron runs do not reset - it. -- Assert the worker does not report success when no phase progress occurred. -- Given a completed deterministic presigned destination transaction but missing metadata, - assert recovery derives its hash, finds the receipt, and restores `complete` without - requiring the spent ephemeral balance to return. - -## Conclusion - -The incident required several protections to fail together: - -1. `squidRouterPay` exceeded the processor timeout. -2. Timeout cancellation was not propagated into the real polling handler. -3. The unrenewed 15-minute lock expired while legitimate retry work was active. -4. Recovery started another execution without ownership fencing. -5. Multiple stale executions advanced concurrently when bridge settlement arrived. -6. Final-settlement operations were not durably idempotent and at least two funding - swaps were submitted. -7. A stale phase transition was allowed to overwrite terminal `complete`. -8. The destination transaction could not be recognized reliably after its funds had - left the ephemeral account. -9. Process-local retry exhaustion was reset by each recovery cycle. - -The immediate symptom was an endless `destinationTransfer` balance timeout, but changing -that timeout or increasing retries would not address the failure. The primary correctness -boundary must be an atomic, ownership-fenced phase transition that makes terminal states -monotonic. Cooperative cancellation, lease renewal, durable transaction idempotency, -metadata-safe updates, and persistent retry state are the supporting controls required -to prevent recurrence and limit impact when an external bridge is slow. - -## Implemented targeted mitigation - -The initial low-impact production mitigation implements the timing and cancellation -controls identified in this report without replacing the existing lock model: - -- `PhaseProcessor` refreshes `processingLock.lockedAt` before every phase attempt, - including retries and recursive phase advancement. -- The existing processor timeout remains the outer 10-minute safety boundary and is now - read through shared phase-processor timeout configuration. -- `squidRouterPay` bounds both its destination-balance check and bridge-status loop at - 80% of the processor timeout. -- `squidRouterPay` forwards the processor's `AbortSignal` to its balance check, polling - delays, and Axelar recovery request so timed-out executions unwind cooperatively. -- If neither polling branch detects settlement, both reject and `checkStatus` raises a - recoverable phase error before the processor's outer timeout. - -Under normally bounded external requests, these changes prevent the incident's -lock-expiry-during-retry path and bound unsuccessful `squidRouterPay` polling before the -processor timeout. - -### Timing measurements and resulting envelope - -| Measurement | Incident / default value | Source | -|---|---:|---| -| Processor phase timeout | 10 minutes (`600,000ms`) | `PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS` default | -| SquidRouter polling timeout after mitigation | 8 minutes (`480,000ms`) | 80% of the processor timeout | -| Database lock expiry | 15 minutes | `PhaseProcessor.isLockExpired()` | -| Default retry delay | 30 seconds | `PHASE_PROCESSOR_RETRY_DELAY_MS` default | -| Incident interval between SquidRouter timeouts | 10 minutes 30 seconds | `11:19:51.503Z` to `11:30:21.528Z` | -| Destination retry-attempt interval | approximately 3 minutes 30 seconds | 180-second balance timeout plus 30-second retry delay | -| Repeated recovery-cycle interval | approximately 45 minutes | Supplied failure-log timestamp series | - -The eight-minute value is derived from the same configuration read as the processor -timeout, so test or deployment overrides preserve the 80% relationship. The focused -configuration regression test verifies that a `1,000ms` processor timeout produces an -`800ms` SquidRouter timeout. - -The bridge timer starts before the existing 60-second initial delay. Timeout detection -occurs at a bridge-loop boundary, so the practical bridge rejection can be later than -exactly eight minutes by up to the 10-second polling interval plus the duration of an -in-flight SquidRouter or Axelar status request. The approximately two-minute margin to -the processor timeout is intended to absorb that normal overrun. - -The destination-balance branch uses the same eight-minute timeout. `Promise.any()` only -rejects after both bridge and balance checks reject, at which point `checkStatus` raises -a `RecoverablePhaseError`. Under normally bounded external requests, the handler exits -before the processor's 10-minute outer timeout and the processor starts its retry after -30 seconds. - -At the start of that retry, `processPhase()` refreshes `lockedAt`. The expected lock-age -sequence is therefore: - -```text -00:00 lock refreshed; SquidRouter attempt starts -~08:00 both settlement checks time out recoverably -~08:30 lock refreshed; retry starts -``` - -This remains well below the 15-minute lock expiry and prevents the recovery worker from -creating the second tracked processor through the exact timing path observed in this -incident. - -### Expectations of this mitigation - -- A normally responsive but unsettled SquidRouter/Axelar operation exits recoverably at - approximately eight minutes instead of reaching the processor's 10-minute timeout. -- Every phase attempt and retry refreshes the lock before handler execution. -- A tracked retry should not be mistaken for abandoned work merely because the original - lock timestamp is older than 15 minutes. -- The incident path where recovery took over during the original processor's retry - should no longer produce two tracked `processRamp()` chains. -- The outer processor timeout remains unchanged as a fallback for unexpectedly blocked - code outside the normal polling cadence. - -### Limitations and residual risks - -- SquidRouter and Axelar status requests do not accept the processor's `AbortSignal`, so - an in-flight status request cannot be interrupted. Cancellation takes effect at the - next signal-aware delay or helper boundary. -- `Promise.any()` does not cancel its losing branch when the other branch succeeds. If - balance settlement succeeds before bridge-status polling finishes, the bridge branch - can continue until it succeeds, fails, reaches its eight-minute deadline, or the - processor signal is aborted while the tracked processor advances into later phases. -- Because the bridge branch contains Axelar gas funding, a losing branch can still make - that side effect before its deadline. Existing transaction-hash checks reduce repeat - funding, but this change is not a general financial-idempotency guarantee. -- Timeout checks run between polling iterations. They do not interrupt an in-flight RPC, - HTTP request, transaction submission, or receipt wait. -- Database lock acquisition remains non-atomic (F-003). Two API instances that begin - from an unlocked row at the same time can still create two tracked processors. -- The lock has no owner token. Refresh and release do not prove that the caller owns the - current lock. If an orphan and a replacement processor already coexist, the orphan's - unconditional refresh can extend or overwrite the replacement owner's lease timestamp; - lock refresh must become owner-conditional when fencing is implemented. -- Phase transitions are still not compare-and-swap updates. If concurrent tracked - processors arise through another path, a stale transition can still overwrite a newer - phase, including a terminal phase. -- Recoverable retry counts remain process-local (F-004). Recovery can grant a new retry - budget after one processing cycle exhausts its retries. `squidRouterPay` now reaches - this existing soft-livelock path itself: nine eight-minute attempts plus eight - 30-second delays can consume approximately 76 minutes, after which stale recovery can - grant another complete budget if the bridge never settles. -- Whole-JSON metadata writes and final-settlement transaction idempotency are unchanged. - -Ownership-fenced locking, conditional phase persistence, durable retry state, and -transaction-operation reconciliation therefore remain recommended follow-up work. This -mitigation is intentionally scoped to the observed lock-expiry-during-retry path and to -ensuring normal SquidRouter polling rejects before the outer processor timeout. diff --git a/docs/runbooks/dashboard-schema-production-rollout.md b/docs/runbooks/dashboard-schema-production-rollout.md deleted file mode 100644 index e4b5197be..000000000 --- a/docs/runbooks/dashboard-schema-production-rollout.md +++ /dev/null @@ -1,190 +0,0 @@ -# Production rollout — dashboard app + unified schema (migrations 038–049) - -Checklist and reference for promoting the dashboard/unified-schema release -(PR #1236, `feature/dashboard-app-staging` → `staging` → `main`) to production. -Verified on staging 2026-07-15: all migrations applied at boot, parity checks passed -(see §4), zero status drift. - -What the release contains: the unified customer schema (`customer_entities`, -`provider_customers`, `kyc_cases`), the partner split + API-key partner FK, -recipients/invitations, notifications, Monerium server-side OAuth, the dashboard app -(own domain, `dashboard.vortexfinance.co`), and the widget KYB deep-link changes. - ---- - -## 1. Before the production deploy - -### Render (production API service) - -- [ ] `MONERIUM_CLIENT_ID=` -- [ ] `MONERIUM_REDIRECT_URI=https://dashboard.vortexfinance.co/monerium/callback` -- Both are **required** — the API refuses to boot without them (`config/vars.ts`). -- Do **not** set `MONERIUM_API_URL` or `SANDBOX_ENABLED` (default resolves to the - production `api.monerium.app`). -- Do **not** set `DASHBOARD_ORIGINS` — `dashboard.vortexfinance.co` is hardcoded in the - CORS whitelist; the env var is only for staging/preview origins. - -### Monerium application portal - -- [ ] Register `https://dashboard.vortexfinance.co/monerium/callback` as a redirect URI - (must match `MONERIUM_REDIRECT_URI` byte for byte). - -### Netlify — production dashboard site - -- [ ] Site created with **Base directory `apps/dashboard`** (build/publish come from - `apps/dashboard/netlify.toml`; leave command/publish/package-directory empty in the UI). -- [ ] `VITE_API_URL=https://api.vortexfinance.co` -- [ ] `VITE_WALLETCONNECT_PROJECT_ID=` -- [ ] `VITE_ALCHEMY_API_KEY=` -- `VITE_WIDGET_URL` may stay unset — production builds default to - `https://app.vortexfinance.co`. -- [ ] DNS: `dashboard.vortexfinance.co` → the Netlify site. - -### Netlify — existing frontend (widget) site - -- [ ] `apps/frontend/netlify.toml` overrides the UI build settings on the first deploy - that contains it — confirm they agree with the current UI configuration. - ---- - -## 2. Deploy - -- [ ] Merge/promote per the usual staging → main flow. -- Migrations **038–049 run automatically at API boot** (umzug). First boot is slower - than usual. A dropped connection mid-migration (the Supavisor pooler has produced - `EAUTHTIMEOUT` before) is safe to retry — completed migrations are bookkept in - `SequelizeMeta` and each migration's backfills are idempotent. -- [ ] Watch the first boot's logs until `SequelizeMeta` reaches - `049-unique-customer-entities-profile-type`. -- No `seed:phase-metadata` rerun is needed (no seeder changes in this release). - -### Rollback posture - -Rolling the **app** back to the previous release is safe **without** reverting -migrations — the schema changes are additive and the old code ignores the new tables. -Never run `migrate:revert*` against production (migration `down()`s drop tables). - ---- - -## 3. Post-deploy data checks - -Production-specific — staging was clean on all three, but production data is real: - -- [ ] **Quarantined Avenia rows**: `tax_ids` rows with `user_id IS NULL` were deliberately - not migrated. Check how many have a real subaccount - (`sub_account_id <> ''`) — those users' KYC status is invisible to the new schema until - they re-onboard, at which point the adoption path - (`brla.controller.ts`, `TaxId.findByPk` on subaccount creation) reclaims the legacy - subaccount by tax id. Non-zero is acceptable; know the number. -- [ ] **Ramp-locked users**: users with a non-terminal ramp cannot register a new one. - Ramps stuck in `initial` self-heal (swept at that user's next registration after 15 - minutes); ramps **wedged past `initial`** block their user indefinitely and need an - operator to move them to a terminal phase after verifying no funds are in flight. -- [ ] **Orphaned API keys**: active keys with `partner_name` set but `partner_id NULL` - are treated as revoked by the new validators. Confirm no production integration - depends on one (staging had zero). - -All three are counted by Section 2 of the parity script (next section). - ---- - -## 4. Parity verification (precondition for ever dropping legacy tables) - -Script: [`apps/api/scripts/schema-parity-checks.sql`](../../apps/api/scripts/schema-parity-checks.sql) -— pure `SELECT`s, mirrors the backfill eligibility rules of migrations 038/039/040 and -the 045 status canonicalization. - -Run it **soon after the deploy**: the backfills are one-shot (no dual-write), so the -legacy tables start drifting from live data immediately, and parity comparisons lose -meaning over time. - -Access notes (Supabase): - -- Every table has RLS enabled; a read-only role sees zero rows without help, and - `BYPASSRLS` cannot be granted (requires superuser, which Supabase's `postgres` isn't). -- Either run the script directly in the Supabase SQL editor (runs as `postgres`, the - table owner, which bypasses RLS), or create a temporary read-only role plus per-table - `FOR SELECT TO USING (true)` policies, and drop both afterwards. -- Section 0 of the script is an RLS sanity probe — if it reads 0 on tables that - `pg_stat_user_tables.n_live_tup` says are populated, fix access before interpreting - anything else. - -Expected results: - -- **Section 1 (parity): all 0.** Exception: `1e` counts provider accounts without a KYC - case, and an **in-flight Monerium authorization started after the deploy** legitimately - appears there (the KYC case is created later in that flow) — check `created_at` before - treating it as a gap. -- **Section 2 (info): non-zero expected**; these size the deliberately skipped buckets - from §3. -- **Section 3 (status drift): empty right after the deploy.** Later rows normally mean - the account progressed post-deploy (the new schema is authoritative). - -- [ ] Section 1 all zero (or explained by post-deploy activity) -- [ ] Section 2 numbers reviewed and acceptable -- [ ] Section 3 empty -- [ ] Record the result (date + numbers) in this file or the PR - ---- - -## 5. Smoke tests - -- [ ] Dashboard: OTP login at `dashboard.vortexfinance.co`, account-type selection, - corridor statuses render from `GET /v1/onboarding/status`. -- [ ] Monerium: start EU onboarding → hosted OAuth → callback lands on - `/monerium/callback` → corridor moves to `started`/`in_review`. -- [ ] Widget: a `?kybLocked=BR` deep link renders the **company** (CNPJ) form, not the - individual CPF form. -- [ ] Recipients: create an invite, open the link, redeem in the widget. -- [ ] CORS: dashboard requests to `api.vortexfinance.co` succeed (origin is hardcoded in - `config/express.ts`). - ---- - -## 6. Table inventory — what stays, what can go - -Reference for the eventual drop migration. **Do not write that migration until the -production parity check (§4) has passed and one full release cycle has run clean.** - -### Keep (live schema) - -| Table | Role | -| :-- | :-- | -| `profiles` | Login identity + `active_customer_entity_id` pointer | -| `customer_entities` | Legal/compliance customer anchor (new) | -| `provider_customers` | Unified provider/rail accounts (new) | -| `kyc_cases` | Unified KYC/KYB attempts (new) | -| `partners`, `partner_pricing_configs` | Post-split partner identity + per-direction pricing | -| `profile_partner_assignments` | Kept per schema plan | -| `api_keys` | Live; see column note below | -| `recipient_invitations`, `sender_recipients`, `recipient_payout_references` | Recipient product (new) | -| `notifications`, `notification_preferences` | Notifications (new) | -| `quote_tickets`, `ramp_states`, `subsidies`, `anchors`, `webhooks`, maintenance/observability tables | Unchanged operational schema | - -### Droppable after production parity + one clean release cycle - -| Table | Why it exists | Drop precondition | -| :-- | :-- | :-- | -| `mykobo_customers` | 040 backfill source; no model, no reads/writes | §4 passed in production | -| `alfredpay_customers` | 040 backfill source; no model, no reads/writes | §4 passed in production | -| `kyc_level_2` | Superseded by `kyc_cases` (no data conversion — dead before migration) | §4 passed in production | -| `partners_legacy` | 039 pre-split snapshot, "never read by code" | §4 passed in production (1f specifically) | - -### Not yet droppable - -| Object | Blocker | -| :-- | :-- | -| `tax_ids` | One live read remains: the subaccount **adoption** path in `brla.controller.ts` (legacy row claimed on re-onboarding), and it quarantines the unowned rows from §3. Drop only after deciding what happens to unclaimed quarantined subaccounts and removing the adoption read. | -| `api_keys.partner_name` (column) | **Load-bearing**: the validators use "partner_name set + partner_id NULL" to detect orphaned partner keys (partner deletion = revocation). Droppable only after an explicit revocation mechanism (e.g. cascade `revoked_at` on partner deletion) replaces the heuristic. | - -### Checklist for the future drop-migration author - -- [ ] §4 recorded as passed on production -- [ ] One full release cycle since, with no reads of the legacy tables (they have no - models — a grep for the table names in `apps/api/src` should only hit migrations) -- [ ] `tax_ids`: adoption read removed or explicitly retired; quarantined-row policy decided -- [ ] `api_keys.partner_name`: revocation cascade shipped first -- [ ] Migration drops tables only (no data transformation); `down()` restores nothing — - document it as irreversible -- [ ] Security-spec sync: `01-auth/api-keys.md` (partner_name references) and - `03-ramp-engine/recipient-transfers.md` diff --git a/docs/security-spec/00-system-overview/architecture.md b/docs/security-spec/00-system-overview/architecture.md index 1af5b1112..b6856632c 100644 --- a/docs/security-spec/00-system-overview/architecture.md +++ b/docs/security-spec/00-system-overview/architecture.md @@ -2,7 +2,7 @@ ## What This Does -Vortex is a cross-border payment gateway built on the Pendulum blockchain. It converts between fiat currencies (BRL, EUR, ARS) and crypto assets across multiple chains (Pendulum, Moonbeam, Stellar, AssetHub, Hydration, Polygon, Base). The system is a Bun monorepo with four main components: +Vortex is a cross-border payment gateway built on the Pendulum blockchain. It converts between fiat currencies (BRL, EUR, ARS) and crypto assets across multiple chains (Pendulum, Moonbeam, AssetHub, Hydration, Polygon, Base). The system is a Bun monorepo with four main components: - **API** (`apps/api`) — Express backend handling ramp orchestration, quote generation, auth, and external service integration - **Frontend** (`apps/frontend`) — React SPA for end-user flows @@ -27,7 +27,7 @@ Vortex is a cross-border payment gateway built on the Pendulum blockchain. It co │ │ ├─ Auth middleware (Supabase/API key/Admin)│ │ │ │ ├─ Controllers + Validators │ │ │ │ ├─ Phase Processor (state machine) │ │ -│ │ └─ Services (ramp, quote, stellar, etc.) │ │ +│ │ └─ Services (ramp, quote, etc.) │ │ │ └────┬───────────┬───────────┬───────────┬────┘ │ │ │ │ │ │ │ ├───────┼───────────┼───────────┼───────────┼─────────────────────────┤ @@ -38,9 +38,8 @@ Vortex is a cross-border payment gateway built on the Pendulum blockchain. It co │ │(DB) │ │(Auth) │ │(RPC) │ │(BRLA/Avenia, │ │ │ └─────────┘ └─────────┘ │Pendulum │ │ Mykobo, │ │ │ │Moonbeam │ │ Alfredpay, │ │ -│ │Stellar │ │ Squid, Stellar) │ │ -│ │AssetHub │ └─────────────────┘ │ -│ │Hydration │ │ +│ │AssetHub │ │ Squid) │ │ +│ │Hydration │ └─────────────────┘ │ │ │Polygon │ │ │ │Base │ │ │ └──────────┘ │ @@ -59,8 +58,8 @@ Vortex is a cross-border payment gateway built on the Pendulum blockchain. It co ## Security Invariants -1. **All client-facing endpoints MUST enforce authentication** — either Supabase OTP, API key (sk\_), or admin token, depending on the route. No ramp or quote mutation endpoint may be accessible without auth. -2. **Trust boundaries MUST be enforced at the middleware layer** — auth checks happen before controller logic, never inside controllers. +1. **Every client-facing endpoint MUST declare its accepted principals** — protected routes require Supabase OTP, API key (`sk_`), or an admin token as appropriate. Quote creation and other explicitly catalogued public-information routes may be anonymous. Anonymous quote IDs are short-lived bearer references until atomically claimed. +2. **Authentication and resource authorization are separate boundaries** — middleware authenticates the presented principal and rejects invalid or indeterminate credentials before controller logic. Controllers/services MUST additionally enforce ownership and authority after loading the referenced quote, ramp, webhook, recipient, or other resource. 3. **The API server MUST NOT hold user private keys** — ephemeral keys are generated client-side (SDK/frontend). The server only receives addresses, never secrets. 4. **Server-held secrets (funding keys, executor keys) MUST only be used for platform operations** — funding ephemeral accounts, executing subsidization, signing webhooks. Never for user-initiated transactions on behalf of the user's own assets. 5. **All external service calls (BRLA, Mykobo, Alfredpay, chain RPCs) MUST be treated as untrusted** — responses must be validated, timeouts enforced, and failures handled without corrupting ramp state. @@ -83,12 +82,12 @@ Vortex is a cross-border payment gateway built on the Pendulum blockchain. It co ## Audit Checklist - [x] Every route in `apps/api/src/api/routes/v1/` has appropriate auth middleware applied — **PASS: F-013 resolved. Legacy fundEphemeral/execute-xcm/subsidize endpoints removed. `/v1/ramp/*` and `/v1/ramp/quotes(/best)` enforce `requirePartnerOrUserAuth()` with per-principal ownership guards. `/v1/brla/*`, `/v1/mykobo/profiles` (F-068 resolved), `/v1/maintenance/*`, `/v1/webhook/*` use `requireAuth`/`adminAuth`/`apiKeyAuth` respectively.** -- [FAIL] No controller directly accesses `process.env` for secrets — all go through `config/vars.ts` — **F-016: `PENDULUM_FUNDING_SEED` accessed directly in `pendulum.service.ts`; also `SLACK_WEB_HOOK_TOKEN`, `COINGECKO_API_KEY`** +- [ ] No controller directly accesses `process.env` for secrets — all go through `config/vars.ts` — **F-016: `PENDULUM_FUNDING_SEED` accessed directly in `pendulum.service.ts`; also `SLACK_WEB_HOOK_TOKEN`, `COINGECKO_API_KEY`** - [x] Ephemeral key secrets never appear in API request/response payloads or logs - [x] Phase processor always reads fresh state from DB before executing a phase (no stale cache) -- [FAIL] All external API calls have timeout configuration — **F-014: Most `fetch()` calls lack timeout/AbortController (Mykobo, price feeds, Subscan, etc.)** -- [PARTIAL] Error responses never leak internal state, stack traces, or secret material — **F-015: Stack traces stripped in prod, but raw `err.message` leaks in some paths** -- [N/A] Database connection uses TLS in production — **F-017: Not configured in Sequelize options; relies on server-side enforcement** +- [ ] All external API calls have timeout configuration — **F-014: Most `fetch()` calls lack timeout/AbortController (Mykobo, price feeds, Subscan, etc.)** +- [ ] Error responses never leak internal state, stack traces, or secret material — **F-015: Stack traces stripped in prod, but raw `err.message` leaks in some paths** +- [ ] Database connection uses TLS in production — **F-017: Not configured in Sequelize options; relies on server-side enforcement** - [x] Rate limiting is applied at the network edge before auth middleware - [x] CORS configuration restricts origins to known frontend domains (staging origin tracked as F-008) - [x] Rebalancer keys are distinct from API server keys diff --git a/docs/security-spec/01-auth/admin-auth.md b/docs/security-spec/01-auth/admin-auth.md index ec30bfc49..3f6db8813 100644 --- a/docs/security-spec/01-auth/admin-auth.md +++ b/docs/security-spec/01-auth/admin-auth.md @@ -12,6 +12,12 @@ The flow: This is the simplest auth mechanism in the system — a single static secret with no user identity, session management, or key rotation built in. +This identity-less design is an explicitly accepted risk for the current architecture +([risk register](../RISK-REGISTER.md), RISK-002). +It does not provide per-operator attribution, selective revocation, MFA, role separation, +or non-repudiation. Administrative changes remain attributable only to possession of +the shared credential; individual admin identities are out of scope for this change. + ## Security Invariants 1. **Token comparison MUST use constant-time comparison** — The `safeCompare()` function XORs character codes and accumulates the result, preventing timing attacks that could leak the secret byte-by-byte. @@ -27,9 +33,10 @@ This is the simplest auth mechanism in the system — a single static secret wit | Threat | Attack Scenario | Mitigation | |---|---|---| | **Timing attack on secret comparison** | Attacker sends varying tokens, measures response time to deduce correct secret | `safeCompare()` XORs all characters regardless of mismatch position; constant-time for equal-length strings | -| **Timing leak on length** | `safeCompare()` returns `false` immediately when lengths differ, leaking the secret length | **Known weakness in current implementation** — `safeCompare` short-circuits on length mismatch. Should use `crypto.timingSafeEqual` which pads or rejects without leaking length. | +| **Timing leak on length mismatch** | A naive comparison returns immediately when lengths differ | `safeCompare` performs a dummy `timingSafeEqual` operation before rejecting a different-length token; equal-length values use `crypto.timingSafeEqual`. | | **ADMIN_SECRET in logs** | Secret accidentally logged via request logging middleware | Auth header should be excluded from request logging; verify no middleware logs full headers | | **Shared secret rotation** | Need to rotate ADMIN_SECRET without downtime | Currently no dual-secret or graceful rotation — changing the env var immediately invalidates all admin sessions | +| **No individual administrative principal** | A privileged change cannot be attributed to, selectively revoked from, or constrained to one operator | **ACCEPTED RISK.** Retain the shared `ADMIN_SECRET` model for now; protect and rotate it operationally. Individual identities and role separation require a later architectural change. | | **Brute force** | Attacker iterates possible ADMIN_SECRET values | Rate limiting on admin endpoints; sufficiently long secret (recommended: 64+ chars) | | **Unauthorized admin endpoint discovery** | Attacker probes for admin routes | Admin routes should not be documented in public API docs; return 401 for unrecognized routes (not 404) | @@ -37,11 +44,11 @@ This is the simplest auth mechanism in the system — a single static secret wit - [x] `adminAuth` middleware is applied to every admin-only endpoint — **PASS** - [x] `safeCompare()` is the only comparison used for the admin secret — no `===` or `==` anywhere — **PASS** -- [x] **FINDING**: `safeCompare()` leaks secret length via early return on `a.length !== b.length` — verify this is acceptable or replace with `crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))` (which requires equal-length buffers but avoids the length-dependent branch) — **EXISTING F-010** -- [x] `config.adminSecret` is validated at startup — empty string defaults should be caught — **PARTIAL: Runtime check returns 500, but no startup validation** +- [x] `safeCompare()` uses `crypto.timingSafeEqual` for equal-length values and performs a dummy constant-time comparison before rejecting a different length. **PASS** +- [x] `config.adminSecret` is validated at production startup, and the middleware also fails closed at runtime if absent. **PASS** - [x] No admin endpoint also accepts Supabase auth or API key auth as a fallback (admin is the only auth layer) — **PASS** - [x] Admin endpoints are not reachable from the public frontend (verify CORS, route prefix separation) — **PASS (CORS allows all origins to all routes, but auth middleware protects)** - [ ] `ADMIN_SECRET` is at least 32 characters in production — **N/A: Deployment config, not verifiable from code** - [x] No logging middleware captures the full `Authorization` header for admin requests — **PASS** - [x] Error response for invalid admin token does not include the expected token or any hint about the secret — **PASS** -- [x] Admin auth errors are logged server-side with request metadata (IP, path) for audit trail — **FAIL: Only exceptions logged, not intentional rejections (F-020)** +- [x] Missing and invalid admin-auth attempts are logged with request IP/path; secret values are not logged. **PASS** diff --git a/docs/security-spec/01-auth/api-keys.md b/docs/security-spec/01-auth/api-keys.md index 203ac22eb..99b10bb19 100644 --- a/docs/security-spec/01-auth/api-keys.md +++ b/docs/security-spec/01-auth/api-keys.md @@ -1,96 +1,123 @@ -# API Key Authentication +# API Credential Authentication ## What This Does -The API key system provides authentication for partner integrations (SDK users, third-party platforms). It uses a dual-key architecture: +Vortex represents each public/secret key pair as one `api_credentials` row with one subject, environment, expiry, and revocation lifecycle. -- **Public keys (`pk_live_*`, `pk_test_*`)** — Included in client-side code (SDK, frontend). Used for tracking which partner initiated a request. Stored in plaintext in the database. Validated via direct DB lookup. -- **Secret keys (`sk_live_*`, `sk_test_*`)** — Server-side only. Used for authenticated operations (creating ramps, managing partner resources). Stored as bcrypt hashes in the database. Validated via prefix lookup + bcrypt comparison. +- **Public key (`pk_live_*`, `pk_test_*`)**: browser-safe identification for quote attribution and explicitly approved low-sensitivity reads. Stored in plaintext. +- **Secret key (`sk_live_*`, `sk_test_*`)**: server-side authentication for sensitive or state-changing operations. Stored only as a SHA-256 digest plus a 16-character lookup prefix and compared in constant time. -Key format: `{pk|sk}_{live|test}_{32 alphanumeric characters}` (generated from 32 bytes of `crypto.randomBytes`). +Both values use `{pk|sk}_{live|test}_{32 alphanumeric characters}`. Validation of either value produces the same context, differing only in strength: -Three middleware components: -- **`apiKeyAuth(options)`** — Factory that returns middleware. Reads `X-API-Key` header. Validates secret keys (sk\_). Optionally validates partner match. -- **`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. +```ts +interface CredentialContext { + credentialId: string; + environment: "live" | "test"; + profileId: string; + partnerId: string | null; + strength: "public" | "secret"; +} +``` -### Optional user binding (`api_keys.user_id`) +Every credential has a non-null `profile_id`. A null `partner_id` is profile-managed; a non-null `partner_id` is partner-managed and partner-attributed while still acting for exactly one profile. Runtime authorization never reads the legacy `api_keys` table and never infers ownership or pairing from a display name. -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. +### Capability Matrix -### Partner binding (`api_keys.partner_id`) and user-scoped keys +| Operation | Public key | Secret key | Supabase session | +|---|---:|---:|---:| +| Create quote and apply attribution | Yes | Yes | Yes | +| Create widget session | Yes | Yes | Yes | +| Read sanitized `GET /v1/ramp-info` | Yes | Yes | Yes | +| Read exact used or remaining financial limits | No | Yes | Yes | +| Register, update, start, or read a ramp | No | Yes | Yes | +| Read ramp history or diagnostic error logs | No | Yes | Yes | +| Manage fiat/provider accounts | No | Yes | Yes | +| Manage webhooks | No | Yes | No | +| Create, list, or revoke profile-managed credentials | No | No | Yes | +| Create, list, or revoke partner-managed credentials | No | No | Admin | -Partner attribution resolves through the `api_keys.partner_id` FK (migration `041-add-partner-id-to-api-keys`, backfilled from the legacy `partner_name` string against the now-unique `partners.name`). `partner_name` remains in the table as a backup column that also marks a key's origin: a key with `partner_name` set but `partner_id = NULL` is an **orphaned partner key** (its partner row was deleted — the FK is `ON DELETE SET NULL`) and is rejected outright, never degraded into a user-scoped key. A key with *both* partner columns 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_id = NULL`. The admin endpoints under `/v1/admin/partners/:partnerName/api-keys` resolve the path's partner name to the unique `partners` row and bind keys via `partner_id`. Revocation is `is_active = false` plus a `revoked_at` timestamp; `scopes` (JSONB) is reserved and unused. +Possession of a public key never authorizes exact financial usage, provider identifiers, ramp history, diagnostics, or mutations. A corresponding secret key is stronger proof and may be accepted on public-key-capable routes. -### Self-serve API key endpoints +### Credential Management -`/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). +`POST`, `GET`, and `DELETE /v1/api-credentials` require a Supabase Bearer session and are owner-scoped to profile-managed credentials. Creation generates both values in one transaction, returns the secret once, defaults to one-year expiry, and rejects expiry beyond two years. Listing returns one object per credential and never returns the secret value. + +A profile may have at most five non-revoked, non-expired credentials. Creation locks the profile row and performs the active count and insert in one transaction, preventing concurrent requests from exceeding the cap. `DELETE /v1/api-credentials/:credentialId` updates the one row's `revoked_at`, atomically disabling both values without a request body or second key ID. + +Admin partner credential operations use the same lifecycle service and require an explicit existing `profile_id` subject. `POST /v1/admin/managed-profiles` provisions a genuine Supabase identity and Vortex profile from explicit `partnerId`, `externalUserId`, email, and `individual`, `business`, or `technical` subject type. The `(partner_id, external_user_id)` and `profile_id` associations are unique; an existing email is reconciled only when its immutable Supabase metadata matches the same association. Individual/business subjects receive the matching customer entity. OTP verification marks the identity claimed without duplicating it. Technical subjects receive no customer entity and are explicitly rejected from customer/ramp operations. + +### Public And Secret Consistency + +When both `X-Public-Key` and `X-API-Key` are supplied, both values are resolved and their `credentialId` values must match. A mismatch returns `403 CREDENTIAL_MISMATCH`; the server must not combine the public value's attribution with the secret value's subject. A quote-body/query `apiKey` and `X-Public-Key` that differ also return `403 CREDENTIAL_MISMATCH`. With a matching pair, the secret context is authoritative. + +### Sanitized Ramp Info + +`GET /v1/ramp-info` accepts `X-Public-Key`, the corresponding `X-API-Key`, or a Supabase session. It derives the profile only from `CredentialContext.profileId` or the session and must not accept `userId`, `profileId`, email, tax ID, or customer-entity selectors. + +Its response is an allowlisted per-corridor projection: + +```json +{ + "corridors": { + "BR": { + "kycStatus": "approved", + "canBuy": true, + "canSell": true + } + } +} +``` + +`kycStatus` is one of `not_started`, `pending`, `approved`, or `rejected`. The response must not include names, email, tax identifiers, KYC failure reasons, provider/customer/subaccount IDs, customer-entity IDs, wallet or bank details, ramp history, transaction data, or exact financial limits/usage. ## 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"]`. -2. **Secret keys MUST be stored as bcrypt hashes** — The raw secret key is never persisted. Only the `keyPrefix` (first 8 chars) and `keyHash` (bcrypt) are stored. -3. **Public keys MUST NOT grant authentication** — The `validateApiKey()` function returns `null` for public keys, explicitly denying authentication. Public keys are for tracking/identification only. -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 resolution MUST go through the `partner_id` FK** — `validateSecretApiKey` resolves `api_keys.partner_id` to the (unique-name) `partners` row; the legacy `partner_name` column is never read for authorization. `validatePartnerMatch`/`enforcePartnerAuth` may still compare by name — with `partners.name` unique, name equality and id equality are equivalent — and both UUID and string name formats for `partnerId` remain supported in request bodies. -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 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. **Only a key with no partner binding at all is user-scoped** — `validateSecretApiKey` treats a key as user-scoped only when *both* `partner_id` and `partner_name` are NULL and `user_id` is set, returning `{ partner: null, apiKeyUserId }`; the middleware leaves `req.authenticatedPartner` unset, so the request authenticates purely as the linked user. A key with `partner_name` set but `partner_id = NULL` (partner row deleted) is rejected — partner deletion is key revocation, in both `validateSecretApiKey` and `validatePublicApiKey` (the public path also rejects a dangling `partner_id` whose partner row is missing). A secret key with neither partner binding 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's pricing config (`partner_pricing_configs`, per ramp direction). 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_id`) 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. +1. **One credential MUST be one row**: `api_credentials` contains exactly one public value and one secret representation with one profile, optional partner, environment, expiry, and revocation timestamp. +2. **Every credential MUST have a real profile subject**: `profile_id` is non-null and foreign-keyed to `profiles`; ownerless credentials cannot be created or migrated. +3. **Secret keys MUST use only `X-API-Key`**: secret values are never accepted in request bodies, query parameters, or URLs. +4. **Public keys MUST use `X-Public-Key` for new APIs**: the legacy quote/session `apiKey` field is attribution-only compatibility input and must agree with the header when both are present. +5. **Secret material MUST NOT be persisted**: only a SHA-256 digest and indexed 16-character lookup prefix are stored; comparison uses `crypto.timingSafeEqual`. +6. **Format validation MUST precede lookup**: malformed or wrong-type keys are rejected before querying credentials. +7. **Revoked and expired credentials MUST fail both halves**: usability requires `revoked_at IS NULL AND expires_at > NOW()`. +8. **Validation MUST return `CredentialContext`**: business code receives credential ID, environment, profile ID, partner ID, and strength rather than interpreting key-row null combinations. +9. **Public capability MUST remain allowlisted**: public possession grants only quote/widget attribution and sanitized `ramp-info`; sensitive reads and all ramp/provider/webhook mutations require secret or session capability as listed above. +10. **Two presented halves MUST match**: different credential IDs return `403 CREDENTIAL_MISMATCH`; no mixed context may continue downstream. +11. **Partner resolution MUST use immutable IDs**: `partner_id` is authoritative. Partner display names are labels and route lookup inputs, never credential-pairing, migration, or authorization evidence. +12. **Profile-managed lifecycle MUST require a session**: `/v1/api-credentials` binds create/list/revoke operations to `req.userId` and `partner_id IS NULL`. +13. **Creation MUST enforce five active credentials atomically**: expired and revoked rows do not count; profile locking serializes concurrent creation. +14. **Revocation MUST disable both values atomically**: one owner-scoped update sets `revoked_at` on the credential row. +15. **Usage timestamps MUST be independent and best-effort**: public and secret validation update their respective last-used timestamps without making auth success depend on the telemetry write. +16. **Ramp registration MUST resolve a real profile**: secret credentials and sessions act only for their bound profile; public keys cannot register ramps or select a profile. +17. **Managed partner subjects MUST be first-class identities**: each real individual, business, or technical subject gets a genuine unique profile and immutable partner/external-user association; individual/business subjects get the matching customer entity, while technical subjects get none and cannot perform customer or ramp operations. No shared dummy profile is allowed. +18. **There MUST be no legacy request-path fallback**: runtime validation reads only `api_credentials`; it does not read `api_keys`, bcrypt hashes, old prefixes, unpaired halves, or name-based relationships. +19. **Startup MUST fail closed**: after migrations and before listening, the API verifies required `api_credentials` columns, nullability, indexes, constraints, and zero active `api_keys` rows. Any failure prevents serving traffic. +20. **`ramp-info` MUST be subject-derived and sanitized**: it accepts no user selector and returns only the documented KYC state and buy/sell booleans. ## Threat Vectors & Mitigations -| Threat | Attack Scenario | Mitigation | -|---|---|---| -| **Secret key exposure in client code** | Partner accidentally ships sk\_ key in frontend bundle | Middleware rejects pk\_ keys for authentication; documentation emphasizes server-only usage for sk\_ keys | -| **Brute force secret key** | Attacker iterates over possible sk\_ values | 32 chars of alphanumeric = ~190 bits entropy; bcrypt cost factor 10 for comparison; rate limiting on API | -| **Timing attack on key validation** | Attacker measures response time to distinguish "key not found" from "bcrypt mismatch" | Prefix lookup returns all matching keys → bcrypt runs for each → timing varies by key count, not by correctness | -| **Partner impersonation** | Attacker uses one partner's API key with another partner's `partnerId` | `enforcePartnerAuth` compares the authenticated partner (resolved via `api_keys.partner_id`) against the requested partner; with unique partner names, name and id comparison are equivalent; 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. | +| Threat | Mitigation | +|---|---| +| Secret exposed in browser or telemetry | Public capability exists for browser use; secret values are server-only, returned once, and forbidden from logs/events. | +| Database read leaks usable secret | Only a high-entropy secret's SHA-256 digest and non-secret lookup prefix are stored. | +| Public key escalates to financial access | Route-level capability matrix rejects public keys from sensitive reads and mutations. | +| Public key from one credential is combined with another secret | Resolve both and return `403 CREDENTIAL_MISMATCH` before business logic. | +| Concurrent creation exceeds the cap | Lock the profile, count active non-expired credentials, and insert in one transaction. | +| Revocation leaves one half active | One row and one `revoked_at` update disable both values. | +| Legacy or ambiguous rows remain reachable | No legacy runtime lookup; startup refuses active legacy rows. Production migration uses explicit immutable-ID mappings, never names. | +| Shared managed identity crosses customer ownership | Require one genuine managed profile per subject and immutable partner/external-user association. | +| Public eligibility read leaks PII or exact limits | `ramp-info` uses an explicit projection and accepts no subject selector. | ## Audit Checklist -- [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** -- [x] Regex patterns in `isValidApiKeyFormat` and `isValidSecretKeyFormat` match the documented format exactly: `^(pk|sk)_(live|test)_[a-zA-Z0-9]{32}$` — **PASS** -- [x] `generateApiKey()` uses `crypto.randomBytes(32)` — not `Math.random()` or other weak sources — **PASS** -- [x] `hashApiKey()` uses bcrypt with salt rounds ≥ 10 — **PASS (saltRounds = 10)** -- [x] Expiration check (`expiresAt`) uses `new Date() > keyRecord.expiresAt`, correctly handling `null` expiresAt (no expiration) — **PASS** -- [x] `enforcePartnerAuth` returns 403 (not 401) when partnerId is present but no auth provided — **PASS (active on `POST /v1/ramp/quotes` and `POST /v1/ramp/quotes/best`)** -- [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`) and is a legacy backup column — authorization never reads it. — **PASS** -- [x] `api_keys.partner_id` FK (migration `041-add-partner-id-to-api-keys`, backfilled from `partner_name`) is the sole partner-resolution path in `validateSecretApiKey`/`validatePublicApiKey`; user-scoped keys have `partner_id = NULL` and authenticate purely as `user_id`. — **PASS** -- [x] Revocation stamps `revoked_at` alongside `is_active = false` (self-serve and admin revoke paths). — **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** +- [x] `api_credentials` stores one public value and one secret digest/prefix with non-null `profile_id`. +- [x] Public and secret validators return the documented `CredentialContext` and reject revoked/expired rows. +- [x] Secret digest comparison is constant-time and lookup is bounded by the indexed 16-character prefix. +- [x] Creation locks the profile and caps active non-expired credentials at five. +- [x] Self-service and admin adapters call the same create/list/revoke service. +- [x] Revocation performs one owner-scoped credential update and takes no paired-key body. +- [x] Public/body/header and public/secret mismatches return `403 CREDENTIAL_MISMATCH`. +- [x] Startup validates the credential schema and refuses any active legacy `api_keys` row. +- [ ] Verify deployment data has zero active legacy, unpaired, or ownerless credentials before cutover; source code cannot prove production data state. +- [x] Managed-profile provisioning is admin-authenticated, idempotent by immutable partner/external-user IDs, unique by profile, rejects conflicting email/association reuse, creates the correct individual/business entity, leaves technical subjects entity-less, and records claims after verified OTP. +- [ ] Verify backend `GET /v1/ramp-info` enforces the allowlisted response and negative PII/cross-user tests; the shared/SDK contract exists but the API route is not represented in the current implementation. +- [ ] Verify every capability-matrix row has an HTTP integration test; current middleware and SDK tests cover the core key validation and mismatch behavior, not every row. diff --git a/docs/security-spec/01-auth/supabase-otp.md b/docs/security-spec/01-auth/supabase-otp.md index 118686813..d47058b8b 100644 --- a/docs/security-spec/01-auth/supabase-otp.md +++ b/docs/security-spec/01-auth/supabase-otp.md @@ -14,16 +14,16 @@ The flow: Two middleware variants exist: - **`requireAuth`** — Returns 401 if token is missing or invalid. Used on protected endpoints. -- **`optionalAuth`** — Attaches `userId` if token is present and valid, but continues without auth if absent. Used on endpoints that behave differently for authenticated users. +- **`optionalAuth`** — Attaches `userId` if a token is present and valid, continues anonymously only when the header is absent, returns `401` for a present invalid credential, and returns `503` when verification is indeterminate. ## Security Invariants -1. **JWT verification MUST use Supabase's server-side verification** — The API MUST call `SupabaseAuthService.verifyToken()` which uses the `SUPABASE_SERVICE_KEY` (service role) to validate tokens. Client-side verification with the anon key is insufficient. +1. **JWT verification MUST use authoritative Supabase Auth validation** — The API MUST call `SupabaseAuthService.verifyToken()` over a server-controlled channel. The configured Supabase project URL and anon key identify the trusted Auth project; the presented bearer token is authoritatively introspected by Supabase Auth. Service-role credentials are required only for operations that need service-role privileges and MUST NOT be a prerequisite merely to verify an access token. 2. **Token extraction MUST require the `Bearer` prefix** — The middleware MUST reject tokens that don't start with `Bearer ` (note trailing space). Raw tokens in the header MUST be rejected. 3. **`userId` MUST only be set by auth middleware** — No controller or service may set `req.userId` directly. It MUST originate exclusively from the middleware's JWT verification result. -4. **`optionalAuth` MUST NOT fail the request on invalid tokens** — If a token is present but invalid/expired, `optionalAuth` logs a warning and continues with `userId` undefined. It MUST NOT return 401. -5. **`requireAuth` MUST fail closed** — Any error during token verification (network error to Supabase, malformed token, expired token) MUST result in a 401 response. Never proceed without valid auth. -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). +4. **Optional authentication MUST NOT downgrade a presented credential** — No authorization header on an anonymous-eligible route continues anonymously. A present malformed, invalid, expired, or revoked credential returns `401`; it MUST NOT be converted into an anonymous request. +5. **Verification outcomes MUST remain distinct** — Missing credentials on protected routes and definitively invalid credentials return `401`; a valid identity without authority returns `403`; a provider/network failure that makes verification indeterminate returns `503`. Neither middleware may proceed anonymously after an indeterminate result. +6. **Auth errors MUST NOT leak token content** — Error responses use generic messages. Logs contain request ID, path, and an error category/message, but no full or truncated bearer-token fragment. 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. @@ -33,8 +33,8 @@ Two middleware variants exist: | Threat | Attack Scenario | Mitigation | |---|---|---| | **Stolen JWT** | Attacker intercepts a user's JWT (XSS, network sniffing) and replays it | Configured token expiry (1 week); TLS enforcement; HttpOnly cookies if applicable | -| **Supabase service key leak** | Attacker obtains `SUPABASE_SERVICE_KEY` and forges arbitrary JWTs | Key stored only in env vars; never exposed in responses or logs; rotation procedure in place | -| **Supabase outage** | Supabase is unreachable — verification calls fail | `requireAuth` fails closed (returns 401); no fallback to unverified access | +| **Supabase service key leak** | Attacker obtains `SUPABASE_SERVICE_KEY` and gains broad administrative privileges | Access-token verification uses the least-privileged Auth client; service-role use is limited to administrative operations. The key remains server-only and independently rotatable. | +| **Supabase outage** | Supabase is unreachable — verification calls fail | Both middleware variants fail closed with `503`; no fallback to anonymous or unverified access and no false invalid-token signal. | | **Email enumeration** | Attacker probes OTP endpoint to discover registered emails | OTP flow handled by Supabase — Vortex API never sees OTP requests; Supabase rate limits apply | | **Token reuse after logout** | User "logs out" in frontend but JWT is still valid server-side | Supabase token invalidation on signout; short expiry window limits exposure | | **userId injection** | Attacker sends crafted request with `userId` in body/headers to bypass auth | `req.userId` is set exclusively by middleware; controllers read from `req.userId` not from request body | @@ -43,13 +43,14 @@ Two middleware variants exist: - [x] `requireAuth` is applied to all endpoints that mutate ramp state, access user data, or perform privileged operations — **PASS: F-013 resolved. `/v1/ramp/*` endpoints now use `requirePartnerOrUserAuth()` (sk_ partner key OR Supabase Bearer) with ownership guards; `/v1/brla/*` uses `requireAuth`; `/v1/mykobo/profiles` (GET + POST) uses `requireAuth` (F-068 resolved); admin and webhook routes use `adminAuth`/`apiKeyAuth`.** - [x] `optionalAuth` is only used on endpoints where unauthenticated access is intentionally allowed (e.g., public quote lookup) — **PASS** -- [x] `SupabaseAuthService.verifyToken()` uses the service role key, not the anon key — **FAIL: Uses anon-key client (F-018). Functionally correct but deviates from spec.** +- [x] `SupabaseAuthService.verifyToken()` uses authoritative Supabase Auth validation without requiring service-role privilege — **PASS** - [x] The `Bearer ` prefix check uses `startsWith("Bearer ")` with the trailing space (not just `"Bearer"`) — **PASS** - [x] `req.userId` is never set by any code path other than the two auth middlewares — **PASS** - [x] Error responses from auth middleware contain no token fragments, user details, or internal error messages — **PASS** -- [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] Authentication logs contain no bearer-token fragments — **PASS** +- [x] A present invalid optional credential returns `401`, while an indeterminate provider failure returns `503` without anonymous fallback — **PASS** +- [x] `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_KEY` are validated at production startup — empty strings are treated as missing. **PASS** - [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** +- [x] Optional auth is limited to anonymous quote discovery and non-mutating BRLA preflight endpoints; protected KYC/resource mutations require authentication, and an indeterminate presented credential never falls back to anonymous. **PASS** diff --git a/docs/security-spec/02-signing-keys/ephemeral-accounts.md b/docs/security-spec/02-signing-keys/ephemeral-accounts.md index a632483be..0fc670f0d 100644 --- a/docs/security-spec/02-signing-keys/ephemeral-accounts.md +++ b/docs/security-spec/02-signing-keys/ephemeral-accounts.md @@ -2,9 +2,8 @@ ## What This Does -Ephemeral accounts are temporary blockchain accounts created per ramp operation. They serve as intermediate holding addresses for assets during the multi-step ramp process. Each ramp creates up to three ephemeral accounts across different chains: +Ephemeral accounts are temporary blockchain accounts created per ramp operation. They serve as intermediate holding addresses for assets during the multi-step ramp process. Each ramp creates up to two ephemeral accounts across different chain families: -- **Stellar ephemeral** — Created via `createStellarEphemeral()`. A new Stellar keypair. The API's funding account creates this on-chain with a 2-of-2 multisig (ephemeral + funding account as co-signers), adds a trustline for the relevant Stellar asset, and funds it with a starting balance. - **Substrate (Pendulum) ephemeral** — Created via `createPendulumEphemeral()`. A new sr25519 keypair for the Pendulum parachain. - **EVM (Moonbeam) ephemeral** — Created via `createMoonbeamEphemeral()`. A new secp256k1 keypair for Moonbeam/EVM chains. @@ -12,57 +11,54 @@ Ephemeral accounts are temporary blockchain accounts created per ramp operation. The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{rampId}.json`) via the `storeEphemeralKeys` config option (defaults to `true`). -The frontend and dashboard store a backup of all ephemeral keypairs in `localStorage["rampEphemerals"]` as a `Record`, keyed by ramp ID. The widget persists this through `PersistenceEffect` in `apps/frontend/src/contexts/rampState.tsx`. The dashboard writes a pending quote-keyed entry before registration, then rebinds it to the returned ramp ID. This archive is **not** cleared when ramp context, authentication, or other UI state resets. The purpose is a user-side failsafe: if a ramp fails mid-flow and the main state is wiped, the ephemeral secret keys remain recoverable from this separate localStorage entry. The dashboard separately persists its serializable transfer-machine snapshot under `vortex-dashboard-transfer-state` so an onramp's server-issued payment instructions survive reload; reset/logout clears this snapshot but not the independent ephemeral archive. +The frontend and dashboard store a backup of all ephemeral keypairs in separate same-origin localStorage maps, keyed by ramp ID (`rampEphemerals` for the widget and `vortex_dashboard_rampEphemerals` for the dashboard). The widget persists this through `PersistenceEffect` in `apps/frontend/src/contexts/rampState.tsx`. The dashboard writes a pending quote-keyed entry before registration, then rebinds it to the returned ramp ID. This archive is **not** cleared when ramp context, authentication, or other UI state resets. The purpose is a user-side failsafe: if a ramp fails mid-flow and the main state is wiped, the ephemeral secret keys remain recoverable from this separate localStorage entry. Once a client observes a terminal state it records `terminalObservedAt`; storage maintenance removes that entry on the first access at least 90 days later. Entries without a terminal observation are retained indefinitely (accepted as [RISK-006](../RISK-REGISTER.md)). The dashboard separately persists its serializable transfer-machine snapshot under `vortex-dashboard-transfer-state` so an onramp's server-issued payment instructions survive reload; reset/logout clears this snapshot but not the independent ephemeral archive. Frontend and SDK Substrate RPC clients are initialized lazily. Creating/importing frontend API services, mounting the Polkadot node provider, constructing the SDK, or entering ramp registration MUST NOT open Pendulum, Moonbeam, Hydration, or AssetHub websocket connections by default. A Substrate RPC may be opened only when the active UI component explicitly asks for that node, or after ramp registration returns unsigned transactions that actually require Substrate-format signing on that network. For example, an EUR→Base EURC Mykobo on-ramp should not initialize Moonbeam solely because the frontend imports `services/api` or because the generic register actor runs. ## Security Invariants 1. **Ephemeral private keys MUST be generated client-side** — The API MUST never generate, receive, store, or have access to ephemeral private keys. Only addresses (`accountMetas`) are sent to the API. -2. **Stellar ephemeral accounts MUST use 2-of-2 multisig** — The funding account is added as a co-signer with weight 1, and all thresholds (low, medium, high) are set to 2. This ensures both the client (ephemeral key holder) and the server (funding key holder) must co-sign any transaction. -3. **Ephemeral accounts MUST be used for a single ramp only** — Each ramp gets fresh accounts. Reusing ephemerals across ramps creates cross-contamination risk. -4. **The API MUST validate that submitted addresses are well-formed** — Before using an ephemeral address in transactions, the API must validate the address format for the respective chain (Stellar public key format, Substrate SS58, EVM hex). -5. **Ephemeral key storage (SDK) MUST be local-only** — The `storeEphemeralKeys` function writes to the local filesystem. Keys MUST NOT be transmitted to the API, logged, or stored in any remote database. -6. **Stellar ephemeral funding MUST use a bounded starting balance** — The `STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS` constant defines the XLM sent to new ephemerals. This should be the minimum needed for operations (trustlines + transaction fees), not more. -7. **The API MUST NOT assume the ephemeral address belongs to an honest user** — An attacker could register a ramp with an address they don't control or an address that's a contract (on EVM). Phase handlers must account for this. -8. **Pre-signed transactions MUST be bound to the specific ephemeral address** — Transactions generated by the API for client signing must include the ephemeral address as the source/signer, not a wildcard. -9. **Ephemeral addresses MUST be proven fresh on every chain the platform supports, at ramp registration time** — Before building any transactions, the API MUST verify on-chain that each submitted ephemeral address has zero nonce / zero balance / does not exist (chain-appropriate definition) on every supported chain of its type — not only the chains the specific ramp route will use. Checking the full supported set (rather than a route-derived subset) prevents a future phase-handler addition from silently reopening the freshness gap. Freshness checks MUST fail closed: any RPC error rejects the registration. Reused ephemerals cause mid-ramp halt because the server assumes a clean nonce and (for Stellar) creates the account from scratch. -10. **Frontend `rampEphemerals` localStorage backup MUST be local-only** — The `rampEphemerals` localStorage item stores ephemeral secret keys as plaintext in the browser. It MUST NOT be transmitted to any server, included in analytics, or accessible to third-party scripts. This backup exists solely as a user-side failsafe for debugging failed ramps. -11. **Client-side Substrate APIs MUST be opened only on demand** — The frontend register actor and SDK signing path MUST inspect the returned unsigned transactions before requesting Pendulum/Moonbeam/Hydration APIs. EVM-format transactions on Moonbeam-compatible chains must use EVM signing clients and MUST NOT force `ApiPromise` initialization. Legacy frontend service singletons MUST NOT create websocket connections in constructors or module-level exports. -12. **Dashboard ramp registration MUST preserve ephemerals first** — The dashboard MUST persist newly generated EVM and Substrate secrets locally before sending their public addresses to `/ramp/register`. Once registration returns, the entry MUST be keyed by ramp ID. Existing ramp entries MUST NOT be removed when another ramp starts, finishes, fails, is left from the payment-instructions step, or when the user logs out. -13. **Dashboard BUY ramps MUST remain ephemeral-only before start** — A dashboard onramp does not require a connected wallet. Every unsigned BUY transaction returned by registration is treated as ephemeral-owned and signed only with the fresh local ephemeral keys; the client MUST pause with provider payment instructions and MUST NOT call `/ramp/start` until the user explicitly confirms that the fiat payment was submitted. -14. **An EVM ephemeral address MUST correspond to its stored private key** — `createMoonbeamEphemeral()` MUST derive the public address from the same raw private key stored in `secret` and later consumed by EVM transaction signing. Address derivation MUST NOT depend on prior Polkadot WASM crypto initialization. -15. **Active ramps MUST NOT share ephemeral addresses** — Before provider transaction preparation, ramp registration takes transaction-scoped advisory locks for the normalized submitted addresses and checks the existing `ramp_states.state` ephemeral fields. A matching ramp whose phase is not `complete`, `failed`, or `timedOut` MUST cause registration to return `409`; terminal ramps do not block reuse. +2. **Ephemeral accounts MUST be used for a single ramp only** — Each ramp gets fresh accounts. Reusing ephemerals across ramps creates cross-contamination risk. +3. **The API MUST validate that submitted addresses are well-formed** — Before using an ephemeral address in transactions, the API must validate the address format for the respective chain (Substrate SS58, EVM hex). +4. **Ephemeral key storage (SDK) MUST be local-only** — The `storeEphemeralKeys` function writes to the local filesystem. Keys MUST NOT be transmitted to the API, logged, or stored in any remote database. +5. **The API MUST NOT assume the ephemeral address belongs to an honest user** — An attacker could register a ramp with an address they don't control or an address that's a contract (on EVM). Phase handlers must account for this. +6. **Pre-signed transactions MUST be bound to the specific ephemeral address** — Transactions generated by the API for client signing must include the ephemeral address as the source/signer, not a wildcard. +7. **Ephemeral addresses MUST be proven fresh on every chain the ramp will sign on, at ramp registration time** — Before building any transactions, the API MUST verify on-chain that each submitted ephemeral address is fresh on every chain the ramp's route actually signs on. Freshness is chain-appropriate but MUST cover both nonce and balance: Substrate requires `nonce === 0 && free === 0`; EVM requires `nonce === 0 && native balance === 0` (a nonce-0 EVM account can still hold a funded native balance, so a nonce-only check is insufficient). The chain set MUST be derived from the quote (`quoteToSigningNetworks`), not the full supported list: validating chains the route never touches makes an unrelated RPC outage able to block every registration (an availability-hostility the earlier all-chains rule created). The route-to-chains mapping MUST be kept in sync with the route builders — under-listing a chain the ephemeral signs on silently reopens the freshness gap — and is pinned by `ephemeral-freshness.test.ts`. Freshness checks MUST fail closed: any RPC error rejects the registration with `503`. Reused ephemerals cause mid-ramp halt because the server assumes a clean nonce. **Known limitation:** only the native balance is checked on EVM; a nonce-0 account pre-loaded with ERC-20 tokens is not detected (enumerating tokens per chain is out of scope). +8. **Frontend `rampEphemerals` backup is plaintext same-origin storage** — The secret keys MUST NOT be transmitted to the API, logs, or analytics. They are readable by any script executing in the origin, so their confidentiality depends on CSP, dependency integrity, and XSS prevention; the spec MUST NOT claim localStorage can exclude third-party same-origin scripts. +9. **Client-side Substrate APIs MUST be opened only on demand** — The frontend register actor and SDK signing path MUST inspect the returned unsigned transactions before requesting Pendulum/Moonbeam/Hydration APIs. EVM-format transactions on Moonbeam-compatible chains must use EVM signing clients and MUST NOT force `ApiPromise` initialization. Legacy frontend service singletons MUST NOT create websocket connections in constructors or module-level exports. +10. **Dashboard ramp registration MUST preserve ephemerals first and apply terminal retention** — The dashboard MUST persist newly generated EVM and Substrate secrets locally before sending their public addresses to `/ramp/register`. Once registration returns, the entry MUST be keyed by ramp ID. Unresolved ramps retain their entries so recovery remains possible. Entries known to be terminal (`complete`, `failed`, `timedOut`, or cancelled) MUST be deleted 90 days after the terminal state was observed. Starting another ramp or logging out MUST NOT delete unresolved entries. +11. **Dashboard BUY ramps MUST remain ephemeral-only before start** — A dashboard onramp does not require a connected wallet. Every unsigned BUY transaction returned by registration is treated as ephemeral-owned and signed only with the fresh local ephemeral keys; the client MUST pause with provider payment instructions and MUST NOT call `/ramp/start` until the user explicitly confirms that the fiat payment was submitted. +12. **An EVM ephemeral address MUST correspond to its stored private key** — `createMoonbeamEphemeral()` MUST derive the public address from the same raw private key stored in `secret` and later consumed by EVM transaction signing. +13. **Active ramps MUST NOT share ephemeral addresses** — Before provider transaction preparation, ramp registration takes transaction-scoped advisory locks for normalized submitted addresses and rejects a matching non-terminal ramp with `409`. ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| | **Ephemeral key interception** | Attacker intercepts ephemeral keys during SDK storage (file read) | Keys stored locally only; file permissions should be restrictive; recommend encryption at rest for production SDK usage | -| **Address substitution** | Attacker registers a ramp with someone else's address, hoping to receive funds at that address | Funds flow through the ephemeral (which the attacker controls the keys for), not directly to an arbitrary destination. The 2-of-2 multisig on Stellar prevents unilateral fund movement. | +| **Address substitution** | Attacker registers a ramp with someone else's address, hoping to receive funds at that address | Funds flow through the ephemeral rather than directly to an arbitrary destination, and active-address reuse is rejected. | | **Ephemeral reuse** | Buggy or malicious clients submit the same still-unused address for multiple active ramps, causing nonce and balance cross-contamination | Client generation creates fresh keypairs; registration serializes matching addresses with transaction-scoped advisory locks and rejects addresses already present on a non-terminal ramp. | -| **Funding account drain** | Attacker creates many ramps to drain the Stellar funding account's XLM balance | Rate limiting on ramp creation; monitoring funding account balance; bounded starting balance | -| **Orphaned ephemerals** | Ramp fails mid-way, leaving funded ephemeral accounts unclaimed | Stellar 2-of-2 multisig allows the funding account to reclaim funds; Substrate/EVM ephemerals can be swept by the key holder | +| **Orphaned ephemerals** | Ramp fails mid-way, leaving funded ephemeral accounts unclaimed | Substrate/EVM ephemerals can be swept by the key holder | | **Malicious ephemeral address (contract)** | On EVM, attacker provides a smart contract address as ephemeral, which could behave unexpectedly when receiving tokens | Validate that EVM ephemeral addresses are externally-owned accounts (EOAs), not contracts, before sending funds | -| **Reused / non-fresh ephemeral** | Client (buggy SDK, attacker, or replay) submits an ephemeral address that already has on-chain history — non-zero nonce on Substrate/EVM, or an existing Stellar account. The server builds transactions assuming nonce 0 / no account, so mid-ramp execution halts with nonce-mismatch or "account already exists" errors after subsidies/funding have been spent. | **MITIGATED (F-072)**: `validateEphemeralAccountsFresh()` runs in `registerRamp` immediately after format validation. For each ephemeral type the client provides, it queries every supported chain of that type (Substrate: pendulum, hydration, assethub; EVM: all configured EVM networks including Moonbeam; Stellar) and rejects the registration if any check finds non-zero nonce / non-zero free balance / pre-existing Stellar account. Fail-closed on RPC errors. | -| **XSS access to `rampEphemerals` localStorage** | An XSS vulnerability could read `localStorage["rampEphemerals"]` to steal ephemeral secret keys for in-flight ramps. Unlike `rampState` (which is wiped on reset), `rampEphemerals` persists across ramp resets, widening the attack window. | The entry is plaintext in localStorage with no additional encryption. Mitigation relies on the same XSS defenses that protect auth tokens and other localStorage secrets (CSP, input sanitization, no `eval`). The `rampEphemerals` map accumulates entries over time — users or support should clear it after debugging. `removeRampEphemeral(rampId)` is exported for targeted cleanup. | +| **Reused / non-fresh ephemeral** | Client (buggy SDK, attacker, or replay) submits an ephemeral address that already has on-chain history — non-zero nonce, or a funded native balance on a nonce-0 account. The server builds transactions assuming nonce 0 and an empty account, so mid-ramp execution halts with nonce-mismatch errors after subsidies/funding have been spent. | **MITIGATED (F-072)**: `validateEphemeralAccountsFresh(ephemerals, quote)` runs in `registerRamp` immediately after format validation. For each ephemeral type the client provides, it queries the chains the quote's route signs on (`quoteToSigningNetworks`) and rejects the registration if any check finds non-zero nonce, non-zero Substrate free balance, or non-zero EVM native balance. Fail-closed on RPC errors (`503`). | +| **Unrelated-RPC registration DoS** | An RPC for a chain the ramp never uses is down; every registration that validated the full chain set would fail | Freshness is scoped to the route's chains (`quoteToSigningNetworks`), so an outage on an unrelated chain's RPC cannot block registrations that never touch it. | +| **XSS access to `rampEphemerals` localStorage** | An XSS vulnerability or compromised same-origin dependency reads ephemeral secret keys for in-flight ramps. | Storage is explicitly plaintext. Mitigation relies on CSP, input sanitization, dependency integrity, and bounded retention: unresolved entries remain recoverable; terminal entries expire after 90 days. | | **Unneeded Substrate RPC initialization** | A route that does not use a Substrate network (for example EUR→Base EURC) imports a frontend service barrel or enters registration and accidentally opens Moonbeam/Pendulum websockets, creating timeout noise and coupling route availability to unrelated RPC health. | Frontend service wrappers defer `createApiComponents(...)` until `getApi()` is called. The register actor requests Pendulum/Moonbeam/Hydration APIs only after filtering returned `ephemeralTxs`, and only for networks whose transaction format requires Substrate signing. Normal EVM transaction signing does not request a Moonbeam `ApiPromise`. | ## Audit Checklist -- [x] `createStellarEphemeral()`, `createPendulumEphemeral()`, `createMoonbeamEphemeral()` are only called in the SDK/frontend, never in `apps/api` — ✅ PASS +- [x] `createPendulumEphemeral()` and `createMoonbeamEphemeral()` are only called in the SDK/frontend, never in `apps/api` — ✅ PASS - [x] The API's ramp registration endpoint only accepts addresses (public keys), never private keys or seed phrases — ✅ PASS -- [ ] Stellar ephemeral creation sets all thresholds to 2 and adds the funding account as a signer with weight 1 — ↗️ Deferred to Module 05 -- [x] `STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS` is set to the minimum viable amount (just enough for trustlines + fees) — ✅ PASS (2.5 XLM) - [x] `storeEphemeralKeys` writes to local filesystem only — verify no network calls in the storage path — ✅ PASS - [ ] Ephemeral addresses are validated for format before use in transaction construction — ❌ FAIL (F-021) - [x] No code path in the API logs or persists ephemeral private keys — ✅ PASS - [x] Each call to `generateEphemerals()` produces fresh, unique keypairs — no memoization or caching — ✅ PASS - [x] Unsigned transactions returned to the client are bound to the specific ephemeral addresses provided during registration — ✅ PASS - [ ] The API does not trust that an ephemeral address is an EOA on EVM — verify if contract address detection is needed — 🟡 PARTIAL (no check, but low self-harm risk) -- [x] **F-072**: Each submitted ephemeral address is verified fresh on every supported chain of its type at `registerRamp` — `validateEphemeralAccountsFresh()` in `apps/api/src/api/services/ramp/ephemeral-freshness.ts`, invoked after `normalizeAndValidateSigningAccounts`. Substrate (pendulum, hydration, assethub): `nonce === 0 && free === 0`. EVM (all configured EVM networks): `nonce === 0`. Stellar: account must not exist on Horizon. The supported-network lists `SUPPORTED_SUBSTRATE_NETWORKS` and `SUPPORTED_EVM_NETWORKS` MUST be updated whenever the platform adds a new chain an ephemeral can ever sign on. Fail-closed on RPC errors (`SERVICE_UNAVAILABLE`). — ✅ PASS +- [x] **F-072**: Each submitted ephemeral address is verified fresh at `registerRamp` on the chains its route signs on — `validateEphemeralAccountsFresh(ephemerals, quote)` in `apps/api/src/api/services/ramp/ephemeral-freshness.ts`, invoked after `normalizeAndValidateSigningAccounts`. Substrate: `nonce === 0 && free === 0`. EVM: `nonce === 0 && native balance === 0n` (balance check added — a nonce-0 account can still hold native funds). The route's chain set comes from `quoteToSigningNetworks(quote)`, which MUST be kept in sync with the route builders whenever a route's chains change (pinned by `ephemeral-freshness.test.ts`). Fail-closed on RPC errors (`SERVICE_UNAVAILABLE`). — ✅ PASS +- [ ] EVM token (ERC-20) balances are NOT checked, only native balance — a nonce-0 ephemeral pre-loaded with tokens passes. **PARTIAL** — accepted: per-chain token enumeration is out of scope; the native-balance + nonce check covers the practical reuse/replay cases. - [x] Browser `rampEphemerals` backups are written only by the widget `PersistenceEffect` and the dashboard's local recovery-key service — neither storage path makes network calls or grants third-party access — ✅ PASS -- [x] `rampEphemerals` entries are keyed by ramp ID and accumulate across ramp resets — `removeRampEphemeral()` is exported for cleanup — ✅ PASS +- [x] Browser ephemeral entries are keyed by ramp ID; unresolved entries are retained and entries observed terminal are automatically removed on the first storage access after 90 days. Terminal observation is idempotent so later polling cannot extend retention. Legacy entries without metadata remain unresolved and are not age-pruned. — ✅ PASS - [x] Dashboard ephemerals are written before registration, rebound to the returned ramp ID, and retained independently from transfer/authentication resets, including leaving payment instructions — `apps/dashboard/src/services/rampEphemerals.ts` — ✅ PASS - [x] Dashboard BUY registration sends only the destination address plus public ephemeral signing accounts, performs no AppKit signature, and remains in `AwaitingPayment` until `PAYMENT_CONFIRMED`. The serializable machine snapshot is dashboard-namespaced and cleared on logout/reset. — ✅ PASS - [x] Frontend/SDK Substrate RPC initialization is lazy and transaction-demand driven — `apps/frontend/src/machines/actors/register.actor.ts` requests Pendulum/Moonbeam/Hydration APIs only for returned `ephemeralTxs` that need those networks; `packages/sdk/src/services/NetworkManager.ts` initializes each API through async getters; `apps/frontend/src/services/api/{moonbeam,pendulum}.service.ts` no longer opens websockets at module import time. — ✅ PASS diff --git a/docs/security-spec/02-signing-keys/server-side-signing.md b/docs/security-spec/02-signing-keys/server-side-signing.md index b8f84e37b..93f6600cd 100644 --- a/docs/security-spec/02-signing-keys/server-side-signing.md +++ b/docs/security-spec/02-signing-keys/server-side-signing.md @@ -4,39 +4,41 @@ The API server holds several private keys used for platform operations. These are distinct from ephemeral keys (which are client-side). Server keys are used for: -1. **Stellar funding operations** — `FUNDING_SECRET`: Stellar secret key used to create and fund ephemeral Stellar accounts, co-sign ephemeral transactions (as the second signer in the 2-of-2 multisig), and reclaim funds from orphaned ephemerals. -2. **Pendulum funding** — `PENDULUM_FUNDING_SEED`: Seed phrase for the Pendulum account that funds ephemeral Substrate accounts with native PEN tokens for transaction fees. -3. **Moonbeam execution** — `MOONBEAM_EXECUTOR_PRIVATE_KEY`: EVM private key used to execute transactions on Moonbeam (funding ephemerals with GLMR, executing subsidization transfers, XCM operations). -4. **Webhook signing** — `WEBHOOK_PRIVATE_KEY`: RSA private key (PEM format) used to sign webhook payloads with RSA-PSS + SHA-256. If missing, the `CryptoService` generates an ephemeral RSA keypair at startup (non-persistent). +1. **Pendulum funding** — `PENDULUM_FUNDING_SEED`: Seed phrase for the Pendulum account that funds ephemeral Substrate accounts with native PEN tokens for transaction fees. +2. **Moonbeam execution** — `MOONBEAM_EXECUTOR_PRIVATE_KEY`: EVM private key used to execute transactions on Moonbeam (funding ephemerals with GLMR, executing subsidization transfers, XCM operations). +3. **Webhook signing** — `WEBHOOK_PRIVATE_KEY`: RSA private key (PEM format) used to sign webhook payloads with RSA-PSS + SHA-256. If missing, the `CryptoService` generates an ephemeral RSA keypair at startup (non-persistent). -All keys are loaded from environment variables. There is no HSM, secrets manager, or rotation mechanism. +All keys are loaded from environment variables. There is no HSM, secrets manager, or rotation mechanism. The Stellar funding key (`FUNDING_SECRET`) no longer exists — Stellar/Spacewalk support was removed (migration 028). ## Security Invariants -1. **Server keys MUST only be used for their designated purpose** — The funding secret signs funding/merge transactions, the executor key executes platform operations. No key should be repurposed for user-level operations. -2. **`FUNDING_SECRET` MUST be the co-signer for Stellar 2-of-2 multisig** — The funding account keypair is used to co-sign ephemeral Stellar transactions alongside the client's ephemeral key. The funding account alone MUST NOT be able to move funds from the ephemeral (threshold is 2, each signer has weight 1). -3. **`WEBHOOK_PRIVATE_KEY` MUST be persistent across restarts** — If the env var is not set, `CryptoService` generates a new key pair in memory. This means webhook consumers who cached the public key will reject signatures after a restart. The env var MUST be set in production. -4. **RSA-PSS signing MUST use SHA-256 with maximum salt length** — The `signPayload` implementation uses `RSA_PKCS1_PSS_PADDING` and `RSA_PSS_SALTLEN_MAX_SIGN`. Consumers must use the same parameters to verify. -5. **The RSA private key MUST NOT be exposed via any API endpoint** — Only the public key should be available for webhook consumers to fetch. The `getPrivateKey()` method is correctly marked `private`. -6. **Key derivation MUST NOT be deterministic from public information** — Funding accounts, executor accounts, and webhook keys must be independently generated, not derived from the same master seed. -7. **Missing mandatory keys MUST prevent server startup** — If `FUNDING_SECRET`, `PENDULUM_FUNDING_SEED`, or `MOONBEAM_EXECUTOR_PRIVATE_KEY` are absent, the server cannot perform its core function and should refuse to start. -8. **The CryptoService singleton MUST initialize keys exactly once** — `initializeKeys()` should be called once at startup. Repeated calls should be idempotent or rejected. +1. **Server keys MUST only be used for their designated purpose** — The funding seed signs funding/merge transactions, the executor key executes platform operations. No key should be repurposed for user-level operations. +2. **`WEBHOOK_PRIVATE_KEY` MUST be persistent across restarts** — If the env var is not set, `CryptoService` generates a new key pair in memory. This means webhook consumers who cached the public key will reject signatures after a restart. The env var MUST be set in production. +3. **RSA-PSS signing MUST use SHA-256 with maximum salt length** — The `signPayload` implementation uses `RSA_PKCS1_PSS_PADDING` and `RSA_PSS_SALTLEN_MAX_SIGN`. Consumers must use the same parameters to verify. +4. **The RSA private key MUST NOT be exposed via any API endpoint** — Only the public key should be available for webhook consumers to fetch. The `getPrivateKey()` method is correctly marked `private`. +5. **Key derivation MUST NOT be deterministic from public information** — Funding accounts, executor accounts, and webhook keys must be independently generated, not derived from the same master seed. +6. **Missing mandatory keys MUST prevent server startup** — If `PENDULUM_FUNDING_SEED` or `MOONBEAM_EXECUTOR_PRIVATE_KEY` are absent, the server cannot perform its core function and should refuse to start. +7. **The CryptoService singleton MUST initialize keys exactly once** — `initializeKeys()` should be called once at startup. Repeated calls should be idempotent or rejected. +8. **Webhook signatures MUST bind the delivery timestamp** — `X-Vortex-Signature` is computed over `` `${timestamp}.${body}` `` where `timestamp` is the value of the `X-Vortex-Timestamp` header (unix seconds). Consumers verify against that exact string, reject timestamps outside a bounded window, and deduplicate on the payload's `eventId`, which is unique per event and stable across delivery retries. A signature over the body alone MUST NOT verify. +9. **Every webhook row MUST have an owner principal** — the partner behind a partner-scoped secret key or the user behind a user-scoped secret key (`webhooks.partner_id` / `webhooks.user_id`). Registering a webhook for a quote requires that the owner principal owns the quote (`quote_tickets.partner_id` / `user_id` match); a foreign quote returns the same 404 as a nonexistent one. Deletion is owner-scoped with a uniform 404 for foreign IDs. Delivery matching filters webhooks by the quote's owner, so session-scoped subscriptions cannot receive another tenant's events. Ownerless rows are unrepresentable: migration 056 deletes any pre-existing rows (there were none in production) and a CHECK constraint requires exactly one of `partner_id`/`user_id`, so the delivery matcher has no ownerless branch — one would match every quote and reopen the cross-tenant hole for exactly the rows an attacker could have planted before ownership existed. An event whose quote owner cannot be resolved is delivered to nobody. +10. **Webhook callback URLs MUST NOT reach internal infrastructure (SSRF)** — registration accepts only HTTPS URLs without embedded credentials, rejects IP-literal hosts outside publicly routable space, and resolves the hostname — rejecting it if it resolves to a non-public address (a host that does not resolve yet is allowed, since DNS is often provisioned after integration setup and delivery re-validates anyway). Before every delivery the hostname is re-resolved and every resolved address must be public; redirects are rejected (`redirect: "error"`). Address classification follows the IANA special-purpose registries for both IPv4 and IPv6, so documentation/benchmarking/6to4/site-local ranges are treated as non-public. **Residual risk (accepted):** a resolve-then-connect race remains — the guard and `fetch` resolve independently, so a DNS-rebinding attacker controlling the domain can answer differently for each. Closing it requires pinning the validated address for the connection (preserving Host/SNI) or an egress proxy enforcing destination policy; tracked as follow-up. Exploitation requires an authenticated secret key, and deliveries are POSTs whose response body is never returned to the registrant (blind SSRF). ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| | **Server compromise → key extraction** | Attacker gains shell access, reads env vars | All keys in env vars are extractable; no HSM protection. Mitigation: key separation limits blast radius — each key controls a different chain/function | -| **Funding account drain** | Attacker with `FUNDING_SECRET` creates unlimited Stellar accounts, draining XLM | Monitor funding account balance; alert on unusual creation volume; rate limit ramp creation | | **Executor key abuse** | Attacker with `MOONBEAM_EXECUTOR_PRIVATE_KEY` drains GLMR or executes arbitrary EVM transactions | Executor account should hold minimal GLMR (just enough for near-term operations); monitor balance and transaction patterns | | **Webhook signature forgery** | Attacker signs fake webhook payloads | RSA-2048 with PSS padding is computationally infeasible to forge without the private key; public key verification by consumers | +| **Webhook replay** | Attacker re-sends a captured delivery later | Signature covers `timestamp.body`, so the timestamp header cannot be swapped; consumers reject stale timestamps and deduplicate `eventId` | +| **Cross-tenant webhook subscription** | Authenticated but unrelated API key subscribes to another client's quote or session events | Ownership required at registration (quote owner must match key principal); delivery matching filtered by quote owner; owner-scoped deletion with uniform 404 | +| **Webhook SSRF** | Callback URL points at internal services (cloud metadata, private ranges) directly, via DNS, or via redirect | HTTPS-only URLs, private/reserved IP literals rejected at registration, hostname re-resolved and checked before every delivery, redirects rejected | | **Non-persistent webhook key** | Server restarts without `WEBHOOK_PRIVATE_KEY`, generates new key; consumers can't verify old signatures | Set `WEBHOOK_PRIVATE_KEY` in production; warn at startup (current behavior: logs warning) | | **Pendulum seed phrase exposure** | Seed phrase logged or leaked | Seed phrases should not be logged; `PENDULUM_FUNDING_SEED` should be treated as a secret in all log redaction rules | | **Key reuse across environments** | Same keys used in staging and production | Use separate keys per environment; include environment checks at startup | ## Audit Checklist -- [ ] `FUNDING_SECRET` is used only in `stellar.service.ts` for account creation and co-signing — never for arbitrary Stellar operations — 🟡 PARTIAL (also aliased as `SEP10_MASTER_SECRET`, F-022) - [x] `PENDULUM_FUNDING_SEED` is used only for funding ephemeral Pendulum accounts — never for arbitrary extrinsics — ✅ PASS - [ ] `MOONBEAM_EXECUTOR_PRIVATE_KEY` is used only for platform operations (funding, subsidization, XCM) — never for user-initiated EVM transactions — 🟡 PARTIAL (also aliased as `MOONBEAM_FUNDING_PRIVATE_KEY`, intentional) - [x] `CryptoService.initializeKeys()` is called exactly once at startup — ✅ PASS @@ -45,7 +47,12 @@ All keys are loaded from environment variables. There is no HSM, secrets manager - [x] If `WEBHOOK_PRIVATE_KEY` is not set, a warning is logged (verified in current code) — ✅ PASS - [x] RSA key generation uses 2048-bit modulus length minimum (verified: `modulusLength: 2048`) — ✅ PASS - [x] Signing uses `RSA_PKCS1_PSS_PADDING` with `RSA_PSS_SALTLEN_MAX_SIGN` (verified in current code) — ✅ PASS +- [x] `X-Vortex-Signature` covers `timestamp.body`; body-only signatures do not verify (webhook-delivery.service) — ✅ PASS +- [x] Webhook registration binds an owner principal and rejects quotes the principal does not own with a uniform 404 (webhook.service `registerWebhook`) — ✅ PASS +- [x] Webhook deletion is owner-scoped; foreign webhook IDs return the same 404 as nonexistent ones — ✅ PASS +- [x] Delivery matching filters by quote owner in addition to quote/session targeting (`findWebhooksForEvent`) — ✅ PASS +- [x] Callback URLs: HTTPS only, no credentials, private/reserved IP literals rejected at registration; DNS re-resolved and checked before every delivery; `redirect: "error"` on delivery fetch — ✅ PASS - [x] No server key (funding, executor, webhook) is ever included in API responses, logs, or error messages — ✅ PASS -- [x] Server startup fails if `FUNDING_SECRET`, `PENDULUM_FUNDING_SEED`, or `MOONBEAM_EXECUTOR_PRIVATE_KEY` is missing — ✅ PASS +- [x] Server startup fails if `PENDULUM_FUNDING_SEED` or `MOONBEAM_EXECUTOR_PRIVATE_KEY` is missing — ✅ PASS - [ ] Funding and executor accounts hold minimal balances — only what's needed for near-term operations — ❓ N/A (operational check) - [ ] Monitoring/alerts exist for unexpected balance changes on funding and executor accounts — ❓ N/A (no monitoring in codebase) diff --git a/docs/security-spec/03-ramp-engine/block-flow-architecture.md b/docs/security-spec/03-ramp-engine/block-flow-architecture.md new file mode 100644 index 000000000..e2eecb522 --- /dev/null +++ b/docs/security-spec/03-ramp-engine/block-flow-architecture.md @@ -0,0 +1,135 @@ +# Versioned Block-Flow Architecture + +## What This Does + +The block-flow engine is the executable financial program behind a quote. A catalog +selects one flow, simulation produces phase-owned metadata, registration produces +phase-owned facts, preparation creates a transaction plan, and the phase processor +executes the persisted phase sequence. + +This module defines the boundary between compile-time composition and persisted runtime +data. TypeScript adjacency checks help developers compose compatible blocks, but they do +not authenticate JSONB loaded after a deployment. Persisted identity, version dispatch, +runtime validation, and startup wiring checks therefore remain mandatory. + +## Security Invariants + +1. **Exactly one catalog match.** Quote resolution MUST evaluate every registered + definition. Zero matches are an unsupported request; more than one match is an + internal configuration error. Definition order MUST NOT select a financial route. +2. **Immutable persisted identity.** Every new quote MUST persist: + - stable flow ID and immutable flow version; + - catalog version; + - ordered-topology hash; + - global metadata, registration-facts, state, and transaction-plan schema versions; + - the schema version for every namespaced block context. +3. **Version dispatch.** Registration, preparation, start, and recovery MUST dispatch + using the persisted flow identity. Resolving the current catalog by request and then + silently accepting a different identity is forbidden. +4. **Recovery support.** A flow version MUST remain registered while the configured + backend can still dispatch persisted state that references it. The startup check MUST + cover every unexpired pending quote and every resumable ramp owned by the configured + flow variant. Resumable ramps include all nonterminal ramps after `initial`, regardless + of age, plus `initial` ramps within the start deadline. Both update and start MUST + reject an `initial` ramp after that deadline, before invoking a persisted-flow + lifecycle hook. A deployment may remove a version only after this scoped check proves + that the backend cannot dispatch it. Rollback MUST retain every version introduced by + the deployment being rolled back. +5. **Legacy adoption.** Unversioned quotes may be adopted by the current version only + when their request and exact block-key set match that version. Their registration + MUST persist the adopted identity. An unversioned ramp's stored phase sequence may be + adopted only when it exactly equals the selected version's sequence; otherwise it + requires manual recovery. +6. **Topology binding.** The topology hash MUST cover flow ID/version, catalog version, + ordered phases, permitted transition edges, and block schema versions. Any mismatch + MUST stop execution before a lifecycle hook or side effect. +7. **Enumerated transitions.** Normal and exceptional edges MUST be registered per flow + version. A handler may not jump to a phase outside those edges. The current universal + exceptional edge is a transition from a nonterminal phase to `failed`. +8. **Unique phase instances.** A flow version MUST NOT contain a duplicate phase name. + Repeated behavior requires a distinct phase-instance identity before it can be added. +9. **Executor bijection.** Construction and startup MUST require: + - one executor for every execution phase; + - executor and phase names equal at the same index; + - no incompatible executor for the same phase; + - no registry overwrite; and + - a registered handler for every phase in every enabled or recovery-supported flow. +10. **Versioned runtime envelopes.** Quote metadata, registration facts, block state, + and transaction plans MUST be object-shaped, namespaced by a context belonging to + the persisted flow, and validated before use. Missing or unknown contexts, invalid + schema versions, malformed phase sequences, and malformed transaction-plan maps + MUST fail closed. +11. **Schema evolution.** A change that reinterprets a persisted field MUST increment + its schema version and either retain its old reader or provide an explicit, + one-directional migration. Missing fields MUST NOT silently acquire a new meaning. +12. **Namespaced ownership.** Blocks MUST read their own metadata and state by context + key. Compatibility projection into legacy top-level `StateMetadata` or API response + fields MUST reject conflicting destinations. New executor dependencies MUST use + namespaced state rather than add another generic projection. +13. **Compile-time claims are limited.** TypeScript establishes block IO adjacency and + context-key typing only. Startup checks establish catalog/registry construction. + Runtime validation establishes persisted-data compatibility. Tests are evidence of + these controls, not substitutes for them. +14. **Durable external-operation identity.** Every provider order, ticket, payout, + subsidy, swap, bridge broadcast, gas payment, or settlement transfer MUST claim a + unique `financial_operations` row before the external call. Its operation key is + derived from scope type/ID, persisted flow ID/version, phase instance, and attempt + class; its request hash binds the financial inputs. +15. **Outcome-aware retry.** Financial-operation status MUST distinguish + `not_started`, `submitted`, `confirmed`, `failed`, and `unknown`. A confirmed + result is replayed locally without another provider call. A definitive rejection + may be retried with corrected inputs only when the integration explicitly raises + `FinancialOperationRejectedError`, proving that no side effect occurred. HTTP status + classes alone MUST NOT establish that proof. A submitted or ambiguous result MUST + halt for reconciliation and MUST NOT be repeated automatically. +16. **Upstream idempotency preference.** The stable operation key MUST be sent as the + provider idempotency key when the provider supports one. When the integration does + not expose such a facility, the local claim plus fail-closed reconciliation policy + is mandatory; an ambiguous timeout is not a retryable error. +17. **Cancellation propagation.** Every execution block MUST accept the processor + `AbortSignal` and propagate it through polling, sleeps, provider/RPC waits, and + financial-operation claims. No new external side effect may begin after + cancellation. If cancellation races an already-started financial call, its outcome + is `unknown` until reconciled. + +## Threat Vectors & Mitigations + +| Threat | Mitigation | +|---|---| +| A deployment changes a predicate or block order while a ramp is active | Persisted version dispatch and topology hash; retain referenced versions | +| A new corridor overlaps an existing predicate | Resolve all candidates and fail on ambiguity | +| A handler bug skips an accounting or delivery phase | Per-version transition graph rejects undeclared edges | +| A repeated phase name advances from the wrong occurrence | Duplicate phase names are rejected at construction | +| A block adds a phase without its executor | Construction and startup executor-bijection checks | +| A registry registration silently replaces another handler | Duplicate registration is rejected | +| Old or manually edited JSONB is cast into a new TypeScript type | Versioned envelope validation before registration, start, or recovery | +| Two blocks flatten different values into one legacy field | Compatibility merge rejects conflicting values | +| An old flow implementation is removed too early | Per-variant deployment/removal check against unexpired pending quotes and resumable ramps; update and start reject expired initial ramps before lifecycle hooks | +| A provider accepts an order and the database transaction later rolls back | Independent durable financial-operation claim; retry reuses the confirmed response or halts on ambiguity | +| Two workers attempt the same external side effect | Unique operation key and atomic `not_started` → `submitted` claim | +| A provider has no idempotency-key API | Unknown outcomes require reconciliation; automatic repetition is forbidden | + +## Audit Checklist + +- [x] Catalog resolution rejects zero and multiple matches. +- [x] New quotes persist flow identity, topology hash, and schema versions. +- [x] Registration and start verify the persisted identity rather than request-only + current-catalog resolution. +- [x] Flow construction rejects duplicate phases and phase/executor mismatch. +- [x] The phase registry rejects overwrites and startup verifies complete coverage. +- [x] Handler overrides are checked against the persisted version's transition graph. +- [x] Runtime envelopes and exact block-key sets are checked before lifecycle hooks. +- [x] Compatibility projection rejects conflicting duplicate destinations. +- [x] Provider lifecycle hooks that create orders or tickets are declared as external + operations and use a durable claim outside the ramp registration transaction. +- [x] Confirmed operation responses are replayed locally; ambiguous outcomes halt + without calling the provider again. +- [ ] Current Avenia, Mykobo, and AlfredPay clients do not expose a documented + idempotency-key parameter. Their operation keys are retained locally and the + fail-closed reconciliation fallback applies until provider support is available. +- [ ] Block-local schemas currently validate their versioned object envelopes and exact + ownership keys; field-by-field schemas must be added when a block changes persisted + shape. Until then, a version bump is mandatory for any such change. +- [ ] Deployment automation must query each flow variant for referenced versions in + unexpired pending quotes and resumable ramps before permitting their removal; the + runtime fails closed if an unsupported version reaches it. diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 70b0246cc..c0f4c03a6 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -4,84 +4,84 @@ The discount stage decides whether the platform tops up a swap result so the user receives an amount closer to the oracle-implied rate than what Nabla (and, for onramps, the downstream Squid bridge) would otherwise deliver. The top-up — a **subsidy** — is paid from a platform-funded account during a subsidy phase later in the ramp. The discount stage does not move funds; it only computes how much subsidy a given quote needs. -For each quote, the discount engine: +For each quote, the block subsidy simulations use the shared math and state in `phases/blocks/core/discount.ts` to: -1. Resolves an `ActivePartner` row for pricing. The source can be an explicit partner-owned request, a validated public-key partner, a profile assignment's ramp-specific partner ID, or the system default `vortex`. Pricing configs are resolved per `(partner_id, ramp_type, fiat_currency)`: a config scoped to the corridor's fiat currency (the quote's fiat leg, via `getTargetFiatCurrency`) takes precedence over the partner's wildcard (`fiat_currency IS NULL`) config; a partner whose configs are all scoped to *other* corridors resolves to no config, and the discount engine falls back to `vortex` for that quote. +1. Resolve an `ActivePartner` row for pricing. The source can be an explicit partner-owned request, a validated public-key partner, a profile assignment's ramp-specific partner ID, or the system default `vortex`. Pricing configs are resolved per `(partner_id, ramp_type, fiat_currency)`: a config scoped to the corridor's fiat currency (the quote's fiat leg, via `getTargetFiatCurrency`) takes precedence over the partner's wildcard (`fiat_currency IS NULL`) config; a partner whose configs are all scoped to *other* corridors resolves to no config, and discount resolution falls back to `vortex` for that quote. 2. Reads two partner-scoped parameters: - `targetDiscount` — the discount to advertise. A positive `targetDiscount` means the user receives **more** than the oracle implies (e.g. `targetDiscount=0.005` means the rate offered is 0.5% better than the oracle rate). - - `maxSubsidy` — a fractional cap on the subsidy as a share of expected output. + - `maxSubsidy` — a fractional per-quote cap on the subsidy as a share of expected output. `0` disables subsidy; values in `(0, 1]` cap it to that fraction. 3. Reads dynamic state `partnerDiscountState[stateKey]`, keyed per `(partner id, ramp direction, fiat corridor)` — corridor-scoped configs accumulate dynamic difference independently of the same partner's wildcard config — which holds a `difference` value that drifts up while no quote is consumed and back down once a quote is consumed, bounded by `[minDynamicDifference, maxDynamicDifference]`. -4. Calculates `expectedOutput = inputAmount × oraclePrice × (1 + targetDiscount + adjustedDifference)`. For offramps the oracle price is inverted first (USD → fiat), so the input amount MUST be USD-denominated: the engine first values the request input in USD via `getUsdDenominatedInputAmount` — USD-like stables (USD, USDC, USDT, USDC.e, axlUSDC) pass through unchanged, fiat-pegged stables (BRLA → BRL, EURC → EUR) are valued at their peg's FIAT-USD oracle rate, and any other input token falls back to the bridged USDC amount (`evmToEvm.outputAmountDecimal`) when available. A rate-feed failure while valuing a fiat-pegged stable does not fail the quote — it falls back to the bridged USDC amount (or the raw input as a last resort), so a transient price-feed outage cannot throw from discount math. +4. Calculate `expectedOutput = inputAmount × oraclePrice × (1 + targetDiscount + adjustedDifference)`. For offramps the oracle price is inverted first (USD → fiat), so the input amount MUST be USD-denominated: `getUsdDenominatedInputAmount` first values the request input in USD — USD-like stables (USD, USDC, USDT, USDC.e, axlUSDC) pass through unchanged, fiat-pegged stables (BRLA → BRL, EURC → EUR) are valued at their peg's fresh FIAT-USD oracle rate, and any other input token may use an independently derived USDC-denominated route amount. If neither a valid rate nor such a route amount exists, quote creation fails. Raw input units MUST NOT be relabeled as USD. 5. Calculates `actualOutput` as what the user would receive without subsidy (Nabla output minus post-swap fees on onramp, anchor fee added back on offramp). 6. Calculates `idealSubsidy = max(0, expectedOutput − actualOutput)` and `actualSubsidy = min(idealSubsidy, maxSubsidy × expectedOutput)` (only when `targetDiscount > 0`). -7. Writes a `ctx.subsidy` record consumed by downstream merge-subsidy and finalize stages and ultimately by the subsidy phase handlers. On EVM post-swap routes this record represents the discount-derived subsidy component only; the runtime handler may additionally cover actual-vs-quoted swap-output discrepancy, which is capped separately. The finalize stage snapshots the quote-time discount component into public display fields (`discountFiat`, `discountUsd`, `discountCurrency`) when the subsidy is applied, allowing the UI to show the user-facing discount separately from fees. +7. Write subsidy metadata under the owning block, consumed by transaction preparation and subsidy executors. On EVM post-swap routes this record represents the discount-derived subsidy component only; the runtime handler may additionally cover actual-vs-quoted swap-output discrepancy, which is capped separately. `phases/blocks/core/quote.ts` snapshots the quote-time discount component into public display fields (`discountFiat`, `discountUsd`, `discountCurrency`) when the subsidy is applied, allowing the UI to show the user-facing discount separately from fees. -The engine is wired by strategy configuration. Of the 10 route strategies in `apps/api/src/api/services/quote/routes/strategies/`, 9 register a discount engine and 1 does **not**: `onramp-monerium-to-evm`. On that single route, no subsidy is computed regardless of partner configuration. +Discount behavior is wired explicitly by the phases composed in `phases/blocks/flows/`; there is no strategy/orchestrator fallback. -The two AlfredPay strategies use dedicated discount engines (`OnRampAlfredpayDiscountEngine`, `OffRampAlfredpayDiscountEngine`) that compute the subsidy in the AlfredPay-side currency: +The AlfredPay block flows compute subsidy in the AlfredPay-side currency: -- **Onramp**: subsidy denominated in the AlfredPay on-chain currency (USDC on Polygon). When the AlfredPay quote API later returns a worse `finalOutput` than the discount-projected `expectedOutput`, the partner engine falls back to the discount engine's `expectedOutput` (see `onramp-alfredpay-to-evm` strategy + `alfredOnrampMintFallback` phase emission). The fallback is bounded by `maxSubsidy × expectedOutput`. -- **Offramp**: subsidy denominated in the AlfredPay on-chain currency (USD on Polygon), computed by inverting the oracle's `outputCurrency -> ALFREDPAY_ONCHAIN_CURRENCY` rate. The engine rejects a non-positive oracle rate. There is no pre-Nabla stage on this route, so the conventional `deductibleFee` is always zero and is omitted. +- **Onramp**: subsidy denominated in the AlfredPay on-chain currency (USDT on Polygon). In the cross-chain block flow, `AlfredpayMint` installs the provider-derived anchor fee, then `AlfredpaySubsidizePre` computes the bounded bridge target. Squid quote and transaction preparation both consume that same target. The `alfredOnrampMintFallback` presigned contingency remains bounded to the provider mint amount. +- **Offramp**: subsidy denominated in the AlfredPay on-chain currency (USD on Polygon), computed by inverting the oracle's `outputCurrency -> ALFREDPAY_ONCHAIN_CURRENCY` rate. There is no pre-Nabla stage on this route, so the conventional `deductibleFee` is always zero and is omitted. -For onramps to EVM destinations other than AssetHub, the engine also probes Squid Router (`getEvmBridgeQuote`) to convert the oracle-expected amount into the equivalent amount of the *pre-bridge* token (USDC on Base or axlUSDC on Moonbeam) so the subsidy is denominated in the token the ramp actually holds on the source chain. +For onramps to non-trivial EVM destinations, `SubsidizePost` probes Squid Router (`getEvmBridgeQuote`) to convert the oracle-expected amount into the equivalent amount of the pre-bridge token so the subsidy is denominated in the token the ramp actually holds on the source chain. ## Security Invariants -1. **Subsidy amount MUST be bounded by `maxSubsidy × expectedOutput`** — when `maxSubsidy > 0`, `calculateSubsidyAmount` clamps the shortfall to `expectedOutput × maxSubsidy`; for higher caps, the full shortfall is paid. The cap MUST always be enforced from the partner row, never from the request. -2. **Discount parameters MUST come from the database**, never from the API request. The engine reads `targetDiscount`, `maxSubsidy`, `minDynamicDifference`, `maxDynamicDifference` from a Sequelize `Partner` row. No request field overrides them. +1. **Subsidy amount MUST be bounded by `maxSubsidy × expectedOutput`** — `maxSubsidy = 0` disables subsidy; when `maxSubsidy` is in `(0, 1]`, `calculateSubsidyAmount` clamps the shortfall to `expectedOutput × maxSubsidy`. Values outside `[0, 1]` MUST be rejected at the administrative configuration boundary. The cap MUST always be enforced from the partner row, never from the request. +2. **Discount parameters MUST come from the database**, never from the API request. Block discount resolution reads `targetDiscount`, `maxSubsidy`, `minDynamicDifference`, `maxDynamicDifference` from partner pricing. No request field overrides them. 3. **Dynamic-difference clamping MUST hold both ends.** `getAdjustedDifference` enforces `≤ maxDynamicDifference`; `handleQuoteConsumptionForDiscountState` enforces `≥ minDynamicDifference`. A partner with no caps configured behaves as if both caps were `0` (no dynamic adjustment). 4. **The default partner row (`name = "vortex"`) MUST exist and MUST be `isActive`.** Discount partner resolution falls back to it when no non-default pricing partner applies or the referenced pricing partner row is inactive. Without an active default, discount computation produces a `null` partner and `targetDiscount=0`, silently disabling subsidies platform-wide. 5. **`targetDiscount` MUST be expressed as a fractional rate (not basis points).** It is added directly to `1` in `calculateExpectedOutput`: `effectivePrice × (1 + targetDiscount + adjustedDifference)`. A value of `0.005` means 0.5%. 6. **Subsidy MUST NOT bypass fee collection.** For onramps, `actualOutput = nablaOutput − (network + vortex + partnerMarkup)`. The subsidy then covers the shortfall against `expectedOutput` *after* those fees, so fees still flow to fee accounts. 7. **For offramps, the anchor fee MUST be added back to `expectedOutput`** before computing the shortfall (`adjustedExpectedOutputDecimal = oracleExpected + anchorFeeInBrl`). Otherwise the user would receive `expectedOutput − anchorFee`, which is short of the advertised rate by the anchor's cut. 8. **Subsidy amounts written to `ctx.subsidy` MUST be deterministic for a given input.** With `targetDiscount=0` the actual subsidy is forced to zero (`actualSubsidyAmountDecimal = Big(0)`), even when `idealSubsidy > 0`. This is the contract the merge-subsidy and subsidy phase handlers rely on. -9. **Discount subsidy MUST remain distinct from runtime swap discrepancy subsidy.** `ctx.subsidy.subsidyAmountInOutputTokenRaw` is the quote-time discount component, bounded by partner `maxSubsidy`. On EVM `subsidizePostSwap`, any actual-vs-quoted swap-output discrepancy is calculated against the live post-swap balance and capped by env-configured `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component is capped separately by env-configured `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Both runtime fractions default to `0.05`. +9. **Discount subsidy MUST remain distinct from runtime swap discrepancy subsidy.** `ctx.subsidy.subsidyAmountInOutputTokenRaw` is the quote-time discount component, bounded by partner `maxSubsidy`. On EVM `subsidizePostSwap`, any actual-vs-quoted swap-output discrepancy is calculated against the live post-swap balance and capped at the greater of $1.00 and env-configured `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output. Discount components below $1 bypass the separate runtime percentage safety cap; components of $1 or more are capped by env-configured `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Both runtime fractions default to `0.05`. 10. **EVM off-ramp Nabla minimums MUST use AMM-only output, not subsidy-merged output.** EVM Nabla quote producers write the AMM-only result to `nablaSwapEvm.ammOutputAmount*`. On Base EVM offramps, `MergeSubsidy` may then merge the quote-time discount subsidy into `nablaSwapEvm.outputAmount*` so downstream payout/finalization targets reflect the subsidized amount. The on-chain Nabla swap minimums MUST be derived from the preserved AMM-only amount (`ammOutputAmountRaw`, falling back to `outputAmountRaw` only for legacy quotes without the snapshot), otherwise the minimum can exceed what the AMM can deliver and cause deterministic swap reverts. 11. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. -12. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. -13. **Offramp `expectedOutput` MUST be computed from the USD value of the input, never the raw input amount.** The inverted oracle rate converts USD → fiat; feeding it a non-USD input amount misdenominates the target. Before this was enforced, a 1000 BRLA → PIX offramp was treated as 1000 USD, inflating `expectedOutput` (and the `maxSubsidy × expectedOutput` cap) by the BRL-USD rate (~5×) and over-paying the subsidy on every such quote; EURC → SEPA offramps were symmetrically under-subsidized. Enforced by `getUsdDenominatedInputAmount` in both `OffRampDiscountEngine` and `OffRampAlfredpayDiscountEngine`. +12. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** `SubsidizePost` catches probe failures and retains the oracle expected output. A network failure on the probe MUST NOT cause the entire quote flow to throw. +13. **Offramp `expectedOutput` MUST be computed from the USD value of the input, never the raw input amount.** The inverted oracle rate converts USD → fiat; feeding it a non-USD input amount misdenominates the target. Before this was enforced, a 1000 BRLA → PIX offramp was treated as 1000 USD, inflating `expectedOutput` (and the `maxSubsidy × expectedOutput` cap) by the BRL-USD rate (~5×) and over-paying the subsidy on every such quote; EURC → SEPA offramps were symmetrically under-subsidized. Enforced by `getUsdDenominatedInputAmount` in `phases/blocks/core/discount.ts` and the block offramp subsidy simulations. 14. **Public discount display MUST only expose applied quote-time discount subsidy.** `discountFiat` / `discountUsd` MUST be present only when `ctx.subsidy.applied` is true, the subsidy amount is positive, the subsidy currency can be inferred, and display conversion succeeds without fallback. Runtime swap-discrepancy top-ups MUST NOT be folded into this display field because they are execution-time protection, not a promotional rate improvement. +15. **Catalog BRL/EUR onramps MUST apply dynamic-discount math at the post-swap boundary.** The `SubsidizePost` block resolves pricing with `resolveDiscountPartner`, calls `calculateExpectedOutput` so `adjustedDifference` and `adjustedTargetDiscount` use the shared partner state, and treats its typed Base USDC input as `actualOutput`. Because `DistributeFees` precedes it, that input already has network, vortex, and partner-markup fees deducted. For non-trivial destinations it probes SquidRouter and divides the oracle target by the Base-USDC-to-destination conversion rate; probe failures retain the 1:1 fallback. This calculation MUST remain phase-hermetic and MUST NOT read Nabla, fee-distribution, or Squid block metadata. AlfredPay's specialized pre-bridge subsidy path is intentionally separate. ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| -| **Subsidy drain via partner row manipulation** | An attacker (or compromised admin endpoint) sets `targetDiscount` or `maxSubsidy` to large values on a partner row, causing the platform to over-subsidize every quote routed through that partner. | Pricing configs are mutated via `POST/DELETE /v1/admin/partner-pricing-configs`, protected by `adminAuth`; the default `vortex` wildcard config is delete-protected (`VORTEX_CONFIG_PROTECTED`) so the platform-wide fallback cannot be removed. Creation rejects negative `maxSubsidy` (the engine reads `maxSubsidy <= 0` as uncapped) and, for corridor-scoped vortex rows, inherits missing payout addresses from the wildcard (refusing when no substrate address is inheritable — a scoped vortex row without one would make fee-distribution building throw for the whole corridor). Deletion returns `409 PRICING_CONFIG_IN_USE` while pending unexpired quotes reference the partner and direction, closing the quote-to-registration window in which a deleted config would re-route or drop partner markup. The only non-admin mutation path is recipient-invite discount seeding (`recipient-transfers.md` invariant 11): restricted to `discount_manager` profiles, `targetDiscount` bounded at 300 bps (so the advertised discount always fits under the runtime EVM discount-subsidy cap with execution headroom), corridor-scoped, partner markup forced to `none`, the platform fee copied from the vortex config, and `maxSubsidy` set to the runtime cap fraction (`MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`, default 0.05) so quote-time subsidy computation and execution-time enforcement agree. Operational monitoring SHOULD alert on `cumulative subsidy per partner per day > threshold`. The `maxSubsidy` field is a hard per-quote ceiling; an organization-level cap is not currently enforced in code. | +| **Subsidy drain via partner row manipulation** | An attacker (or compromised admin endpoint) sets `targetDiscount` or `maxSubsidy` to large values on a partner row, causing the platform to over-subsidize every quote routed through that partner. | Pricing configs are mutated via `POST/DELETE /v1/admin/partner-pricing-configs`, protected by `adminAuth`; the default `vortex` wildcard config is delete-protected (`VORTEX_CONFIG_PROTECTED`) so the platform-wide fallback cannot be removed. Creation rejects `maxSubsidy` outside `[0, 1]`, and the engine treats zero as disabled. For corridor-scoped vortex rows, missing payout addresses are inherited from the wildcard (and creation refuses when no substrate address is inheritable). Deletion returns `409 PRICING_CONFIG_IN_USE` while pending unexpired quotes reference the partner and direction. The only non-admin mutation path is recipient-invite discount seeding (`recipient-transfers.md` invariant 11), which is separately bounded. The `maxSubsidy` field is a hard per-quote ceiling; aggregate exposure across quotes and subsidy classes is intentionally unchanged and remains an accepted risk pending a later budgeting design. | | **Quote bursting against dynamic difference** | A client issues many quotes in rapid succession to consume `partnerDiscountState.difference` down to `minDynamicDifference`, then waits for it to drift back up before placing a real ramp at a better rate. | `handleQuoteConsumptionForDiscountState` decreases `difference` by `deltaD` on each *consumed* quote (clamped at `minDynamicDifference`); `getAdjustedDifference` only increases it when `isWithinStateTimeout` is false. Rate limiting at the API layer is the primary defense; the discount state itself only provides eventual mean-reversion. | | **Owner/pricing partner drift** | A profile-assigned quote is user-owned (`partner_id = NULL`) but priced by a partner row. If discount state follows quote ownership instead of pricing attribution, dynamic adjustment is applied to no partner or the wrong partner. | Quote persistence records `pricing_partner_id`; ramp registration consumes dynamic discount state using `pricing_partner_id ?? partner_id`, so state follows the partner whose pricing was used. | -| **State loss on process restart re-grants discount** | The `partnerDiscountState` map lives in process memory (`apps/api/src/api/services/quote/engines/discount/helpers.ts:15`). A restart resets every partner's `difference` to `0` and `lastQuoteTimestamp` to `null`, effectively forgiving any in-progress quote consumption. | **Operational risk only — accepted for now.** Until the state is persisted to PostgreSQL, restarts will reset partner positions. Operators MUST treat planned restarts during high-volume periods as a known subsidy leakage vector. | +| **State loss on process restart re-grants discount** | The `partnerDiscountState` map lives in process memory (`apps/api/src/api/services/phases/blocks/core/discount.ts`). A restart resets every partner's `difference` to `0` and `lastQuoteTimestamp` to `null`, effectively forgiving any in-progress quote consumption. | **Operational risk only — accepted for now.** Until the state is persisted to PostgreSQL, restarts will reset partner positions. Operators MUST treat planned restarts during high-volume periods as a known subsidy leakage vector. | | **Multi-replica state divergence** | Running the API behind multiple replicas with no sticky routing causes each replica to maintain its own `partnerDiscountState`. The total subsidy paid can exceed the intended cap because each replica enforces its own ceiling independently. | **OPEN (F-DISC-01).** The current deployment topology MUST run a single replica, or the discount state MUST be persisted/centralised (e.g. Redis or a `partner_discount_state` table with row-level locking) before horizontal scaling. | | **Side-effect on read (cache-poisoning analogue)** | `getAdjustedDifference` mutates `partnerDiscountState` whenever it's called (`partnerDiscountState.set` on lines 106, 111, 120). If a quote pipeline retries the discount stage, the dynamic difference is incremented twice for one logical quote, charging the platform more than intended. | **OPEN (F-DISC-02).** `getAdjustedDifference` MUST be split into a pure reader and an explicit `recordQuoteIssued()` mutator, invoked once per quote at a well-defined point. As long as the discount engine is called exactly once per quote (the current stage pipeline guarantees this), the practical impact is bounded. | | **Misleading `[CAPPED]` log on zero-discount partners** | `formatPartnerNote` appends `[CAPPED]` whenever `actualSubsidy < idealSubsidy`. When `targetDiscount=0`, line 79 of `offramp.ts` (and 211 of `onramp.ts`) force `actualSubsidy=0`, but `idealSubsidy` can still be positive whenever Nabla undershoots the oracle. Operators reading logs may interpret a flood of `[CAPPED]` notes as `maxSubsidy` exhaustion when the real reason is `targetDiscount=0`. | **OPEN (F-DISC-03).** `formatPartnerNote` SHOULD distinguish "no discount configured" (`targetDiscount=0`) from "discount configured but cap hit" (`targetDiscount>0 && actual 0` (otherwise `actualSubsidy = 0`). Provider quote TTL (30 seconds) limits the timing window; quote refresh at start (`refreshAlfredpayOnrampQuoteIfMatching`) only re-binds when the new provider quote is byte-identical on `toAmount` and `fee`. | -| **AlfredPay offramp inverted-rate misconfiguration** | If the oracle returns a non-positive rate for `outputCurrency -> ALFREDPAY_ONCHAIN_CURRENCY`, dividing by it would yield `+∞` or NaN, corrupting downstream `expectedOutput` and subsidy math. | `OffRampAlfredpayDiscountEngine` throws when `effectiveRate ≤ 0`. The partner engine (`OfframpTransactionAlfredpayEngine`) has a symmetric `effectiveRate.gt(0)` guard with a fallback that uses `evmToEvm.outputAmountDecimal`. | -| **Subsidy on a discount-less route** | A partner with positive `targetDiscount` requests a quote on one of the discount-less routes (`offramp-evm-to-alfredpay`, `onramp-alfredpay-to-evm`, and the Mykobo EUR on/off-ramp routes which run end-to-end on Base without a Pendulum hop); they receive the bare Nabla rate and no subsidy, contradicting their partner configuration. | **Accepted product behavior.** These routes have no Pendulum-side Nabla swap that the discount engine is designed to subsidize (Alfredpay flows use direct stablecoin transfers; Mykobo flows swap on Nabla-on-Base, which is outside the Substrate-Nabla discount path). Operators SHOULD ensure partner-facing documentation makes the route-level discount applicability explicit. | +| **AlfredPay offramp inverted-rate misconfiguration** | If conversion returns a zero rate for `ALFREDPAY_ONCHAIN_CURRENCY -> outputCurrency`, dividing by it would corrupt downstream expected-output and subsidy math. | `AlfredpayOfframp.simulate` uses `Big.js` division, so a zero divisor throws and quote creation fails closed before a provider order can be created. | ## Audit Checklist -- [x] `BaseDiscountEngine.execute` short-circuits with a skip note when `request.rampType !== config.direction`. **PASS** — `index.ts:42-45`. -- [x] `OffRampDiscountEngine` only fires for `RampDirection.SELL`; `OnRampDiscountEngine` only fires for `RampDirection.BUY`. **PASS** — `offramp.ts:10`, `onramp.ts:18`. +- [x] Subsidy simulations are composed only into flows with the matching ramp direction. **PASS** — verified through the block flow catalog. - [x] Discount parameters (`targetDiscount`, `maxSubsidy`, `minDynamicDifference`, `maxDynamicDifference`) are read exclusively from the `Partner` Sequelize model and never accepted from request fields. **PASS** — `resolveDiscountPartner` uses `Partner.findOne`; no request field is read. -- [x] Subsidy cap `maxSubsidy × expectedOutput` is enforced in `calculateSubsidyAmount` (`helpers.ts:152-167`). **PASS**. +- [x] Subsidy cap `maxSubsidy × expectedOutput` is enforced in `calculateSubsidyAmount` (`phases/blocks/core/discount.ts`). **PASS**. +- [x] `maxSubsidy = 0` disables subsidy and the admin endpoint rejects values outside `[0, 1]`. **PASS**. +- [ ] Aggregate subsidy exposure is bounded across quotes, partners, time windows, and subsidy classes. **ACCEPTED RISK RISK-001** — the current per-quote/per-component behavior is intentionally preserved; no aggregate budget is enforced in this change. - [x] EVM post-swap runtime cap logic treats discount subsidy separately from swap discrepancy subsidy. **PASS** — the discount component comes from `ctx.subsidy.subsidyAmountInOutputTokenRaw`; the live discrepancy component is computed from the post-swap balance and quoted actual output. Each component must pass its own env-configured runtime cap before transfer. -- [x] Base EVM off-ramp Nabla swap minimums use the AMM-only output when quote-time subsidy was merged. **PASS** — EVM Nabla quote producers write `nablaSwapEvm.ammOutputAmountRaw`, `OffRampMergeSubsidyEvmEngine` leaves that AMM-only value untouched while merging subsidy into `outputAmountRaw`, and `addNablaSwapTransactionsOnBase` derives soft/hard minimums from `ammOutputAmountRaw ?? outputAmountRaw`. -- [x] `targetDiscount=0` forces `actualSubsidyAmountDecimal = Big(0)` in both engines. **PASS** — `offramp.ts:76-79`, `onramp.ts:209-212`. -- [x] Offramp `expectedOutput` adds back the anchor fee (`adjustedExpectedOutputDecimal = oracleExpectedOutput + anchorFeeInBrl`). **PASS** — `offramp.ts:50-51`. -- [x] Onramp `actualOutput` subtracts post-swap fees (`network + vortex + partnerMarkup`). **PASS** — `onramp.ts:198-199`. -- [x] Squid probe failures return `null` and the engine falls back to `adjustedExpected = oracleExpected`. **PASS** — `onramp.ts:88-93`, `148-153`, `185-192`. -- [x] Squid probe short-circuits to `1` when the destination is Base USDC same-token same-chain. **PASS** — `onramp.ts:123-125`. -- [x] `resolveDiscountPartner` falls back to `name = "vortex"` only when no active pricing partner applies. **PASS** — `helpers.ts:44-62`. An audit MUST verify a database seed exists that creates an active `vortex` partner row for every supported `rampType`. +- [x] `targetDiscount=0` forces the block subsidy amount to zero. **PASS** — enforced by the subsidy phase simulations. +- [x] Offramp expected output adds back the anchor fee before subsidy calculation. **PASS** — `phases/blocks/phases/subsidize-post/simulation.ts` and the Pendulum offramp variant. +- [x] Onramp subsidy observes post-fee phase input because `DistributeFees` precedes `SubsidizePost`. **PASS** — verified by catalog phase order. +- [x] Squid probe failures retain the oracle expected amount. **PASS** — `phases/blocks/phases/subsidize-post/simulation.ts`. +- [x] `resolveDiscountPartner` falls back to `name = "vortex"` only when no active pricing partner applies. **PASS** — `phases/blocks/core/discount.ts`. An audit MUST verify a database seed exists that creates an active `vortex` pricing row for every supported `rampType`. - [x] Consumed quote dynamic state uses the pricing partner rather than only the quote owner. **PASS** — ramp registration resolves `pricing_partner_id ?? partner_id` before calling `handleQuoteConsumptionForDiscountState`, passing the quote's corridor fiat currency so consumption hits the same corridor-scoped `stateKey` the quote-time engine used. - [x] Dynamic difference is bounded by `partner.minDynamicDifference` and `partner.maxDynamicDifference` (defaulting to `0` when null). **PASS** — `helpers.ts:103, 119, 144, 147`. - [x] `deltaD` is derived from `config.quote.deltaDBasisPoints / 10000`, server-side only. **PASS** — `helpers.ts:17-19`. - [x] `isWithinStateTimeout` gates the increment branch in `getAdjustedDifference`. **PASS** — `helpers.ts:115-125`. - [x] `handleQuoteConsumptionForDiscountState` only decrements when the timestamp is within the state-timeout window, then nulls the timestamp. **PASS** — `helpers.ts:127-150`. - [x] All discount-stage math uses `Big.js`. No native `number` arithmetic on monetary values. **PASS** — verified across all four files. -- [x] Strategies that wire a discount engine: 9 of 10. The single opt-out (`onramp-monerium-to-evm`) is intentional. **PASS** — verified via strategy file inspection. -- [x] `OffRampAlfredpayDiscountEngine` rejects non-positive oracle rates with an explicit error; `OfframpTransactionAlfredpayEngine` guards `effectiveRate.gt(0)` symmetrically. **PASS**. -- [x] `OnRampAlfredpayDiscountEngine` produces an `expectedOutput` that the `alfredOnrampMintFallback` phase consumes when the provider quote degrades; fallback subsidy stays bounded by `maxSubsidy × expectedOutput`. **PASS**. +- [x] Discount phases are wired explicitly by the block flow catalog; there is no strategy/orchestrator fallback. **PASS**. +- [x] AlfredPay offramp rate inversion fails closed on a zero divisor before provider order creation. **PASS** — `phases/blocks/phases/alfredpay-offramp/simulation.ts`. +- [x] AlfredPay onramp block metadata supplies the bounded target consumed by the fallback execution path. **PASS**. +- [x] Catalog BRL/EUR onramps resolve the active corridor pricing config, preserve `adjustedDifference` / `adjustedTargetDiscount`, use the post-fee typed USDC input as actual output, and adjust non-trivial routes by the Squid conversion rate without cross-block metadata reads. **PASS** — `phases/blocks/phases/subsidize-post/simulation.ts`. - [ ] **F-DISC-01 (OPEN)**: `partnerDiscountState` is process-local. Either constrain deployment to a single API replica or migrate the state to a shared store (e.g. PostgreSQL row with `SELECT ... FOR UPDATE`, or Redis with optimistic locking) before horizontal scaling. - [ ] **F-DISC-02 (OPEN)**: `getAdjustedDifference` mutates state on read. Split into a pure reader and an explicit `recordQuoteIssued()` mutator to make retry-safety explicit. - [ ] **F-DISC-03 (OPEN)**: `formatPartnerNote` emits `[CAPPED]` whenever `actual < ideal`, including the `targetDiscount=0` case where capping is forced. Disambiguate the log so operators do not misread it as a `maxSubsidy` exhaustion signal. diff --git a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md index a88e54d61..761e93592 100644 --- a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md +++ b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md @@ -9,7 +9,6 @@ The cleanup process runs as a background worker (`cleanup.worker.ts`) on a 5-min ### Chains Involved Ephemeral accounts may be created on: -- **Stellar** — For Spacewalk bridge operations and direct Stellar payments - **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. @@ -21,7 +20,6 @@ Ephemeral accounts may be created on: Post-process handlers registered in `apps/api/src/api/services/phases/post-process/index.ts`: -- **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** — 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. @@ -35,10 +33,10 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { ## Security Invariants 1. **Every funded ephemeral account MUST be cleaned up after ramp completion** — Residual tokens on an ephemeral account represent trapped value. Cleanup must run for every chain that held funds. -2. **Cleanup MUST cover ALL chains that an ephemeral account was funded on** — If a ramp touched Stellar, Pendulum, Moonbeam, Polygon, AssetHub, and Hydration, all six must have cleanup handlers. +2. **Cleanup MUST cover ALL chains that an ephemeral account was funded on** — If a ramp touched Pendulum, Moonbeam, Polygon, AssetHub, and Hydration, all five must have cleanup handlers. 3. **Failed and timed-out ramps MUST have a cleanup path** — If a ramp fails mid-execution (e.g., after funding the ephemeral account but before completing the swap), the funds on the ephemeral account must be recoverable. 4. **The cleanup worker MUST NOT skip ramp categories silently** — If a ramp type is excluded from cleanup (e.g., SEPA), the exclusion must be justified and the funds must be recoverable through another mechanism. -5. **Cleanup transactions MUST be submitted with the server's cosigner authority** — The ephemeral account's keypair is generated client-side and may not be available post-ramp. Cleanup relies on the server's cosigner (SetOptions on Stellar, multisig on Substrate) to authorize the sweep. +5. **Cleanup transactions MUST be submitted with the server's cosigner authority** — The ephemeral account's keypair is generated client-side and may not be available post-ramp. Cleanup relies on the server's cosigner (multisig on Substrate) to authorize the sweep. 6. **The Moonbeam 3-hour delay MUST be enforced before cleanup** — SquidRouter cross-chain swaps can trigger refunds via Axelar. Cleaning up before refunds land means the refunded tokens are sent to an account nobody controls. 7. **Cleanup failures MUST be logged and retried** — A single cleanup failure should not cause permanent fund loss. The worker should re-attempt on subsequent cycles. @@ -46,20 +44,20 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { | Threat | Attack Scenario | Mitigation | |---|---|---| -| **Stuck funds on failed ramp** | Ramp fails after `fundEphemeral` but before any swap executes. Tokens sit on ephemeral Pendulum account. | The cleanup worker selects on `currentPhase ∈ {"complete", "failed", "timedOut"}`, so failed/timed-out ramps with funded ephemerals on Stellar/Pendulum/Moonbeam/Polygon/Hydration are picked up by their respective post-process handlers. F-044 is therefore largely addressed at the worker-selection level; per-chain coverage gaps (no Base handler, AssetHub no-op stub) remain. | +| **Stuck funds on failed ramp** | Ramp fails after `fundEphemeral` but before any swap executes. Tokens sit on ephemeral Pendulum account. | The cleanup worker selects on `currentPhase ∈ {"complete", "failed", "timedOut"}`, so failed/timed-out ramps with funded ephemerals on Pendulum/Moonbeam/Polygon/Hydration are picked up by their respective post-process handlers. F-044 is therefore largely addressed at the worker-selection level. Remaining per-chain gaps: `BaseChainPostProcessHandler` is registered but its `shouldProcess` returns `false` unless `currentPhase === "complete"`, so **failed/timed-out Base ramps are never swept** despite the worker selecting them; AssetHub is a no-op stub. | | **Stuck ERC-20 dust on Base** | BRL on/off-ramps could leave BRLA/USDC residuals on the Base ephemeral. | **Mitigated.** `BaseChainPostProcessHandler` sweeps both BRLA and USDC after `currentPhase === "complete"` via presigned `approve` + funding-key `transferFrom`. ETH gas dust is not swept. | | **No-op AssetHub cleanup** | An AssetHub ephemeral holds residual tokens after an AssetHub-routed ramp. The registered `AssetHubPostProcessHandler` always returns `shouldProcess=false`. | **Known gap.** The handler is a placeholder. If AssetHub ephemerals can hold residual tokens, this needs to be implemented; otherwise the handler can be removed and the gap accepted. | | **SEPA ramp exclusion (historical)** | An older revision of the worker excluded `from: "sepa"` from cleanup. If still in place, residual Monerium EURe on the Polygon ephemeral from a failed SEPA onramp would be unrecoverable. | **No longer exclusionary.** The `cleanup.worker.ts` query no longer filters on `from`; SEPA ramps are now eligible. The PolygonPostProcessHandler runs against them and sweeps any user-approved residual via `transferFrom`. F-046 is therefore resolved by the worker change. | | **Premature Moonbeam cleanup** | Cleanup runs before the 3-hour SquidRouter refund window expires. Refunded tokens land on an already-swept ephemeral account. | MoonbeamPostProcessHandler enforces `MOONBEAM_CLEANUP_DELAY_MS` (3 hours). Verify this delay is checked before every Moonbeam cleanup, not just on first attempt. | -| **Ephemeral key loss** | Client generates the ephemeral keypair, but if the client disconnects or loses the key before cleanup, the server needs cosigner authority to sweep. If cosigner was never set (see F-040), cleanup is impossible. | Ensure SetOptions/multisig setup is validated at registration time. Server cosigner must be confirmed before the ramp starts. | +| **Ephemeral key loss** | Client generates the ephemeral keypair, but if the client disconnects or loses the key before cleanup, the server needs cosigner authority to sweep. If cosigner was never set, cleanup is impossible. | Ensure multisig setup is validated at registration time. Server cosigner must be confirmed before the ramp starts. | | **Cleanup worker saturation** | A burst of completed ramps overwhelms the worker (only 5 per cycle). Stale ramps accumulate. | Current mitigation: 5 ramps × every 5 minutes = 60 ramps/hour. Monitor queue depth. If insufficient, increase batch size or add a secondary worker. | ## Audit Checklist -- [x] **F-044 (largely resolved at worker layer)**: `cleanup.worker.ts` selects `currentPhase ∈ {"complete", "failed", "timedOut"}`. Failed/timed-out ramps are now eligible for cleanup wherever a post-process handler exists for the chain. Per-chain coverage gaps remain (no Base handler; AssetHub no-op). +- [x] **F-044 (largely resolved at worker layer)**: `cleanup.worker.ts` selects `currentPhase ∈ {"complete", "failed", "timedOut"}`. Failed/timed-out ramps are now eligible for cleanup wherever a post-process handler exists for the chain. Remaining per-chain gaps: the Base handler exists but gates on `currentPhase === "complete"` (failed/timed-out Base ephemerals are not swept — see below); AssetHub is a no-op stub. +- [ ] **Base cleanup does not cover failed/timed-out ramps** — `BaseChainPostProcessHandler.shouldProcess` returns `false` unless `currentPhase === "complete"`, so residual BRLA/EURC/USDC/AxlUSDC on the Base ephemeral of a failed or timed-out ramp is never swept, even though the worker selects those ramps. **OPEN** — the handler's presigned cleanup transactions exist regardless of terminal phase, so widening the guard is the likely fix. - **F-045 / F-NEW-05 (resolved)**: A `BaseChainPostProcessHandler` is now registered alongside Polygon and Hydration. It sweeps BRLA and USDC residuals from Base ephemerals after the ramp completes. ETH gas dust on Base ephemerals remains unswept (intentional). - [x] **F-046 (resolved)**: SEPA exclusion (`from: "sepa"`) is no longer present in the cleanup worker query. SEPA ramps now flow through normal post-processing. -- [x] StellarPostProcessHandler submits `stellarCleanup` XDR from ramp state — verified - [x] PendulumPostProcessHandler submits `pendulumCleanup` extrinsic from ramp state — verified - [x] MoonbeamPostProcessHandler enforces 3-hour delay before cleanup (`MOONBEAM_CLEANUP_DELAY_MS`) — verified - [x] PolygonPostProcessHandler broadcasts the user-presigned `approve` and runs `transferFrom(ephemeral, fundingAccount, balance)` from `getEvmFundingAccount(Polygon)` — verified (`polygon-post-process-handler.ts:36-83`) @@ -70,6 +68,6 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { - [x] Cleanup worker processes at most 5 ramps per cycle — verified - [x] Cleanup worker marks ramps as cleaned (`postProcessDone: true` via `postCompleteState.cleanup.cleanupCompleted`) to prevent re-processing — verified - [x] Base post-process handler catches errors per-chain and does not let one chain's failure block others — verified (each handler's `process` returns `[success, error]` and the worker `Promise.allSettled`s them) -- [EXISTING FINDING] **F-051**: No Slack alerting or monitoring notification for cleanup failures — silent fund trapping risk. -- [EXISTING FINDING] **F-052**: No admin endpoint to manually trigger cleanup for a specific ramp ID. -- [EXISTING FINDING] **F-057**: `destinationTransfer` handler sends presigned tx without validating destination address — combined with F-050, no destination validation exists in the ephemeral-to-user transfer path. +- [ ] **F-051**: No Slack alerting or monitoring notification for cleanup failures — silent fund trapping risk. +- [ ] **F-052**: No admin endpoint to manually trigger cleanup for a specific ramp ID. +- [ ] **F-057**: `destinationTransfer` handler sends presigned tx without validating destination address — combined with F-050, no destination validation exists in the ephemeral-to-user transfer path. diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index 6d7fe38af..28db9c293 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -2,90 +2,150 @@ ## What This Does -Fee calculation determines how much the user pays for a ramp operation and how that payment is distributed. This is a **critical financial security concern** because incorrect fee handling directly impacts user funds and platform revenue. - -### ⚠️ KNOWN ISSUE: Dual Fee System Discrepancy - -**Two parallel fee calculation systems exist, and they do NOT agree:** - -1. **Token-config-based fees (ACTUALLY USED)** — Defined in `shared/src/tokens/*/config.ts`. Parameters: `onrampFeesBasisPoints`, `onrampFeesFixedComponent`, `offrampFeesBasisPoints`, `offrampFeesFixedComponent`. Applied via `calculateTotalReceiveOnramp()` and `calculateTotalReceive()` helper functions. **These are the fees that actually reduce the user's output amount.** - -2. **Database-based fees (STORED/DISPLAYED ONLY)** — Calculated by `calculateFeeComponents()` using the `FeeConfiguration` and `Partner` database tables. Components: network fee, vortex fee, anchor fee, partner markup fee. These are stored in the database and returned in the API response, but **they do NOT determine the actual fee deduction**. - -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`. -- **Off-ramp:** Fees are deducted from the swap output AFTER the swap. `outputAfterFees = swapOutput - fees`. -- **Anchor fees** (Avenia/BRLA, Stellar) are deducted by the external anchor during the anchor interaction phase — the system must account for this deduction. -- **Platform fees** (vortex, network, partner markup) are distributed during the `distributeFees` phase, which dispatches to a Substrate (Pendulum) or EVM (Base, Multicall3) implementation based on the ephemeral chain in use. - -### Distribution Mechanisms - -Two parallel implementations live in `apps/api/src/api/services/transactions/common/feeDistribution.ts`: - -1. **Substrate (Pendulum)** — Single batch extrinsic that transfers each fee component to the corresponding partner address read from `partner_pricing_configs.payout_address_substrate`. -2. **EVM (Base)** — `Multicall3.aggregate3` batch (`MULTICALL3_ADDRESS = 0xcA11bde05977b3631167028862bE2a173976CA11`) executes one ERC-20 transfer per fee recipient atomically. Recipient addresses come from `partner_pricing_configs.payout_address_evm`. The handler pre-checks the active `vortex` pricing config for the quote's ramp direction has a non-NULL `payout_address_evm` and aborts the phase otherwise; partner-markup recipients resolve through the quote's pricing partner (`pricing_partner_id ?? partner_id`) and fall through with a warning when that partner's `payout_address_evm` is NULL. - -The `distribute-fees-handler.ts` chooses the correct path at runtime based on the ephemeral network (Pendulum vs. Base). For EVM, the handler pre-checks that the ephemeral has sufficient ERC-20 balance via `checkEvmBalanceForToken` with a 60-second poll timeout (`FEE_BALANCE_POLL_TIMEOUT_MS`). - -### Ordering with Nabla swap (BRL flows on Base) - -- **Offramp (USDC → BRLA)**: `distributeFees` runs **before** `nablaSwap` so partner/vortex fees are taken in USDC (the universal stablecoin) before swapping the remainder to BRLA. -- **Onramp (BRLA → USDC)**: `distributeFees` runs **after** `nablaSwap`, again ensuring fees are denominated in USDC. +The block-flow quote pipeline computes one fee snapshot and persists it on the quote. +Every later consumer uses that snapshot for API display, swap sizing, subsidy math, and +fee-distribution transaction preparation. Historical functions such as +`calculateTotalReceiveOnramp()` and `calculateTotalReceive()` are not part of the current +architecture and MUST NOT be used as evidence for current behavior. + +### Canonical block-flow pipeline + +1. `blocks/core/quote-fees.ts` calculates the configured Vortex fee, partner markup, and + provisional anchor fee using `Big.js` and server-side pricing rows. +2. `blocks/core/fees.ts` builds `PhaseCtx.fees` in both USD and the display fiat + currency. A block that obtains a live provider or bridge price may replace only the + component it owns: + - Mykobo and Avenia fee blocks install their live provider fee; + - routed blocks install the Squid network fee; + - direct/no-bridge routes preserve a zero network fee. +3. Quote finalization persists the resulting snapshot in + `quote_tickets.metadata.fees`. After quote creation, fee amounts are immutable. +4. `blocks/core/fee-distribution.ts` reads `metadata.fees.usd` when the registration + transaction plan is built. It distributes `network + vortex + partnerMarkup`; the + provider collects the anchor fee separately. + +`calculateFeeComponents` still derives a provisional anchor fee from the `anchors` +table. Provider-backed production blocks replace it with the provider's live amount. +The provisional value MUST NOT be treated as authoritative for a route whose provider +block has not successfully supplied its override. + +### Ordering is per flow + +There is deliberately no global “fees before swap” or “fees after swap” rule. Fees are +distributed while the ephemeral holds USDC: + +- BRL/EUR off-ramp flows execute `DistributeFees` before the USDC-to-BRLA/EURC Nabla + swap. +- BRL/EUR on-ramp flows execute `DistributeFees` after the BRLA/EURC-to-USDC Nabla + swap. +- Anchor fees are netted by the provider and are not moved by `DistributeFees`. +- A flow without a `DistributeFees` block does not collect Vortex, network, or partner + components on-chain. Such a flow MUST either quote those components as zero or add an + explicit collection block. + +The cataloged flow sequence is the authority. A broad statement that distribution must +always occur only after all user-facing phases is incorrect. + +### Distribution + +- **EVM/Base:** Base USDC is sent directly when only the Vortex destination is needed, + or atomically through Multicall3 `aggregate3` at + `0xcA11bde05977b3631167028862bE2a173976CA11` for split Vortex/partner payouts. +- **Pendulum:** `utility.batchAll` groups USDC transfers, with the configured optional + PEN buyback applied to the Vortex component. +- Network and Vortex components use the active Vortex payout address. Partner markup + resolves through `pricing_partner_id ?? partner_id`. +- Fee **amounts** come from the immutable quote snapshot. Payout **addresses** are + deliberately resolved while the registration plan is built, so address rotation can + affect an already-created but not-yet-registered quote. +- Distributed fees are final. The current implementation has no automatic clawback if + a later delivery phase fails. + +### Rounding + +Big.js modes are explicit where security-sensitive: + +| Point | Current rule | +|---|---| +| Quote component/display totals | half-up to the documented decimal precision | +| Substrate distribution raw units | round down (`toFixed(0, 0)`) | +| EVM distribution raw units | half-up (`toFixed(0)`) | +| Provider-side amounts that require truncation | round down | + +The EVM/Substrate raw-unit difference is current behavior, not a universal invariant. +Changing it requires explicit compatibility and accounting review because existing +quotes may already contain snapshots prepared under the old rule. ## Security Invariants -1. **The fees actually deducted MUST match the fees displayed to the user** — **CURRENTLY VIOLATED**. The token-config fees (actually deducted) and database fees (displayed) are calculated independently and may differ. This must be reconciled. -2. **Fee parameters MUST NOT be client-controllable** — All fee rates (basis points, fixed components) must come from server-side configuration (token config or database), never from request parameters. -3. **Fee calculations MUST use safe decimal arithmetic** — The code uses `Big.js` for fee calculations, avoiding floating-point precision errors. All monetary calculations MUST use arbitrary-precision arithmetic, never native JavaScript `number`. -4. **Negative output amounts MUST be blocked** — If fees exceed the input/output amount, the result must be clamped to zero, never negative. Both helper functions check `totalReceiveRaw.gt(0)` and return `'0'` otherwise. -5. **Fee deduction MUST happen at the correct point in the flow** — On-ramp fees deducted before swap; off-ramp fees deducted after swap. Applying fees at the wrong point changes the effective rate. -6. **Anchor fees MUST be accounted for in the quoted amount** — When BRLA or Stellar anchors deduct their fee, the system's quoted output must have already factored this in. The user should receive exactly the quoted net amount. -7. **Subsidization MUST NOT bypass fee collection** — When the platform subsidizes a shortfall (swap returned less than quoted), the subsidization covers the difference AFTER fees, not before. The platform should not subsidize to offset its own fees. -8. **Fee distribution (`distributeFees` phase) MUST transfer exact calculated amounts** — The amounts sent to vortex, network, and partner fee accounts must match the fee breakdown calculated during quoting. -9. **Partner markup distribution MUST use pricing attribution** — When `pricing_partner_id` is present, partner markup payout MUST use that partner row instead of relying only on the quote owner `partner_id`; `partner_id` is only the backward-compatible fallback. -10. **Rounding MUST be consistent and favor the platform** — On-ramp fees are rounded to 6 decimal places (round half up). Off-ramp fees are rounded to 2 decimal places (round half down). Rounding mode should never create a scenario where the user receives more than entitled. -11. **Fee configuration changes MUST NOT affect in-flight ramps** — Once a quote is created with specific fees, those fees are locked. Changing fee configuration should only apply to new quotes. -12. **Displayed discount MUST NOT hide charged fee components** — If a quote includes a subsidized rate improvement, clients may display the user benefit as a separate discount line and may show an effective total fee equal to charged fees minus discount. The underlying charged fee fields (`processingFeeFiat`, `networkFeeFiat`, `partnerFeeFiat`, and API `totalFeeFiat`) MUST remain unchanged; only the UI's effective total may become lower or negative. The discount is a platform-funded benefit, not negative revenue. +1. **One snapshot MUST govern display and collection** — the API, flow sizing, and + distribution MUST derive fee amounts from the persisted `metadata.fees` snapshot. +2. **Fee parameters MUST NOT be client-controllable** — rates and fixed amounts come + only from server pricing configuration and provider quotes. +3. **Fee arithmetic MUST use arbitrary precision** — monetary computation uses + `Big.js`; native JavaScript floating-point arithmetic is not authoritative. +4. **Negative fee components MUST be clamped to zero.** +5. **Provider and network overrides MUST occur before quote finalization** — a live + corridor MUST NOT execute using the provisional anchor value or a network fee from a + route that is not present. +6. **Flow-local ordering MUST match the collection currency** — each cataloged flow + places `DistributeFees` at the point where the ephemeral holds USDC. +7. **Anchor fees MUST be included in the quoted economics but excluded from on-chain + distribution** — the provider collects them. +8. **Distribution MUST transfer the snapshot's network, Vortex, and partner-markup + components without recalculating rates.** +9. **Partner markup MUST use pricing attribution** — + `pricing_partner_id ?? partner_id` identifies the payout partner. +10. **Pricing changes MUST NOT alter an existing quote's amounts** — payout-address + rotation before registration is the only deliberate live configuration lookup. +11. **A missing required Vortex payout destination MUST fail transaction preparation** + — it must not silently drop fees. +12. **A positive partner markup without a payout destination MUST be rejected before + execution or recorded as an explicit conformance gap** — logging and dropping it is + not fee integrity. +13. **Discount display MUST NOT rewrite charged components** — a subsidized rate + improvement is a separate platform-funded benefit. +14. **Reconciliation MUST compare like with like** — on-chain totals exclude anchor + fees; provider statements account for the anchor component. +15. **Failure after fee distribution is an accepted recovery gap** — no spec may imply + an automatic fee refund until one exists. ## Threat Vectors & Mitigations -| Threat | Attack Scenario | Mitigation | -|---|---|---| -| **Fee discrepancy exploitation** | User sees low fees in the API response (database fees) but is charged higher fees (token-config fees) — or vice versa | **MUST FIX**: Reconcile the two fee systems so displayed fees equal applied fees | -| **Fee bypass via direct quote manipulation** | Attacker modifies fee fields in the quote response before registering a ramp | Fees are recalculated server-side; quote amounts are immutable once stored; the token-config fees are applied regardless of what's in the database | -| **Rounding exploitation** | Attacker crafts amounts that exploit rounding to extract fractional value over many transactions | Rounding modes are specified (`Big.js` roundDown for off-ramp, roundUp for on-ramp); verify these favor the platform | -| **Fee parameter injection** | Attacker passes custom fee rates in the API request | Fee rates come exclusively from `getAnyFiatTokenDetails()` (token config) or database; never from request body | -| **Subsidization drain** | Attacker manipulates conditions so the platform always subsidizes the maximum amount | Slippage bounds limit subsidization; monitoring for excessive subsidization; circuit breaker on total subsidization per period | -| **Partner markup theft** | Partner sets unreasonably high markup to extract value | Partner markup bounds should be enforced; review partner configuration for reasonable limits | -| **Profile-priced markup not paid** | A profile-assigned quote is user-owned (`partner_id = NULL`) but has partner markup from custom pricing; fee distribution looks only at `partner_id` and drops the partner payout. | Fee distribution resolves the payout partner from `pricing_partner_id ?? partner_id`, so profile-assigned pricing still pays the partner whose rate was used. | +| Threat | Mitigation | +|---|---| +| Client injects lower fee fields | Request fee fields are ignored; server builds the snapshot | +| Pricing changes rewrite an in-flight quote | Amounts are persisted and read from `metadata.fees` | +| Wrong universal ordering changes the effective charge | Cataloged per-flow order is normative | +| Anchor fee is collected twice | Anchor included in quote total but excluded from `DistributeFees` | +| Partner payout is misattributed | Resolve with `pricing_partner_id ?? partner_id` | +| Missing payout address silently loses revenue | Vortex destination fails closed; partner gap remains tracked | +| Rounding is represented inaccurately | Current EVM and Substrate rules are documented separately | +| Later phase fails after collection | Accepted recovery gap; operational reconciliation is required | ## Audit Checklist -- [EXISTING FINDING] **CRITICAL FINDING F-002**: Verify the exact magnitude of discrepancy between token-config fees and database fees for each currency pair and ramp direction. Document which one the user actually experiences. **EXISTING FINDING** — documented as F-002 (dual fee system discrepancy). -- [x] `calculateTotalReceiveOnramp()` and `calculateTotalReceive()` are the only functions that affect the actual amount the user receives — verify no other fee deduction exists. **PASS** — confirmed: these are the only fee-deducting functions in the output amount calculation. -- [x] `calculateFeeComponents()` results are stored but NOT used for actual deductions — verify this hasn't changed. **PASS** — confirmed: database fee components are for display/logging only. -- [x] All fee calculations use `Big.js` (or equivalent arbitrary-precision library), never native `number`. **PASS** — verified: `Big.js` used throughout fee calculations. -- [N/A] Negative output protection: both fee functions return `'0'` when fees exceed the amount. **N/A** — requires business review to confirm the clamping behavior is intentional for all scenarios. -- [x] On-ramp fee is applied BEFORE the swap (reducing `inputAmount`). **PASS** — verified in the on-ramp flow. -- [Deferred] Off-ramp fee is applied AFTER the swap (reducing swap output). **Deferred to Module 05** — fee application point varies by integration; verified per-integration in Module 05 audits. -- [x] No fee parameter is accepted from the client request body. **PASS** — confirmed: all fee rates come from server-side config. -- [x] Fee configuration from token configs (`shared/src/tokens/*/config.ts`) matches what's intended for each currency. **PASS** — token configs reviewed; basis points and fixed components present for all supported tokens. -- [x] Rounding modes: on-ramp uses `round(6, 0)` (round half up to 6 decimals), off-ramp uses `round(2, 1)` (round half down to 2 decimals). **PASS** — verified rounding modes in both helper functions. -- [x] `distributeFees` phase distributes exactly the amounts from the fee breakdown — no recalculation. **PASS** — fee distribution uses stored breakdown values. -- [x] Partner markup payout uses the pricing partner when present. **PASS** — fee distribution resolves payout from `pricing_partner_id ?? partner_id`, preserving profile-assigned quote payouts while keeping older partner-owned quotes compatible. -- [x] Anchor fee deduction by external services (BRLA, Stellar) is pre-accounted in the quoted amount. **PASS** — anchor fees factored into quote calculation. -- [ ] Mykobo anchor fee in the quote MUST match the tier Mykobo actually charges. The fee tier is selected by `MYKOBO_CLIENT_DOMAIN`; an unset env var silently degrades to Mykobo's default tier (~5x worse), causing `defaultDepositFee` / `defaultWithdrawFee` and on-chain settlement to diverge. See `07-operations/secret-management.md` (invariant 9) and `05-integrations/mykobo.md` (invariant 20). -- [ ] Mykobo `/fees` outage during quote creation surfaces as `QuoteError.AnchorTemporarilyUnavailable` (`503`), not a generic failure. The optional env-gated display fallback (`MYKOBO_FEE_FALLBACK_ENABLED` → flat `MYKOBO_FALLBACK_DEPOSIT_FEE` / `MYKOBO_FALLBACK_WITHDRAW_FEE`) is **display-only** and MUST NOT price a ramp execution; a fallback-priced quote MUST re-validate the live Mykobo fee before a rail runs (EUR registration is currently disabled). See `05-integrations/mykobo.md` (invariant 26). -- [x] Fee changes in token config or database don't retroactively affect already-created quotes. **PASS** — quotes store immutable fee snapshots at creation time. -- [x] **FINDING F-061 (MEDIUM)**: Verify quote finalization enforces maximum amount limits. **PASS (FIXED)** — added `validateAmountLimits(..., "max", ...)` calls in both `OnRampFinalizeEngine.validate()` and `OffRampFinalizeEngine.validate()`. -- [x] **FINDING F-067 (MEDIUM)**: Verify `calculateFeeComponent()` cannot produce negative fee values. **PASS (FIXED)** — added `if (feeComponent.lt(0)) { feeComponent = new Big(0); }` floor check to clamp negative results to zero. -- [x] EVM branch of `distributeFees` uses `Multicall3.aggregate3` at `0xcA11bde05977b3631167028862bE2a173976CA11`. **PASS** — address constant matches canonical Multicall3 deployment. -- [x] EVM fee handler pre-checks ephemeral ERC-20 balance via `checkEvmBalanceForToken` with `FEE_BALANCE_POLL_TIMEOUT_MS=60s`. **PASS** — verified in `distribute-fees-handler.ts`. -- [x] BRL offramp ordering: `distributeFees` BEFORE `nablaSwap`. **PASS** — verified in `evm-to-brl-base.ts`. -- [x] **Vortex `payout_address_evm` NULL fallback**: `DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS` / `config.defaults.vortexEvmPayoutAddress` is used when the active `vortex` row lacks an EVM payout address. -- [x] **Partner `payout_address_evm` NULL no longer drops markup silently**: BRL-on-Base quote creation rejects partner-markup routes when the partner lacks EVM payout config, and runtime fee distribution logs a warning if the condition slips through. +- [x] `blocks/core/fees.ts` is the only block-flow writer of the base fee snapshot; + provider/network blocks replace only owned components. +- [x] Quote finalization persists `metadata.fees`; registration and status do not + recompute fee amounts. +- [x] `fee-distribution.ts` reads `metadata.fees.usd` and excludes `anchor`. +- [x] BRL/EUR off-ramp flows distribute before Nabla; BRL/EUR on-ramp flows distribute + after Nabla. +- [x] Direct BRL/EUR same-token routes quote zero bridge network fee. +- [x] EVM distribution uses direct ERC-20 transfer or atomic Multicall3 with + `allowFailure: false`. +- [x] Partner payout attribution uses `pricing_partner_id ?? partner_id`. +- [x] Negative calculated components are clamped to zero. +- [ ] **OPEN — uncollected displayed components:** any live flow that displays positive + Vortex/partner/network fees but has no `DistributeFees` block violates invariant 1. +- [ ] **OPEN — missing partner payout:** the EVM builder currently logs and drops a + positive markup when the pricing partner has no payout address. Quote/registration + must fail instead. +- [ ] **OPEN — cross-chain rounding consistency:** EVM raw distribution uses half-up + while Substrate truncates. Preserve current behavior until a versioned accounting + decision changes it. +- [ ] **OPEN — post-distribution failure recovery:** no automated clawback/refund exists + after fees have been distributed. +- [ ] Mykobo fee-tier selection depends on `MYKOBO_CLIENT_DOMAIN`; configuration and + live provider fee must agree before the rail executes. diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 20b5dfa54..234b3caca 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -4,9 +4,9 @@ Quotes are the entry point for every ramp. A quote calculates the expected output amount for a given input, factoring in exchange rates, fees, and dynamic pricing adjustments. The lifecycle: -1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (`BUY` for on-ramp or `SELL` for off-ramp). If an active maintenance window exists, the backend rejects quote creation with `503 Service Unavailable`, `Retry-After`, and downtime start/end metadata before fetching rates or writing a quote. Otherwise, the API calculates fees, fetches live exchange rates (fiat forex from fastforex.io with best-effort CoinGecko sanity checks, swap rates from Nabla DEX and SquidRouter), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, optional quote-time subsidy display fields, and a quote ID. +1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (`BUY` for on-ramp or `SELL` for off-ramp). If an active maintenance window exists, the backend rejects quote creation with `503 Service Unavailable`, `Retry-After`, and downtime start/end metadata before fetching rates or writing a quote. Otherwise, `QuoteService` resolves the request through the block-flow catalog, calls `Flow.simulate`, and persists its `{ globals, blocks }` metadata. The catalog is authoritative: an unmapped corridor returns `400` and is not passed to the removed strategy/orchestrator runtime path. The API calculates fees, fetches live exchange rates (fiat forex from fastforex.io with best-effort CoinGecko sanity checks, swap rates from Nabla DEX and SquidRouter), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, optional quote-time subsidy display fields, and a quote ID. - If live route/pool liquidity cannot serve the quote at the requested amount, the API returns a user-facing `500` quote error (`This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.`). Clients should treat it as a user-correctable liquidity failure and ask for a smaller amount or to check back soon. This applies to Nabla pool coverage failures, Squid route low-liquidity responses, and `/v1/quotes/best` when every candidate route fails for liquidity. Unexpected provider or calculation failures still follow the global production error policy and are masked as internal errors. -2. **Expiry** — Quotes expire **10 minutes** after creation (hardcoded in `QuoteTicket.create()` and the model default: `new Date(Date.now() + 10 * 60 * 1000)`). After expiry, the quote cannot be used to start a ramp. Note: this is a separate timeout from `discountStateTimeoutMinutes` (see Dynamic Pricing below). +2. **Expiry** — Quotes expire 10 minutes after creation by default. When a block phase returns an earlier provider expiry, `Flow.simulate` propagates the earliest phase expiry and the persisted quote uses it instead. After expiry, the quote cannot be used to start a ramp. This is separate from `discountStateTimeoutMinutes` (see Dynamic Pricing below). 3. **Binding** — When a ramp is registered (`POST /v1/ramp/register`), it binds to a specific quote ID. The quote's amounts become the committed values for the ramp. 4. **Consumption** — A quote can only be bound to one ramp. Once consumed, it cannot be reused. @@ -45,23 +45,23 @@ The system maintains an **in-memory** `Map customer_entities -> provider_customers (avenia)` (`resolveAveniaAccountForRamp`), `api_keys.user_id -> customer_entities -> provider_customers (alfredpay)` (`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`. Every resolver requires canonical `provider_customers.status = approved`; provider-native state remains separately available in `status_external`. 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. -18. **Dashboard BUY quote direction MUST match the selected fiat rail and EVM destination** — Dashboard onramp requests set `from`/`paymentMethod` from the approved fiat corridor, `to` and `network` to the selected ramp-enabled EVM network, `inputCurrency`/`inputAmount` to the fiat payment, `outputCurrency` to the selected dynamic-catalog token key, and `rampType = BUY`. The displayed receive amount and registration quote ID must come from that server response, not client-side rate math. -19. **Dashboard SELL registration MUST fail closed when the connected wallet cannot fund the refreshed quote** — Dashboard SELL quotes are input-driven from the selected executable EVM token, network, and decimal amount. The dashboard reads the selected network's Alchemy token portfolio and matches the exact configured contract address, normalizing Alchemy's null native-token address to the shared native sentinel; it MUST NOT use the wallet's currently selected chain or another same-symbol token as the balance source. The funding UI blocks on loading, lookup failure, or insufficient raw units using the selected token's configured decimals. After the transfer machine refreshes a near-expiry quote, it repeats that exact-token balance check against the replacement `inputAmount` before generating ephemeral keys or calling `/ramp/register`. -20. **Dashboard BUY options MUST include only executable destination assets** — Native POL is supported as a Polygon SELL input, but MUST NOT appear in BUY selectors until every onramp transaction path can construct a native destination transfer. Ramp history exposes each ramp's server-derived 15-minute start deadline so expired initial BUY ramps are displayed as cancelled rather than awaiting payment. +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 credential context's `profileId` (or the Supabase session): `profile_id -> customer_entities -> provider_customers (avenia)`, `profile_id -> customer_entities -> provider_customers (alfredpay)`, and `profile_id -> profiles.email` 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`. Every resolver requires canonical `provider_customers.status = approved`; provider-native state remains separately available in `status_external`. 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; the Avenia payout registration hook passes it to block-owned `validateAveniaOfframpRecipient`, which compares it with the provider's masked PIX-owner tax ID and derives the payout wallet from the trusted subaccount response. 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. A quote created with an API credential also stores `api_credential_id`; secret-key registration MUST resolve that same credential ID, so another credential for the same profile or partner cannot consume it. A Supabase session for the owning profile remains valid. 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. +18. **Quote and ramp preparation MUST resolve the same persisted flow** — Registration resolves the catalog flow from `quote.metadata.globals.request`, calls that flow's `register` and `prepareTxs`, and transactionally persists metadata refreshed by registration hooks. No route resolver or corridor transaction assembler remains; registration MUST NOT select a different corridor from mutable input. Phase registration facts and response artifacts are projected into the compatibility `StateMetadata` / API response shape only for active ramps; provider operations remain owned by the resolved flow. +19. **Presigned Squid input MUST equal the quoted block input** — Cross-chain AlfredPay source and destination fallback transaction construction MUST use `metadata.blocks.squidRouterSwap.inputAmountRaw`. It MUST NOT substitute the gross AlfredPay mint amount, because fees and subsidy can make those values differ. +20. **Dashboard BUY quote direction MUST match the selected fiat rail and EVM destination** — Dashboard onramp requests set `from`/`paymentMethod` from the approved fiat corridor, `to` and `network` to the selected ramp-enabled EVM network, `inputCurrency`/`inputAmount` to the fiat payment, `outputCurrency` to the selected dynamic-catalog token key, and `rampType = BUY`. The displayed receive amount and registration quote ID must come from that server response, not client-side rate math. +21. **Dashboard SELL registration MUST fail closed when the connected wallet cannot fund the refreshed quote** — Dashboard SELL quotes are input-driven from the selected executable EVM token, network, and decimal amount. The dashboard reads the selected network's Alchemy token portfolio and matches the exact configured contract address, normalizing Alchemy's null native-token address to the shared native sentinel; it MUST NOT use the wallet's currently selected chain or another same-symbol token as the balance source. The funding UI blocks on loading, lookup failure, or insufficient raw units using the selected token's configured decimals. After the transfer machine refreshes a near-expiry quote, it repeats that exact-token balance check against the replacement `inputAmount` before generating ephemeral keys or calling `/ramp/register`. +22. **Dashboard BUY options MUST include only executable destination assets** — Native POL is supported as a Polygon SELL input, but MUST NOT appear in BUY selectors until every onramp transaction path can construct a native destination transfer. Ramp history exposes each ramp's server-derived 15-minute start deadline so expired initial BUY ramps are displayed as cancelled rather than awaiting payment. +23. **Ramp update/start lifecycle MUST resolve the persisted flow generically** — Ramp update and start resolve `quote.metadata.globals.request` through the block catalog and invoke `Flow.start`. RampService MUST NOT branch on fiat currency or provider. Lifecycle metadata and state changes are persisted in the caller's transaction, and phase response artifacts are projected into the existing API response shape. ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| -| **Stale quote exploitation** | Attacker creates a quote when rates are favorable, waits for rates to move against the platform, then registers a ramp at the old rate | Quote expiry (10 minutes hardcoded); quote-time discount subsidy is bounded by partner `maxSubsidy`, and EVM post-swap runtime subsidy components are bounded by their own env-configured caps before funds move. | +| **Stale quote exploitation** | Attacker creates a quote when rates are favorable, waits for rates to move against the platform, then registers a ramp at the old rate | Quote expiry (10-minute default, shortened to the earliest provider expiry); quote-time discount subsidy is bounded by partner `maxSubsidy`, and EVM post-swap runtime subsidy components are bounded by their own env-configured caps before funds move. | | **Quote replay** | Attacker uses the same favorable quote ID for multiple ramps | One-time consumption: quote status is set to `"consumed"` on ramp registration; second attempt is rejected (`quote.status !== "pending"`) | | **Quote manipulation** | Attacker modifies quote amounts in transit or in database | Quotes stored server-side; amounts calculated server-side from authoritative sources; client cannot override amounts | | **Price oracle manipulation** | Attacker manipulates the DEX price before requesting a quote to get an artificially favorable rate | Use TWAP or multi-source pricing; bound acceptable deviation from reference rates; monitor for unusual quote patterns | | **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` 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. | +| **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; credential-originated quotes additionally bind `api_credential_id`, and secret registration must match it. 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. | @@ -101,25 +104,25 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` ## Audit Checklist - [x] Quote creation endpoint calculates all fee components server-side — no fee amounts accepted from the client. **PASS** — verified: all fee calculations happen in `calculateFeeComponents()` and token-config helpers; no fee fields accepted from request body. -- [x] Quote expiry is hardcoded to 10 minutes (`new Date(Date.now() + 10 * 60 * 1000)`) in the finalize engine — verify this is appropriate and cannot be overridden by client input. **PASS** — verified in `QuoteTicket.create()` and model default. +- [x] Quote expiry defaults to 10 minutes and may only be shortened by a phase-supplied provider expiry; client input cannot override it. **PASS** — verified in block flow simulation/finalization and the model default. - [x] Verify `discountStateTimeoutMinutes` (default 10 min) controls discount state inactivity, **NOT** quote expiry — these are separate timeouts that happen to share the same default. **PASS** — confirmed: separate code paths, separate purposes. - [x] Quotes are marked as consumed atomically with ramp creation — verify `consumeQuote` and `handleQuoteConsumptionForDiscountState` are called within the same transaction boundary. **PASS** — both called during ramp registration flow. - [x] `deltaDBasisPoints` (default 0.3) step size is reasonable — verify `0.3 / 10000 = 0.00003` per step is the intended rate adjustment granularity. **PASS** — confirmed in code; granularity appropriate for gradual rate adjustment. -- [N/A] `maxDynamicDifference` and `minDynamicDifference` are set to reasonable values for all partners in the database — check the "vortex" default partner especially. **N/A** — requires database inspection, not a code audit item. -- [EXISTING FINDING] **FINDING F-012**: Dynamic pricing state is in-memory only (`partnerDiscountState` Map) — lost on server restart. Verify this is acceptable or if persistence is needed. **EXISTING FINDING** — documented as F-012. -- [N/A] Verify `minDynamicDifference` cannot be set to a dangerously negative value in the partners table — no DB CHECK constraint exists. **N/A** — requires database schema review, not a code audit item. -- [N/A] Verify `maxDynamicDifference` cannot be set to an unreasonably high value that would cause excessive subsidization. **N/A** — requires database schema review, not a code audit item. +- [ ] `maxDynamicDifference` and `minDynamicDifference` are set to reasonable values for all partners in the database — check the "vortex" default partner especially. **N/A** — requires database inspection, not a code audit item. +- [ ] **FINDING F-012**: Dynamic pricing state is in-memory only (`partnerDiscountState` Map) — lost on server restart. Verify this is acceptable or if persistence is needed. **EXISTING FINDING** — documented as F-012. +- [ ] Verify `minDynamicDifference` cannot be set to a dangerously negative value in the partners table — no DB CHECK constraint exists. **N/A** — requires database schema review, not a code audit item. +- [ ] Verify `maxDynamicDifference` cannot be set to an unreasonably high value that would cause excessive subsidization. **N/A** — requires database schema review, not a code audit item. - [x] Exchange rates used in quote calculation come from live sources: fiat forex from fastforex.io (with best-effort CoinGecko `usd-coin` sanity check/fallback and the configured short cache TTL), swap rates from Nabla DEX and SquidRouter API. **PASS** — verified: forex rates are resolved through `PriceFeedService`; swap rates come from Nabla/Squid; failed fiat conversion providers now fail closed instead of returning the unconverted input amount. **Operational risk:** CoinGecko fallback/reference is a USDC-as-USD proxy during depeg conditions. - [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] Dashboard BUY quote builders are covered for BRL/PIX, MXN/SPEI, COP/ACH, USD/ACH, and ARS/CBU and bind the selected destination network/token into the request. **PASS**. - [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/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. +- [ ] Authentication is enforced on quote creation (verify which auth mechanisms protect `POST /v1/ramp/quotes`). **PARTIAL** — quote creation is optional-auth by design for public rate discovery. Alfredpay block quote phases use the approved customer id when available and the tracking-only anonymous sentinel otherwise; register/start require an effective KYC-approved 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`. - [x] The `resolveDiscountPartner` fallback to the `"vortex"` default partner is intentional — verify the default partner exists and has appropriate discount/subsidy settings. **PASS** — fallback to "vortex" partner confirmed in code when no active pricing partner applies. -- [N/A] Monitoring exists for quotes with unusually high subsidization requirements. **N/A** — no monitoring infrastructure audited. +- [ ] Monitoring exists for quotes with unusually high subsidization requirements. **N/A** — no monitoring infrastructure audited. - [x] **FINDING F-059 (HIGH)**: Verify `registerRamp` acquires `SELECT FOR UPDATE` lock on the quote, checks `consumeQuote` affected rows, and has a unique constraint on `rampState.quoteId` to prevent double-binding. **PASS (FIXED)** — lock added, affected rows checked, unique constraint migration `026` created. - [x] Verify active maintenance windows block `POST /v1/quotes` and `POST /v1/quotes/best` server-side with client-actionable downtime metadata. 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 8e142d155..ec37e274f 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -9,60 +9,75 @@ Understanding the complete token flow for each corridor is critical for security 2. **Each phase handler submits presigned or server-signed transactions** — incorrect ordering or skipped phases can leave funds in intermediate accounts. 3. **Subsidy phases inject platform funds** — the platform tops up ephemeral accounts to cover gas, bridging fees, or amount shortfalls, creating a direct drain vector if amounts are unchecked. -There are 29+ phase handlers in `apps/api/src/api/services/phases/handlers/`. The phase processor in `state-machine.md` orchestrates their execution. The authoritative registry lives in `register-handlers.ts`. +The phase processor in `state-machine.md` orchestrates execution. The authoritative definitions live in `phases/blocks/flows/catalog.ts`: each mapped flow derives its `RampPhase[]`, transaction plan, registration hooks, and executors. Active mappings include BRL/Avenia onramps and `BrlOfframpBase`, EUR/Mykobo onramps and `EurOfframpBase`, and AlfredPay flows. `phases/blocks/register-handlers.ts` registers only catalog executors. Corridors absent from the catalog are unavailable at quote creation, not runtime fallbacks. The persisted identity, upgrade, runtime-schema, transition, and wiring contract is defined in `block-flow-architecture.md`. + +`RampService` dispatches by the flow identity persisted with the quote, then invokes that version's `register` and `prepareTxs` in sequence. Registration metadata refreshes are persisted in the ramp-registration transaction. Phase facts and response artifacts are projected into the legacy top-level state/API fields required by active ramps only when duplicate destinations are absent or equal; conflicting projections fail registration. `blockState` remains the phase-owned source of truth. No corridor selector or transaction route builder participates in registration dispatch. ### Major Ramp Corridors **EUR Off-ramp (Mykobo on Base):** User's crypto on source EVM → Squid bridge to Base USDC (user-signed, client-side) → Nabla-on-Base swap (USDC→EURC) → Mykobo SEPA payout - Runtime backend phases: `initial` → `fundEphemeral` → `distributeFees` (on Base, USDC) → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `mykoboPayoutOnBase` → `complete` -- The Squid bridge from the source EVM chain to Base is executed by the user's wallet (presigned `squidRouterApprove` + `squidRouterSwap` are submitted client-side). Skip-Squid case: source = Base USDC. +- `EurOfframpBase` statically resolves the source token/network. Base USDC emits one user-wallet transfer; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap. `fundEphemeral` verifies reported hashes against the issued payloads before platform funds move, using the same validation applied to BRL EVM offramps. +- `MykoboOfframpPayout.register` derives the approved customer email from the authenticated user, treats the supplied email only as a consistency check, sends the effective IP to the withdrawal intent, and accepts the payout address only from validated provider instructions. Intent facts are phase-owned and feed payout preparation/execution. - Note: `distributeFees` runs **before** `nablaSwap` on offramp because fees are denominated in USDC and must be deducted before swapping to EURC. Mirrors the BRL-on-Base off-ramp. -- **Removed:** the previous Stellar-based EUR off-ramp (Pendulum → Spacewalk → Stellar anchor) is no longer active. See `stellar-anchors.md`. +- **Removed:** the previous Stellar-based EUR off-ramp (Pendulum → Spacewalk → Stellar anchor) is no longer active — Stellar/Spacewalk support was fully removed (migration 028). -**EUR On-ramp (Mykobo SEPA on Base):** SEPA payment → Mykobo settles EURC on the Base ephemeral → Nabla-on-Base swap (EURC→USDC) → optional Squid → user destination -- Runtime backend phases: `initial` → `mykoboOnrampDeposit` (poll Base RPC, 24h outer / 5min inner) → `fundEphemeral` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `distributeFees` → `subsidizePostSwap` → `squidRouterSwap` → `destinationTransfer` → `complete` -- Note: like BRL on-ramp, `fundEphemeral` provides ETH gas to the Base ephemeral before swap/approve/squid txs. `mykoboOnrampDeposit` transitions to `fundEphemeral` (`mykobo-onramp-deposit-handler.ts`), which selects `subsidizePreSwap` next for the `BUY && inputCurrency === EURC` branch (`fund-ephemeral-handler.ts`). -- Skip-Squid case (destination = Base USDC): the `squidRouterSwap` handler short-circuits directly to `destinationTransfer`. +**EUR On-ramp (Mykobo SEPA on Base):** SEPA payment → Mykobo settles EURC on the Base ephemeral → optional Nabla-on-Base swap (EURC→USDC) → optional Squid → user destination +- Shared swapped prefix: `initial` → `mykoboOnrampDeposit` (poll Base RPC, 24h outer / 5min inner) → `fundEphemeral` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `distributeFees` → `subsidizePostSwap`. +- Note: like BRL on-ramp, `fundEphemeral` provides ETH gas to the Base ephemeral before swap/approve/squid txs. The sequence is composed explicitly in `phases/blocks/flows/eur-onramp-base-same-chain.ts` and `eur-onramp-base-cross-chain.ts`; executors do not select the next phase. +- Base USDC appends only `destinationTransfer` and uses `EUR_ONRAMP_BASE_SAME_CHAIN`; no Squid transaction or phase is present. +- Base USDT, ETH, AXLUSDC, and BRLA append one `squidRouterSwap` phase and then `destinationTransfer`, using `EUR_ONRAMP_BASE_SAME_CHAIN_SWAP`. The same-chain route uses the Base transaction builder and has no `squidRouterPay`, backup transactions, or `finalSettlementSubsidy`. - Cross-chain case (destination ≠ Base USDC): `squidRouterSwap` → `squidRouterPay` → `finalSettlementSubsidy` → `destinationTransfer` for supported EVM destinations. -- **Degenerate EUR→EURC-on-Base case:** `isEurToEurcBaseDirect` short-circuits the entire pipeline to a single `destinationTransfer` (no Nabla, no Squid, no `finalSettlementSubsidy`, no cleanup), because Mykobo already settles EURC on the Base ephemeral and the generic path would otherwise swap EURC→USDC→EURC for itself. See `05-integrations/mykobo.md`. -- Base ephemeral cleanup (`baseCleanupUsdc`, `baseCleanupEurc`, `baseCleanupAxlUsdc`) is performed out-of-flow by `BaseChainPostProcessHandler` after `complete`. +- The non-Base EVM case is catalog-backed by `EurOnrampBaseCrossChain`. `MykoboMint.register` derives the authenticated Mykobo customer, creates the deposit intent, and returns phase-owned facts and IBAN artifacts. Preparation receives only `mykoboMint` registration facts; those facts and the EURC cleanup approval remain owned by that phase. +- **Degenerate EUR→EURC-on-Base case:** the exact SEPA/EUR/EURC/Base catalog predicate selects `EurOnrampBaseDirect`: `initial` → `mykoboOnrampDeposit` → `fundEphemeral` → `destinationTransfer` → `complete`. It has no Nabla, fee-distribution, Squid, final-settlement, or cleanup transaction because Mykobo already settles EURC on the Base ephemeral. Its only presigned transaction is `destinationTransfer` at Base nonce `0`. See `05-integrations/mykobo.md`. +- The swapped block flows presign `baseCleanupEurc` and `baseCleanupUsdc` in the cleanup nonce lane; `BaseChainPostProcessHandler` broadcasts the applicable post-`complete` sweeps. - **Removed:** the previous Monerium EUR on-ramp (EURe on Polygon → Squid → Moonbeam → XCM → Pendulum) is no longer active. See `monerium.md`. **BRL Off-ramp (Avenia/BRLA on Base):** User's crypto on source EVM → Squid bridge to Base USDC (user-signed, client-side) → Nabla-on-Base swap (USDC→BRLA) → Avenia PIX payout +- A Base BRLA source requires no Squid source route or network fee. Quote simulation values the BRLA at the BRL/USD oracle rate before entering the common Base offramp pricing pipeline, preserving the fiat peg rather than treating one BRLA as one USD. - Runtime backend phases: `initial` → `fundEphemeral` → `distributeFees` (on Base, USDC) → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `brlaPayoutOnBase` → `complete` - The Squid bridge from the source EVM chain to Base is executed by the user's wallet (presigned `squidRouterApprove` + `squidRouterSwap` are submitted client-side); there is no runtime `squidRouterPay` phase in the BRL off-ramp. -- **Temporary disablement:** AssetHub→BRL quotes are currently not returned by the quote engine. The active BRL off-ramp corridor is source EVM → Base → PIX only; any legacy AssetHub→BRL route code should be treated as unreachable until the corridor is re-enabled. +- `BrlOfframpBase` covers three source variants while preserving one runtime phase family: Base USDC emits one user-wallet `squidRouterNoPermitTransfer`; another Base token emits same-chain user-wallet Squid approve/swap; another EVM source emits cross-chain user-wallet Squid approve/swap into Base USDC. `fundEphemeral` verifies the reported hashes against those server-issued payloads before funding or executing Base phases. +- `AveniaOfframpPayout.register` derives the sender's Avenia identity from the authenticated user and calls `blocks/core/avenia-registration.ts` directly. That block-owned module validates the PIX key against the receiver's normalized tax ID (without stripping Avenia's mask), includes pending SELL volume in BRL/global limits, and returns the trusted Avenia EVM wallet. `AveniaMint.register` uses the same module for pending BUY limits and provider ticket creation. The payout transaction preparer cannot consume client-supplied payout-recipient facts, and `RampService` has no Avenia validation/ticket methods. +- **Temporary disablement:** AssetHub→BRL quotes are currently not returned by the quote engine. `BrlOfframpAssethubUsdc` is cataloged for persisted resolution, preparation, and recovery, but the explicit quote-eligibility gate rejects it before simulation. It preserves the inactive runtime sequence `fundEphemeral` → `distributeFees` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `pendulumToMoonbeamXcm` → `brlaPayoutOnBase`; `assethubToPendulum` remains a user-broadcast blueprint before runtime and `pendulumCleanup` remains post-completion. - Note: `distributeFees` runs **before** `nablaSwap` on offramp because fees are denominated in USDC and must be deducted before swapping to BRLA. - Naming: `nablaApprove`, `nablaSwap`, `distributeFees`, `subsidizePreSwap`, and `subsidizePostSwap` are polymorphic runtime phases that dispatch to the EVM (Base) branch when the ephemeral involved is on Base (BRL input or output corridor) and to the Substrate (Pendulum) branch otherwise. -- The EVM `subsidizePostSwap` branch may fund two bounded components in one transfer: the actual-vs-quoted Nabla output discrepancy and the quote-time discount subsidy. The discrepancy component uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. +- The EVM `subsidizePostSwap` branch may fund two bounded components in one transfer for both BUY and SELL flows: the actual-vs-quoted Nabla output discrepancy and the quote-time discount subsidy. The discrepancy component is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output. Discount components below $1 bypass the runtime percentage safety cap; components of $1 or more use `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Partner `maxSubsidy` still bounds the quote-time component. **BRL On-ramp (Avenia/BRLA on Base):** PIX payment → Avenia mints BRLA on Base ephemeral → Nabla-on-Base swap (BRLA→USDC) → optional Squid → user destination -- Runtime backend phases: `initial` → `brlaOnrampMint` (poll Base RPC, 30min outer / 5min inner) → `fundEphemeral` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `distributeFees` → `subsidizePostSwap` → `squidRouterSwap` → `destinationTransfer` → `complete` -- Skip-Squid case (destination = Base USDC): the `squidRouterSwap` handler short-circuits directly to `destinationTransfer`. -- Cross-chain case (destination ≠ Base USDC): `squidRouterSwap` → `squidRouterPay` → `finalSettlementSubsidy` → `destinationTransfer` for supported EVM destinations. **BRL→AssetHub quotes are temporarily disabled** and should not enter this phase chain. +- Base USDC phases: `initial` → `brlaOnrampMint` → `fundEphemeral` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `distributeFees` → `subsidizePostSwap` → `destinationTransfer` → `complete`. Squid is absent rather than handler-short-circuited. +- Other configured Base outputs (USDT, ETH, AXLUSDC, EURC): the same prefix continues through `squidRouterSwap` → `destinationTransfer` → `complete`. This is a same-chain swap only: no `squidRouterPay`, backup bridge transactions, or `finalSettlementSubsidy`. +- Cross-chain EVM case (destination ≠ Base USDC): `squidRouterSwap` → `squidRouterPay` → `finalSettlementSubsidy` → `destinationTransfer`. The distinct legacy BRL→AssetHub USDC topology is cataloged as `BrlOnrampAssethubUsdc` for deterministic preparation and recovery but remains rejected by quote eligibility, so no new ramp can enter it. - Amount precision: the Squid quote output is stored as the final EVM destination amount. `evmToEvm.inputAmountRaw` remains Base USDC raw and drives the Squid source-chain swap, while `evmToEvm.outputAmountRaw` and `quote.outputAmount` must use the destination token's raw/decimal precision before `destinationTransfer` is built. -- **Degenerate BRL→BRLA-on-Base case:** `isBrlToBrlaBaseDirect` short-circuits the entire pipeline to a single `destinationTransfer` (no Nabla, no `distributeFees`, no Squid, no `finalSettlementSubsidy`, no cleanup), because Avenia already mints BRLA on the Base ephemeral and the generic path would otherwise swap BRLA→USDC→BRLA for itself. Mirrors the EUR→EURC-on-Base bypass. See `05-integrations/brla.md`. +- **Degenerate BRL→BRLA-on-Base case:** the catalog selects `BrlOnrampBaseDirect`, whose flow contains only `brlaOnrampMint` → `fundEphemeral` → `destinationTransfer` (no Nabla, no `distributeFees`, no Squid, no `finalSettlementSubsidy`, no cleanup), because Avenia already mints BRLA on the Base ephemeral. Mirrors the intended EUR→EURC-on-Base bypass. See `05-integrations/brla.md`. - Base ephemeral cleanup (`baseCleanupUsdc`, `baseCleanupBrla`) is performed out-of-flow by a separate sweeper after `complete`; cleanup approvals are presigned but not part of the runtime nextPhase chain. **Alfredpay corridors:** Similar structure with `alfredpayOfframpTransfer` / `alfredpayOnrampMint` replacing the fiat provider phases. -- **Degenerate Polygon same-token onramp case:** Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) on Polygon. When the user requests that same token on Polygon (`quote.metadata.request.to === Networks.Polygon` **and** `quote.outputCurrency === ALFREDPAY_EVM_TOKEN`), the `squidRouterSwap` handler short-circuits to `finalSettlementSubsidy` with no swap. Any **other** Polygon output (e.g. USDC) still runs the real USDT→output swap — `quote.metadata.request.to` is the destination network, not the output token, so the short-circuit MUST also check `outputCurrency`. See `05-integrations/alfredpay.md`. + +Local manual flow testing may set `MOCK_ANCHOR_OPERATIONS=true`. In development, the BRLA and AlfredPay mint +executors replace partner polling with an on-chain ephemeral balance wait for the exact simulated mint amount. The +offramp block executors raise a recoverable, zero-retry pause at `brlaPayoutOnBase` or `alfredpayOfframpTransfer` +before reading partner state or broadcasting the anchor-bound transfer. The ramp remains in the payout phase and is +not cleanup-eligible, leaving the client-custodied ephemeral key available for fund recovery. The switch is active +only when `NODE_ENV=development`. +- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `complete`. The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Final transfer and recovery fallback share Polygon nonce 0, and `polygonCleanupAxlUsdc` follows at nonce 1. +- **Degenerate Polygon same-token onramp case:** Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) on Polygon. `AlfredpayOnrampDirect` composes a Squid passthrough block when the requested output is that same token and a same-chain Squid block for every other Polygon output. Both continue through `finalSettlementSubsidy` and `destinationTransfer`. See `05-integrations/alfredpay.md`. - **Amount precision on routed Alfredpay onramps:** when Alfredpay mints on Polygon and the user requests a different EVM output token, the routed Squid output is the final settlement amount. `evmToEvm.inputAmountRaw` remains the Polygon source-token raw amount, while `evmToEvm.outputAmountRaw` and `quote.outputAmount` MUST use the final destination token's raw/decimal precision. The direct Polygon same-token case remains at the minted token's precision. -- **Alfredpay offramp always runs `finalSettlementSubsidy`:** The `FinalSettlementSubsidyHandler` direct-transfer skip explicitly excludes `SELL && isAlfredpayToken(outputCurrency)`. This ensures the Polygon ephemeral is always subsidized to the expected amount before `alfredpayOfframpTransfer`, regardless of the `isDirectTransfer` flag. -- **`fund-ephemeral-handler` direct-transfer skip excludes Alfredpay offramps:** The `nextPhaseSelector` in `fund-ephemeral-handler.ts` skips to `destinationTransfer` for any ramp with `isDirectTransfer === true` **except** `SELL && isAlfredpayToken(outputCurrency)`. This preserves the existing skip for all other corridors while ensuring Alfredpay offramps proceed through `finalSettlementSubsidy` → `alfredpayOfframpTransfer`. +- **Alfredpay offramp always runs `finalSettlementSubsidy`:** `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. No executor short-circuits this sequence. **Cross-chain delivery (post-swap):** After the Nabla swap, tokens are routed to their final destination: -- ~~From Pendulum to Stellar (ARS-only since EUR was migrated to Mykobo): `spacewalkRedeem` → `stellarPayment`~~ — **REMOVED.** The Stellar/Spacewalk backend infrastructure was removed in commits `f89554d46` and `82761ba91`. `spacewalkRedeemHandler` and `stellarPaymentHandler` are no longer registered in `register-handlers.ts`. See `stellar-anchors.md`. - From Pendulum to Moonbeam: `pendulumToMoonbeamXcm` - From Pendulum to AssetHub: `pendulumToAssethubXcm` - From Pendulum to Hydration: `pendulumToHydrationXcm` → `hydrationToAssethubXcm` (if needed) - From Base to supported EVM destinations (BRL and EUR onramps): `squidRouterApprove` → `squidRouterSwap` → `squidRouterPay` → optional `backupSquidRouter*` on destination → `destinationTransfer` -- Trivial case (Base→Base USDC): direct `destinationTransfer` only (Squid skipped) +- Trivial post-Nabla case (Base→Base USDC): direct `destinationTransfer` only (Squid skipped) +- Same-chain Base token conversion: `squidRouterApprove` → `squidRouterSwap` → immediately adjacent `destinationTransfer`; no bridge-pay, backup, or final-settlement phases -**History/status terminal transaction link:** The API's V2 final transaction hash/link must point to the terminal user-facing on-chain delivery phase, not to intermediate bridge/swap phases such as `squidRouterSwap`. For EVM onramps this is `destinationTransferTxHash`; for AssetHub onramps it is `pendulumToAssethubXcmHash` or `hydrationToAssethubXcmHash`; for active offramps it is the corridor terminal payout hash (`brlaPayoutTxHash`, `mykoboPayoutTxHash`, or `alfredpayOfframpTransferTxHash`). Post-complete cleanup sweeps are not user-facing delivery and must not be exposed as the final transaction. +**History/status terminal transaction link:** The API's V2 final transaction hash/link must point to the terminal user-facing on-chain delivery phase, not to intermediate bridge/swap phases such as `squidRouterSwap`. For EVM onramps this is `destinationTransferTxHash`; for AssetHub onramps it is `pendulumToAssethubXcmHash` or `hydrationToAssethubXcmHash`; for active offramps it is the corridor terminal payout hash (`brlaPayoutTxHash`, `mykoboPayoutTxHash`, or `alfredpayOfframpTransferTxHash`). The cataloged AssetHub→BRL recovery flow has no separate Base payout transfer, so its terminal on-chain hash is `pendulumToMoonbeamXcmHash`. Post-complete cleanup sweeps are not user-facing delivery and must not be exposed as the final transaction. ### Phase Transition Diagrams -The following diagrams show the phase transitions for all on-ramp and off-ramp corridors as registered in `register-handlers.ts` and assembled by the route builders in `apps/api/src/api/services/transactions/{on,off}ramp/routes/`. Diamond nodes denote conditional branches resolved at route-build time (not runtime phase transitions). +The following diagrams retain the intended phase transitions for corridors as they are ported. Only catalog-mapped flows are active; their transitions are assembled from block-owned `phases` rather than route-builder constants. Diamond nodes denote distinct flow selection, not runtime handler branching. #### On-Ramp Phase Flow @@ -111,11 +126,11 @@ graph TD ``` > Notes: -> - **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 onramp funds the ephemeral.** The EUR flow definitions place `fundEphemeral` after `mykoboOnrampDeposit` and before `subsidizePreSwap`. 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 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. +> - **SquidRouter RPC selection is sourced from block metadata `fromNetwork`, not the input currency.** `blocks/phases/squid-router-swap/execution.ts` passes that network to `getClient(network)` for approve and swap. Transaction tests assert the source network on every intent. +> - **Alfredpay direct-token onramp uses an explicit passthrough block.** When Alfredpay mints `ALFREDPAY_EVM_TOKEN` on Polygon and the requested output is that same token, `flows/alfredpay-onramp-direct.ts` composes `SquidRouterPassthrough`, then `finalSettlementSubsidy` and `destinationTransfer`. Other Polygon outputs compose a same-chain swap; other EVM outputs use the bridge flow. +> - `BrlOnrampAssethubUsdc` preserves the inactive production topology: `brlaOnrampMint` → `fundEphemeral` → `moonbeamToPendulumXcm` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `distributeFees` → `subsidizePostSwap` → `pendulumToAssethubXcm`. It is cataloged so persisted quotes and recovery have one source of truth, but quote eligibility explicitly rejects BRL→AssetHub. The dormant non-USDC Hydration branches are not cataloged or ported. #### Off-Ramp Phase Flow @@ -128,7 +143,7 @@ graph TD %% --- Shared Base entry: BRL + EUR --- %% The user-signed Squid bridge (source EVM -> Base USDC) is submitted client-side - %% before the backend runtime starts. AssetHub -> BRL is temporarily disabled at quote eligibility. + %% before the backend runtime starts. AssetHub -> BRL is cataloged for recovery but disabled at quote eligibility. Corridor -->|BRL or EUR on Base| BaseFund[fundEphemeral] BaseFund --> BaseDistEvm["distributeFees (EVM branch)"] BaseDistEvm --> BaseSubPreEvm["subsidizePreSwap (EVM branch)"] @@ -155,25 +170,26 @@ graph TD ``` > Notes: -> - The ARS-via-Stellar off-ramp is **REMOVED.** Backend infrastructure was removed in commits `f89554d46` and `82761ba91`. `spacewalkRedeemHandler` and `stellarPaymentHandler` are no longer registered. See `stellar-anchors.md`. -> - `BaseChainPostProcessHandler` sweeps **all four** Base tokens regardless of corridor (`base-chain-post-process-handler.ts:9`: `BASE_CLEANUP_PHASES = ["baseCleanupBrla", "baseCleanupUsdc", "baseCleanupEurc", "baseCleanupAxlUsdc"]`). Per-corridor route builders only presign the subset they need. +> - `BaseChainPostProcessHandler` sweeps the Base cleanup transactions present in ramp state. Phase-owned transaction preparation emits only the cleanup intents required by the resolved flow. > - `pendulumCleanup` and other chain-specific post-process handlers (`PolygonPostProcessHandler`, `HydrationPostProcessHandler`) execute after `complete` via the post-process subsystem, not as in-flow phases. See `ephemeral-accounts.md`. -> - **Alfredpay offramp `finalSettlementSubsidy` is mandatory.** The direct-transfer short-circuit in `FinalSettlementSubsidyHandler` explicitly excludes Alfredpay SELL ramps, so the subsidy always runs regardless of `isDirectTransfer`. -> - **`fund-ephemeral-handler` direct-transfer skip excludes Alfredpay offramps.** The `nextPhaseSelector` skips to `destinationTransfer` for any ramp with `isDirectTransfer === true` **except** when `state.type === SELL && isAlfredpayToken(outputCurrency)`. This preserves existing skip behavior for all non-Alfredpay corridors while ensuring Alfredpay offramps proceed through `finalSettlementSubsidy` → `alfredpayOfframpTransfer`. +> - `BrlOfframpAssethubUsdc` uses only a Substrate ephemeral. The user's AssetHub wallet broadcasts the server-issued `assethubToPendulum` XCM blueprint and reports its hash; registration/start validation requires that hash before platform-funded Pendulum phases run. Pendulum fee distribution, Nabla approve/swap, Pendulum-to-Moonbeam XCM, backup signatures, and cleanup share one contiguous Substrate nonce sequence. +> - Pendulum pre/post-swap subsidy phases wait until the transferred balance is visible before advancing. Transient Pendulum RPC/submission failures remain recoverable, while a confirmed insufficient funding-account balance remains unrecoverable. A persisted Pendulum fee-distribution hash is checked for successful execution before the phase advances. +> - A newly submitted Pendulum-to-Moonbeam Avenia XCM records the fixed GLMR subsidy only after the expected BRLA arrives on Moonbeam, matching the transfer's completed accounting boundary. +> - **Alfredpay offramp `finalSettlementSubsidy` is mandatory.** `AlfredpayOfframp` declares the subsidy between funding and provider transfer for every source variant; no direct-transfer selector exists. ### Phase Handler Categories | Category | Handlers | Funds Controlled By | |---|---|---| -| **Subsidization (Substrate)** | `subsidize-pre-swap-handler` (Substrate branch), `subsidize-post-swap-handler` (Substrate branch), `final-settlement-subsidy`, `fund-ephemeral-handler` | Pendulum funding account → Pendulum ephemeral | -| **Subsidization (EVM)** | `subsidize-pre-swap-handler` (EVM branch), `subsidize-post-swap-handler` (EVM branch) | EVM funding account (`EVM_FUNDING_PRIVATE_KEY`, resolved per-network via `getEvmFundingAccount(network)` — currently the same key on Moonbeam and **Base**) → EVM ephemeral | -| **DEX Swap (Substrate)** | `nabla-approve-handler` (Substrate branch), `nabla-swap-handler` (Substrate branch), `hydration-swap-handler` | Ephemeral → DEX contract → ephemeral | -| **DEX Swap (EVM)** | `nabla-approve-handler` (EVM branch), `nabla-swap-handler` (EVM branch) | Base ephemeral → Nabla-on-Base contract → Base ephemeral | -| **Bridge / XCM** | `moonbeam-to-pendulum-handler`, `moonbeam-to-pendulum-xcm-handler`, `pendulum-to-moonbeam-xcm-handler`, `pendulum-to-assethub-phase-handler`, `pendulum-to-hydration-xcm-phase-handler`, `hydration-to-assethub-xcm-phase-handler`, `spacewalk-redeem-handler` | Source chain ephemeral → destination chain ephemeral | -| **Fiat provider** | `stellar-payment-handler`, `brla-payout-base-handler` (Base), `brla-onramp-mint-handler` (polls Base BRLA arrival), `mykobo-payout-handler` (Base EURC payout), `mykobo-onramp-deposit-handler` (polls Base EURC arrival), `alfredpay-offramp-transfer-handler`, `alfredpay-onramp-mint-handler` | Ephemeral ↔ provider | -| **SquidRouter** | `squid-router-phase-handler`, `squid-router-pay-phase-handler`, `squidrouter-permit-execution-handler` (incl. no-permit fallback) | Ephemeral/executor → SquidRouter → destination | -| **Fee distribution** | `distribute-fees-handler` (Substrate Pendulum + EVM Multicall3 on Base) | Ephemeral → platform fee collection address(es) | -| **Lifecycle** | `initial-phase-handler`, `destination-transfer-handler` | Setup and final delivery | +| **Subsidization (Substrate)** | `phases/blocks/phases/pendulum-subsidize-pre/`, `pendulum-subsidize-post/`, `final-settlement-subsidy/`, `fund-ephemeral/` | Pendulum funding account → Pendulum ephemeral | +| **Subsidization (EVM)** | `blocks/phases/subsidize-pre/execution.ts`, `blocks/phases/subsidize-post/execution.ts` | EVM funding account (`EVM_FUNDING_PRIVATE_KEY`, resolved per-network via `getEvmFundingAccount(network)`) → EVM ephemeral | +| **DEX Swap (Substrate)** | `blocks/phases/pendulum-nabla-swap/`, `blocks/phases/pendulum-offramp-nabla-swap/` | Ephemeral → DEX contract → ephemeral | +| **DEX Swap (EVM)** | `blocks/phases/nabla-swap/execution.ts` | Base ephemeral → Nabla-on-Base contract → Base ephemeral | +| **Bridge / XCM** | `blocks/phases/moonbeam-to-pendulum-xcm/execution.ts`, `blocks/phases/pendulum-to-assethub-xcm/execution.ts`, `blocks/phases/avenia-pendulum-offramp/execution.ts` | Source chain ephemeral → destination chain ephemeral | +| **Fiat provider** | `blocks/phases/avenia-mint/execution.ts`, `blocks/phases/avenia-offramp-payout/execution.ts`, `blocks/phases/mykobo-mint/execution.ts`, `blocks/phases/mykobo-offramp-payout/execution.ts`, `blocks/phases/alfredpay-mint/execution.ts`, `blocks/phases/alfredpay-offramp/execution.ts` | Ephemeral ↔ provider | +| **SquidRouter** | `blocks/phases/squid-router-swap/execution.ts`, plus Alfredpay permit execution in `blocks/phases/alfredpay-offramp/execution.ts` | Ephemeral/executor → SquidRouter → destination | +| **Fee distribution** | `blocks/phases/distribute-fees/execution.ts` (Substrate Pendulum + EVM Multicall3 on Base) | Ephemeral → platform fee collection address(es) | +| **Lifecycle** | `blocks/core/initial-executor.ts`, `blocks/phases/destination-transfer/execution.ts` | Setup and final delivery | ## Security Invariants @@ -181,14 +197,16 @@ graph TD 2. **Subsidy amounts MUST be bounded** — Every subsidization handler (`subsidizePreSwap`, `subsidizePostSwap`, `fundEphemeral`, `finalSettlementSubsidy`) must enforce a maximum USD-equivalent cap to prevent draining the funding account on a single ramp. EVM pre/post-swap cap fractions are loaded from environment configuration and default to `0.05`. EVM `subsidizePostSwap` must not treat the top-up as one undifferentiated bucket: the actual-vs-quoted swap-output discrepancy and the discount-derived subsidy must each pass their own configured cap before any transfer is submitted. 3. **Presigned transactions MUST be used in the correct phase** — `getPresignedTransaction(state, phase)` retrieves the transaction for a specific phase. A phase handler MUST NOT access presigned transactions for a different phase. 4. **Token amounts at each phase MUST be traceable to the original quote** — The quote defines input/output amounts. Each phase should operate on amounts derived from the quote, not from untrusted runtime state. -5. **Cross-chain transfers MUST wait for finalization before advancing** — XCM and bridge transfers must confirm the source chain has finalized the send before the destination chain phase begins. Non-finalized transfers can be reverted by chain reorganization. -6. **Fee distribution MUST happen after all user-facing phases complete** — The `distributeFees` phase occurs near the end of the flow. Deducting fees before the user receives their funds risks the ramp failing after fees are taken. +5. **Cross-chain advancement MUST use the strongest evidence available for that corridor** — Squid flows prefer terminal Squid/Axelar status and persist any route-scoped EVM balance fallback. Moonbeam→Pendulum waits for source finalization plus the planned destination amount. Quote-disabled BRL↔AssetHub recovery keeps narrowly documented XCM exceptions in `06-cross-chain/xcm-transfers.md` and `RISK-REGISTER.md`; those exceptions MUST NOT be generalized or used after the corridor is re-enabled. +6. **Fee distribution ordering is defined per corridor, not globally** — off-ramps run `distributeFees` **before** the swap (fees taken in USDC, the universal stablecoin, before the remainder swaps to the payout token); on-ramps run it **after** the swap (again in USDC). The corridor's phase sequence is the authority; see `fee-integrity.md` invariant 6. Distributed fees are final — a ramp that fails after `distributeFees` has no fee-refund path, which is accepted as RISK-010 and bounded by the recovery/retry design of the later phases. 7. **Each phase handler MUST be idempotent or have re-execution guards** — If the phase processor retries a phase (due to timeout or recoverable error), the handler must not double-execute (double-swap, double-transfer, double-fund). Nonce checks and balance pre-checks serve this purpose. -8. **SquidRouter RPC selection MUST be driven by `bridgeMeta.fromNetwork`** — `squid-router-phase-handler.ts` resolves the network via `bridgeMeta.fromNetwork` (set at registration by the route builder) and passes it to `getClient(network)` for both approve and swap calls. Selecting the RPC from `inputCurrency` would mis-route EUR onramps whose presigned txs carry `network: Networks.Base` to non-Base chains (causing `invalid chain id for signer: have X want Y` errors on cross-chain destinations). -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. +8. **SquidRouter RPC selection MUST be driven by block metadata `fromNetwork`** — `blocks/phases/squid-router-swap/execution.ts` resolves the network from `SquidRouterSwapContext` and passes it to `getClient(network)` for both approve and swap calls. Selecting the RPC from `inputCurrency` would mis-route EUR onramps whose presigned txs carry `network: Networks.Base` to non-Base chains (causing `invalid chain id for signer: have X want Y` errors on cross-chain destinations). +9. **On same-chain destinations, `destinationTransfer` MUST be the first executable nonce after the broadcast SquidRouter txs — no nonce gap** — The ephemeral shares one nonce sequence for `squidRouterSwap` → `destinationTransfer`. `Flow.prepareTxs` allocates main-lane intents before cleanup and backup lanes, while same-chain Squid preparation omits bridge-only backups. Flow transaction tests enforce the contiguous sequence for EUR, BRL, and Alfredpay. +10. **`destinationTransfer` MUST fail closed on transaction-validation uncertainty** — `blocks/phases/destination-transfer/execution.ts` parses the server-generated transaction, 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 executor raises an `UnrecoverablePhaseError` for manual review instead of looping until the retry budget silently exhausts. A malformed server-generated transaction is unrecoverable corruption. An unavailable required RPC preflight is recoverable. Neither condition may fall through to broadcast. Using `"pending"` rather than `"latest"` ensures the check accounts for mempool transactions. +11. **Base EVM `nablaSwap` MUST be dry-run before broadcast** — `blocks/phases/nabla-swap/execution.ts` must verify the raw transaction signer and simulate the exact presigned swap 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 `blockTag: "pending"`. If simulation reverts, the executor MUST fail before broadcast. Regression coverage lives in `blocks/__tests__/nabla-swap.executor.test.ts`. +12. **Presigned payout transfers MUST be validated and 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` (`blocks/core/destination-funding.ts`) recovers the sender from the signed raw transaction, decodes a positive token amount from `transfer` calldata (or positive native value), and polls (5s interval, 3-minute timeout) until the sender's balance covers the transfer. An unparseable, zero-value, or non-transfer server-generated payload is unrecoverable corruption. RPC/balance-read inability or a timeout is recoverable. Neither condition may fall through to broadcast. +13. **Pendulum subsidy settlement MUST be observed before advancing** — After a Pendulum pre/post-swap subsidy transfer, the handler polls until the ephemeral reaches the phase-owned target amount. Transient RPC, decode, submission, and settlement failures MUST remain recoverable; a confirmed insufficient funding-account balance MAY fail unrecoverably. +14. **Persisted Pendulum fee hashes MUST be verified** — `distributeFees` MUST verify that an existing Pendulum extrinsic completed successfully before advancing. Hash presence alone is not evidence that fees were distributed. ## Threat Vectors & Mitigations @@ -196,44 +214,40 @@ graph TD |---|---|---| | **Phase skip / injection** | Attacker with DB access modifies `currentPhase` to skip subsidization or jump to `complete`. | Phase transitions are controlled by handler return values, not external input. DB access is a prerequisite (see `state-machine.md`, Threat: "Phase skip attack"). No DB-level constraints on valid transitions exist. | | **Subsidy drain** | A crafted ramp triggers multiple subsidization phases, each at the maximum allowed amount, draining the funding account. | Per-ramp subsidy caps (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, balance pre-checks in pre/post-swap handlers). EVM pre/post-swap caps are env-configured quote-relative fractions, and EVM post-swap subsidy is split into discrepancy and discount components with independent caps. No aggregate cross-ramp cap exists — many concurrent ramps could still drain funds. | -| **Double-execution on retry** | Phase processor retries after timeout. Handler re-executes a swap or transfer that already completed. Funds are consumed twice. | Nonce guards in Spacewalk and Hydration handlers detect prior execution. Other handlers rely on transaction nonce uniqueness at the chain level. Not all handlers have explicit re-execution guards. | +| **Double-execution on retry** | Phase processor retries after timeout. Handler re-executes a swap or transfer that already completed. Funds are consumed twice. | The Hydration handler has a nonce guard. Other handlers rely on transaction nonce uniqueness at the chain level. Not all handlers have explicit re-execution guards. | | **Stale presigned transaction** | Client registers a ramp, waits for market movement, then starts the ramp with presigned transactions based on the old quote. | `RAMP_START_EXPIRATION_TIME_SECONDS` limits the window between registration and start. Quote expiry (10 minutes) limits how old the amounts can be. | | **Direct API ramp mutation during planned downtime** | A partner bypasses the UI maintenance state and calls register/update/start while operators expect Vortex services to be paused. | Ramp mutation routes run the backend maintenance guard and return `503` with `Retry-After`, `maintenance_start`, and `maintenance_end` before registration, presigned transaction updates, or phase processing begins. | | **Cross-chain race condition** | XCM transfer submitted but not finalized. Next phase on destination chain reads a zero balance. | Most XCM handlers use `waitForFinalization=true`. Exception: Hydration skips finalization (F-009, deferred). | | **Fee distribution failure** | `distributeFees` fails, but ramp is already marked `complete`. Platform loses fee revenue. | `distributeFees` is a phase — if it fails, the ramp enters retry, not `complete`. However, if the ramp fails after user delivery but before fee distribution, fees may be lost. | -| **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. | +| **Wrong-chain signer on SquidRouter** | RPC selected from request currency instead of phase metadata; a Base transaction is submitted to another EVM RPC. | `blocks/phases/squid-router-swap/execution.ts` reads `fromNetwork` from its own metadata and routes approve/swap to that client. | +| **Same-chain destination nonce gap (0-delivery)** | `destinationTransfer` is signed after cleanup/backup transactions and cannot mine because its nonce is too high. | Flow-level nonce lanes place main transactions first; same-chain Squid omits bridge backups; `blocks/phases/destination-transfer/execution.ts` fails fast on a detectable nonce gap. | +| **Predictable EVM Nabla swap revert** | Base Nabla pool constraints make the presigned swap impossible before broadcast. | `blocks/phases/nabla-swap/execution.ts` runs `eth_call` on the exact decoded transaction with `blockTag: "pending"` before `sendRawTransaction`. | | **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 - [x] Phase processor calls handlers in sequence via `phaseRegistry` lookup — no parallel execution or phase skipping in code - [x] `getPresignedTransaction(state, phase)` filters by phase name — handlers cannot accidentally access another phase's transaction -- [x] `subsidize-pre-swap-handler` and `subsidize-post-swap-handler` both query funding account balance before transfer (after F-032 fix) +- [x] The block pre/post-subsidy executors query funding account balance before transfer (after F-032 fix) - [x] `final-settlement-subsidy` has `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` cap (after F-001 fix) - [x] `final-settlement-subsidy` validates SquidRouter swap output amount (after F-030 fix) -- [x] `squidrouter-permit-execution-handler` validates `squidRouterPermitExecutionValue` cap (after F-027 fix) -- [x] `spacewalk-redeem-handler` has nonce-based re-execution guard — skips to waiting path if nonce indicates prior execution -- [x] Hydration XCM handler has nonce guard but only warns (F-028, fixed to skip like Spacewalk) -- [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] `blocks/phases/alfredpay-offramp/execution.ts` validates `squidRouterPermitExecutionValue` (after F-027 fix) - [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 -- [x] Active BRL corridors are end-to-end on Base — no Moonbeam/Pendulum/XCM involvement. **PASS** — `register-handlers.ts` does not register any `brlaPayoutOnMoonbeam` phase; active BRL quotes are limited to the Base/EVM route builders (`evm-to-brl-base.ts` and `avenia-to-evm-base.ts`). BRL↔AssetHub is temporarily disabled at quote eligibility. -- [x] Active EUR corridors are end-to-end on Base — no Pendulum/Spacewalk/Stellar involvement for EUR. **PASS** — `register-handlers.ts` registers `mykoboOnrampDeposit` and `mykoboPayoutOnBase`. EUR off-ramp uses `evm-to-mykobo.ts`; EUR on-ramp uses `mykobo-to-evm.ts`. Stellar-EUR off-ramp and Monerium-EUR on-ramp are removed. See `05-integrations/mykobo.md`. -- [x] On the EUR/Base corridor, `distributeFees` is positioned **before** `nablaSwap` on offramp (USDC fees deducted pre-EUR-swap) and **after** `nablaSwap` on onramp (USDC fees deducted post-EUR→USDC swap). **PASS** — verified in `evm-to-mykobo.ts` and `mykobo-to-evm.ts`, mirroring the BRL/Base structure. -- [x] On the BRL/Base corridor, `distributeFees` is positioned **before** `nablaSwap` on offramp (USDC fees deducted pre-BRL-swap) and **after** `nablaSwap` on onramp (USDC fees deducted post-BRL→USDC swap). **PASS** — verified in `evm-to-brl-base.ts` and `avenia-to-evm-base.ts`. -- [x] EVM subsidy phases enforce USD-equivalent caps. **PASS** — `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` defaults to `0.05` and clamps pre-swap subsidy plus the post-swap actual-vs-quoted swap-output discrepancy component. `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` defaults to `0.05` and separately clamps the post-swap discount-derived component. Both values are env-overridable. Over-cap cases are intentionally recoverable retries: no transfer is submitted, and the ramp waits for operator intervention instead of moving to `failed`. -- [x] BRL on-ramp `backupApprove` allowance is bounded (no `maxUint256`). **PASS** — `avenia-to-evm-base.ts` `backupApprove` is set to `inputAmountRawFinalBridge × 1.05` (F-NEW-03 resolved). +- [x] `alfredpayOfframpTransfer`, `mykoboPayoutOnBase`, and `brlaPayoutOnBase` run `ensurePresignedTransferFunded` before the first broadcast of their presigned single-use transfer — sender recovered from the signature, positive token/native amount decoded, balance polled with a 3-minute timeout, and all validation/preflight failures stop before broadcast. Malformed server-generated payloads are unrecoverable; transient RPC failure and shortfall timeout are recoverable. +- [ ] **F-053 (narrowed)**: `blocks/phases/pendulum-to-assethub-xcm/execution.ts` trusts a persisted finalized source hash without destination-arrival proof. **DEFERRED RISK RISK-009**. +- [x] Backup presigned intents (`backupSquidRouterApprove`, `backupSquidRouterSwap`, `backupApprove`) are contingency payloads owned by `SquidRouterSwap`, not executable phases; executor-bijection validation correctly excludes them. **PASS** +- [ ] No aggregate cross-ramp subsidy rate limiting — **ACCEPTED RISK RISK-001**; many concurrent ramps could drain the funding account. +- [x] Active BRL corridors are end-to-end on Base. **PASS** — BRL→AssetHub USDC is cataloged only for deterministic preparation/recovery and is explicitly rejected before quote simulation; therefore no new active ramp reaches its Moonbeam/Pendulum/XCM executors. +- [x] Active EUR corridors are end-to-end on Base — no Pendulum involvement for EUR. **PASS** — the catalog maps EVM→EUR to `EurOfframpBase` and EUR onramps to the Mykobo Base families; handlers are derived from those flows. The Monerium-EUR on-ramp is removed. See `05-integrations/mykobo.md`. +- [x] On the EUR/Base corridor, `distributeFees` is positioned **before** `nablaSwap` on offramp (USDC fees deducted pre-EUR-swap) and **after** `nablaSwap` on onramp (USDC fees deducted post-EUR→USDC swap). **PASS** — derived by `EurOfframpBase` and the catalog-backed EUR onramp flows. +- [x] On the BRL/Base corridor, `distributeFees` is positioned **before** `nablaSwap` on offramp (USDC fees deducted pre-BRL-swap) and **after** `nablaSwap` on onramp (USDC fees deducted post-BRL→USDC swap). **PASS** — derived by `BrlOfframpBase` and the catalog-backed BRL onramp flows. +- [x] EVM subsidy phases enforce USD-equivalent caps. **PASS** — the pre-swap subsidy and the post-swap actual-vs-quoted swap-output discrepancy component are each clamped to the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`) × quote output; the $1.00 floor keeps small quotes from being stuck below a workable subsidy allowance. `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` defaults to `0.05` and separately clamps the post-swap discount-derived component (no floor). Both fractions are env-overridable. Over-cap cases are intentionally recoverable retries: no transfer is submitted, and the ramp waits for operator intervention instead of moving to `failed`. +- [x] BRL on-ramp `backupApprove` allowance is bounded (no `maxUint256`). **PASS** — `blocks/phases/squid-router-swap/transactions.ts` bounds it from the phase-owned bridge amount (F-NEW-03 resolved). - [x] EVM ephemeral cleanup coverage. **PASS** — **Polygon** (`PolygonPostProcessHandler`), **Hydration** (`HydrationPostProcessHandler`), and **Base** (`BaseChainPostProcessHandler`, sweeping both BRLA and USDC) are registered and active. **AssetHub** handler is registered but a no-op stub (`shouldProcess` always returns `false`). ETH gas dust on EVM ephemerals is not swept (intentional). F-NEW-05 resolved. See `ephemeral-accounts.md` for the full cleanup architecture. -- [x] Subsidy phase handlers extend the recoverable-retry budget. **PASS** — `subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` declare `getMaxRetries(): 200`, overriding the global `MAX_RETRIES = 8` in `phase-processor.ts`. Recoverable-exhausted ramps in subsidy phases wait (no `failed` transition) until a human tops up the funding account or cancels the ramp. -- [x] `squid-router-phase-handler.ts` resolves the source network from `bridgeMeta.fromNetwork` (not from `inputCurrency`); both `squidRouterApprove` and `squidRouterSwap` use the same `getClient(network)`. -- [x] On same-chain destinations (source == destination, e.g. EUR → Base EURC), `destinationTransfer` is placed at the first nonce **immediately after** `squidRouterSwap` (no gap), cleanups are appended after it, and the handler-less backup re-swap txs are omitted — verified in `mykobo-to-evm.ts`, `alfredpay-to-evm.ts`, `avenia-to-evm-base.ts`. Prevents the "nonce too high" 0-delivery strand. -- [x] `destination-transfer-handler.ts` fails fast on a nonce gap. **PASS** — before broadcasting it compares the presigned `destinationTransfer` nonce against the live ephemeral nonce (`getTransactionCount`, `blockTag: "pending"`) and throws `UnrecoverablePhaseError` if the presigned nonce is ahead, instead of retrying until the budget exhausts. The live-nonce read is best-effort (RPC failure warns and falls through), so a transient RPC outage cannot wedge the happy path. +- [x] Subsidy block executors extend the recoverable-retry budget. **PASS** — EVM and Pendulum subsidy executors override the global phase retry budget where operator funding may need intervention. +- [x] `blocks/phases/squid-router-swap/execution.ts` resolves the source network from its own `fromNetwork` metadata; approve and swap use the same client. +- [x] Same-chain flow transaction tests place `destinationTransfer` immediately after `squidRouterSwap`, append cleanup later, and omit bridge-only backups. +- [x] `blocks/phases/destination-transfer/execution.ts` fails fast on a detectable nonce gap. **PASS** — it compares the presigned nonce against the pending live nonce and throws `UnrecoverablePhaseError` when ahead. - [x] EUR (Mykobo) and BRL (BRLA) onramps/offramps do NOT require a Pendulum ephemeral. `getRequiresPendulumEphemeralAddress` returns `false` for EURC and BRL inputs; registration skips Pendulum funding for these corridors. - [x] Active maintenance windows block `POST /v1/ramp/register`, `POST /v1/ramp/update`, and `POST /v1/ramp/start` before ramp state mutation or phase processing. -- [x] Base EVM `nablaSwap` dry-runs the exact presigned swap before broadcast. **PASS** — `nabla-swap-handler.ts` decodes the serialized transaction with `parseTransaction`, calls Base `eth_call` from the ephemeral sender using the decoded transaction fields and `blockTag: "pending"`, and only then calls `sendRawTransaction` if the call succeeds. +- [x] Base EVM `nablaSwap` dry-runs the exact presigned swap before broadcast. **PASS** — `blocks/phases/nabla-swap/execution.ts` decodes the transaction, calls Base `eth_call` with `blockTag: "pending"`, and broadcasts only after success. diff --git a/docs/security-spec/03-ramp-engine/recipient-transfers.md b/docs/security-spec/03-ramp-engine/recipient-transfers.md index a91917c0d..a3090d02b 100644 --- a/docs/security-spec/03-ramp-engine/recipient-transfers.md +++ b/docs/security-spec/03-ramp-engine/recipient-transfers.md @@ -35,8 +35,10 @@ out against another tenant's relationship. set it to `NULL` (acceptance re-checks `expired` under the row lock, so the sweep below cannot race an accept into overwriting an already-expired invite); sender listing also expires and clears pending rows past their TTL — expired rows stay visible to the sender (token `NULL`, - no re-copy) until archived — so accepted/expired invites hold no live secret at rest - (`recipient-invite.service.ts`, `recipients.controller.ts`). + no re-copy) until archived. An expired row not revisited by either path may retain its raw + value, but the server checks `expires_at` before redemption and will clear it on the next + preview, acceptance, or sender listing. Accepted invites hold no live secret at rest + (`recipient-invite.service.ts`, `recipients.controller.ts`; RISK-003). 2. **Redemption is token-bound (plan D1).** Possession of the token is the redemption key. If `invitee_email` was recorded, the redeemer's authenticated email must additionally match its canonical (trimmed, lowercased) form, else `403 INVITE_EMAIL_MISMATCH`. @@ -44,8 +46,8 @@ out against another tenant's relationship. holding the token (subject to 2). Once accepted it binds to `accepted_by_profile_id`: any *other* profile presenting the token gets `409 INVITE_ALREADY_ACCEPTED`. Revoked/expired → `410`. Expiry is 14 days (`INVITE_TTL_MS`); redemption of a *pending* invite past `expires_at` - transitions the row to `expired`; sender listing performs the same transition and excludes - expired rows. This holds under concurrency: the acceptance transaction + transitions the row to `expired`; sender listing performs the same transition and includes + expired rows without a token so the sender can see why the link stopped working. This holds under concurrency: the acceptance transaction re-reads the invitation `FOR UPDATE` and re-checks acceptance/revocation under the lock, so two profiles redeeming the same token simultaneously produce exactly one relationship (integration-tested with parallel accepts). @@ -111,8 +113,9 @@ out against another tenant's relationship. recipient payout capture remains in the recipient's widget onboarding session. 11. **Invite discounts are role-gated at creation and materialized only once, at first acceptance.** `POST /v1/recipients/invite` accepts an optional `discounts` body - (`buyBps`/`sellBps`, integers `0..300` — bounded so the advertised discount always fits - under the runtime EVM discount-subsidy cap with execution headroom; `0` means none) + (`buyBps`/`sellBps`, integers `0..configuredMaximum`, where the deployment setting + `RECIPIENT_INVITE_MAX_DISCOUNT_BPS` defaults to and can never exceed the immutable + application hard cap of `300`; `0` means none) only from profiles holding the `discount_manager` role in `profile_roles` (`403 DISCOUNT_ROLE_REQUIRED` otherwise — the role check is server-side, the dashboard's field visibility is UX only). Validated seeds are stored on @@ -151,37 +154,47 @@ out against another tenant's relationship. customer entity the acceptance linked. Sender-side KYC tracking is client-agnostic either way: list/eligibility read `provider_customers` scoped by the relationship's recipient entity + the invitation's provider/type/country. +12. **Recipient-directed payout is unsupported in this API version (RISK-004).** Recipient list and + eligibility endpoints are onboarding/advisory functionality only. Ramp registration remains + a sender self-offramp and rejects `recipientId`, `senderRecipientId`, + `recipientRelationshipId`, and `recipientPayoutReferenceId` in `additionalData` with `400` + instead of silently ignoring them. No eligibility response authorizes money movement. + Enabling recipient payout requires a separately reviewed registration schema, ownership and + eligibility enforcement, and provider-side payout-instrument resolution. -### Ramp registration vs. the recipient model — **PRESSING, TO BE DEFINED** +### Ramp registration vs. the recipient model — intentionally out of scope Ramp registration today is structurally a **self-offramp** flow, and (post ownership enforcement) payout destinations are already bound to the *sender* on two of three corridors — verified against the code: -- **Mykobo/EUR** (`evm-to-mykobo.ts`): the withdraw intent is created for the sender's own +- **Mykobo/EUR** (`MykoboOfframpPayout.register`): the withdraw intent is created for the sender's own anchor profile (email derived from the effective user); the payout IBAN lives anchor-side. Third-party payout impossible. -- **Alfredpay** (`evm-to-alfredpay.ts`): `customerId` is server-derived from the effective user; +- **Alfredpay** (`AlfredpayOfframp.register`): `customerId` is server-derived from the effective user; the client-supplied `fiatAccountId` is provider-scoped to that customer. Third-party payout impossible. -- **BRL/avenia** (`ramp.service.ts` `prepareOfframpBrlTransactions`): sender identity is +- **BRL/avenia** (`AveniaOfframpPayout.register`): sender identity is server-derived, but `pixDestination` + `receiverTaxId` are client-supplied; `receiverTaxId` defaults to the sender's own tax id and is only consistency-checked against the pix key's - owner (`validateBrlaOfframpRequest`). **Third-party payout is possible here by design** — the + owner (block-owned `validateAveniaOfframpRecipient`, including masked tax-ID comparison). + The Avenia wallet and QR/code facts are read from the provider subaccount and are never accepted + from registration input. **Third-party payout is possible here by design** — the one corridor where it is. Consequently, sender→recipient transfers cannot be expressed through the current registration API at all (except mechanically on BRL): the gap is **not a missing destination check** but a -missing concept — registration has no second principal. The pending design (plan §7 + §7.1, -still to be defined) is a **recipient-context extension**: a registration that carries the +missing concept — registration has no second principal. A future, separately reviewed design is +a **recipient-context extension**: a registration that carries the `sender_recipients` id, where the server (a) verifies the relationship belongs to the authenticated sender, (b) runs `getTransferEligibility`, and (c) resolves the payout side from the **recipient's** provider identity / verified payout reference — recipient pix key + tax id for BRL (narrowing the currently-free destination whenever a recipient context is present), an order against the recipient's alfredpay customer + fiat account, a withdraw intent under the recipient's mykobo profile. Until that lands, every dashboard "transfer" is a self-offramp of -the sender, and `GET /:id/eligibility` is UX, not a security boundary. Blocked on the §7.1 -payout-instrument decision (no code path writes `verified` payout references yet). +the sender, `GET /:id/eligibility` is UX rather than a money-movement authorization boundary, +and attempts to attach recipient context to registration are rejected. No code path writes +`verified` payout references yet. ## Threat Vectors & Mitigations @@ -205,7 +218,8 @@ payout-instrument decision (no code path writes `verified` payout references yet - **Self-granted pricing discount via invite**: a sender without the `discount_manager` role posts `discounts` directly to the API (bypassing the role-gated UI), or a discount manager posts oversized bps. The role is checked server-side against `profile_roles` and bps are - bounded (`0..1000`, integers); the seeded `fiatCurrency` comes from the validated corridor, so + bounded to integers from zero through the configured maximum, with an immutable 300-bps + application hard cap; the seeded `fiatCurrency` comes from the validated corridor, so a seed can never price a corridor the invite was not created for. An accepting profile with an existing active assignment keeps it (admin-set pricing is never clobbered by a link). - **Transfer to an unverified/restricted recipient**: the eligibility gate reports @@ -240,6 +254,8 @@ payout-instrument decision (no code path writes `verified` payout references yet - [x] Alfredpay sender self accounts remain provider-side, registration carries only the sender-owned `fiatAccountId`, and creating one does not write a `recipient_payout_references` row or satisfy invited-recipient eligibility. **PASS**. +- [x] Ramp registration rejects common recipient-context keys with `400`; eligibility cannot be + mistaken for authorization to direct a payout. **PASS**. ## Next Steps diff --git a/docs/security-spec/03-ramp-engine/state-machine.md b/docs/security-spec/03-ramp-engine/state-machine.md index 6e3b682a9..e6fae52c1 100644 --- a/docs/security-spec/03-ramp-engine/state-machine.md +++ b/docs/security-spec/03-ramp-engine/state-machine.md @@ -12,7 +12,7 @@ The phase processor is the core orchestration engine for ramp operations. It exe 6. Retries recoverable errors up to 8 times with configurable delay (default 30 seconds) 7. Transitions to `failed` on unrecoverable errors -There are 28+ phase handlers covering the full ramp lifecycle across all integration paths. +Handlers are derived from the block-flow catalog and registered before recovery workers start. The registry rejects conflicting executor classes for the same phase during assembly; only catalog-mapped corridors can create new ramps. ### Locking Mechanism @@ -30,7 +30,7 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi 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. **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`, env-overridable via `PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS` for tests) prevents handlers from hanging indefinitely. Timeouts are treated as recoverable errors. **FIXED (2026-07-08)**: the timeout previously only abandoned the execution (`Promise.race`) without stopping it — abandoned polling loops kept running forever, accumulated across retries and recovery-worker passes until the CPU pegged (production incidents Jun 26–Jul 8), and could later perform real side effects (e.g. Avenia ticket creation) concurrently with the live retry. The processor now aborts each timed-out execution via an `AbortSignal` passed to `handler.execute`, and the shared polling helpers (`waitUntilTrue*`, `checkEvmBalance*`) stop when it fires. Regression-tested in `apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts`. +7. **Phase execution MUST be time-bounded and cancellation MUST reach every active wait** — The 10-minute timeout (`MAX_EXECUTION_TIME_MS`, env-overridable via `PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS` for tests) prevents handlers from hanging indefinitely. Timeouts are treated as recoverable errors. The processor aborts each timed-out execution via an `AbortSignal` passed to `handler.execute`. Every block executor propagates that signal through polling helpers, sleeps, provider/RPC waits, and durable financial-operation claims. Before any new external side effect, the executor re-checks the signal. A transport that lacks native cancellation may finish its already-started request in the background, but `abortableCall` detaches the abandoned phase immediately so it cannot perform subsequent work; an interrupted financial operation is recorded as `unknown` and cannot be retried without reconciliation. Regression coverage includes `phase-processor.cancellation.integration.test.ts`, shared polling-helper tests, and financial-operation abort tests. 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. 9. **Error logs MUST be appended, never overwritten** — Each error is pushed to the `errorLogs` array with timestamp, phase, recoverability flag, and stack trace. 10. **Phase handlers MUST NOT directly mutate the database** — Only the processor should call `state.update()` for phase transitions. Handlers return a pending state object. @@ -43,7 +43,7 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi | **Race condition on locking** | Two API instances process the same ramp simultaneously due to non-atomic lock acquisition | **KNOWN VULNERABILITY**: No database-level atomic lock. Mitigation: in-memory lock helps for single-instance deployments; multi-instance requires `SELECT FOR UPDATE` or advisory locks | | **Stale state execution** | Handler reads stale data from DB cache, executes with wrong balances/amounts | Phase processor calls `findByPk` before each ramp processing; handlers should re-read state from DB as needed | | **Infinite retry loop** | A recoverable error keeps retrying forever | Bounded at 8 retries; after exhaustion, processing stops | -| **Phase handler timeout** | A handler hangs (e.g., waiting for an RPC response that never comes), blocking the ramp | 10-minute timeout per phase; timeout throws `RecoverablePhaseError` which triggers retry, and the abandoned execution is aborted via `AbortSignal` so it cannot keep polling or perform late side effects | +| **Phase handler timeout** | A handler hangs (e.g., waiting for an RPC response that never comes), blocking the ramp | 10-minute timeout per phase; timeout throws `RecoverablePhaseError`, propagates an `AbortSignal` through all active block waits, and forbids a new side effect after cancellation. An already-started ambiguous operation is durably marked for reconciliation. | | **Lock starvation** | Process acquires lock, crashes, lock persists for 15 minutes | Lock expiry mechanism detects stale locks; force-releases and reacquires | | **Retry counter memory leak** | `retriesMap` (in-memory `Map`) grows unbounded for many ramps | Counter is deleted on terminal state, successful phase change, or max retries reached. Long-running ramps with many retries could accumulate entries, but each entry is just an integer. | | **Phase skip attack** | Attacker manipulates DB to skip phases (e.g., jump from `initial` to `complete`) | Phase transitions are controlled by handler return values, not external input. However, if an attacker has DB access, they could modify `currentPhase` directly — no DB-level constraints prevent invalid transitions. | @@ -51,11 +51,12 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi ## Audit Checklist -- [EXISTING FINDING] **F-003**: Lock acquisition is non-atomic — `state.processingLock.locked` check and `RampState.update()` are separate operations with a race window. No `SELECT FOR UPDATE` or advisory lock. Multi-instance deployment would be vulnerable. -- [EXISTING FINDING] **F-004**: After max retries exhausted for a recoverable error, the ramp stays in its current phase (not transitioned to `failed`). Retry counter resets across processing cycles, creating an infinite soft loop. +- [ ] **F-003**: Lock acquisition is non-atomic — `state.processingLock.locked` check and `RampState.update()` are separate operations with a race window. No `SELECT FOR UPDATE` or advisory lock. Multi-instance deployment would be vulnerable. +- [ ] **F-004**: After max retries exhausted for a recoverable error, the ramp stays in its current phase (not transitioned to `failed`). Retry counter resets across processing cycles, creating an infinite soft loop. - [x] `state.update()` in the processor uses `{ fields: ["currentPhase", "phaseHistory"] }` — enforced and not bypassed - [x] Terminal states `complete` and `failed` both trigger `retriesMap.delete()` and halt recursion - [x] `MAX_EXECUTION_TIME_MS` (10 minutes) is enforced via `Promise.race` with a timeout promise, and the losing execution is aborted via `AbortSignal` (not merely abandoned) +- [x] Every catalog-registered block executor accepts the processor signal; polling helpers, explicit sleeps, provider/RPC waits, transaction receipt waits, and financial-operation claims propagate or race it. New side effects check cancellation first. - [x] `MAX_RETRIES` (8) is the hard limit — no code path bypasses this (caveat: resets across cycles per F-004) - [x] `RecoverablePhaseError.minimumWaitSeconds` is respected when provided; fallback is 30 seconds - [x] `phaseHistory` is append-only — phase transitions add to the array, never truncate it @@ -67,4 +68,4 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi - [x] `squidRouterPay` bounds both bridge-status and destination-balance polling at 80% of the processor timeout - [ ] Lock refresh/release are not owner-fenced; a surviving stale processor can still overwrite a replacement owner's lock timestamp (F-003 follow-up) - [x] Phase processor is a singleton — `PhaseProcessor.getInstance()` pattern, default export is singleton instance, no production file creates `new PhaseProcessor()` (tests instantiate the class directly) -- [EXISTING FINDING] **F-056**: `sandboxEnabled` causes `initial-phase-handler` to skip the entire state machine (transitions directly `initial` → `complete` after a 10-second sleep) — no production guard prevents this. +- [ ] **F-056**: `sandboxEnabled` causes `initial-phase-handler` to skip the entire state machine (transitions directly `initial` → `complete` after a 10-second sleep) — no production guard prevents this. diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index dfc1d18e1..2d9dd9014 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -8,7 +8,7 @@ Validation occurs at two points: 1. **`updateRamp`** — When the client submits signed transactions, `validatePresignedTxs(..., { requireComplete: false })` validates every submitted non-skipped transaction against the server-generated unsigned transaction set before the signed subset is merged into ramp state. 2. **`startRamp`** — Before execution begins, `validatePresignedTxs()` runs again with complete-set validation enabled, plus `validateAllPresignedTransactionsSigned()` confirms all expected transactions are signed. -The validation logic lives in `apps/api/src/api/services/transactions/validation.ts` and is chain-specific: separate paths for EVM (Ethereum-compatible), Substrate (Polkadot-compatible), and Stellar transactions. Additional quote-level and integration-level validation lives in `transactions/onramp/common/validation.ts` and `transactions/offramp/common/validation.ts`. +The signed-transaction validation logic lives in `apps/api/src/api/services/transactions/validation.ts` and is chain-specific: separate paths for EVM (Ethereum-compatible) and Substrate (Polkadot-compatible) transactions. Quote/account validation needed during flow registration is owned by the corresponding block, including EVM offramp source validation in `phases/blocks/core/offramp-validation.ts`. ### Presigned-Tx Partitioning, Filtering, and Deposit-QR Gating @@ -29,12 +29,12 @@ User-wallet phases: - `squidRouterNoPermitApprove` — User wallet approves Squid spender. - `squidRouterNoPermitSwap` — User wallet calls Squid swap. -**Layer 1 — `validatePresignedTxs` REJECTS presigned txs for these phases.** Any submitted presigned tx whose phase is in the user-wallet set throws `APIError(BAD_REQUEST, "Phase is broadcast by the user wallet; do not submit a presigned transaction for it. Submit only the on-chain tx hash via additionalData.")`. The previous behavior silently `continue`d past these phases, which allowed a malicious client to attach an unrelated presigned tx that would never be validated. The reject closes that surface. +**Layer 1 — `validatePresignedTxs` REJECTS presigned txs for these phases.** Any submitted presigned tx whose phase is in the user-wallet set, including AssetHub `assethubToPendulum`, throws `APIError(BAD_REQUEST, "Phase is broadcast by the user wallet; do not submit a presigned transaction for it. Submit only the on-chain tx hash via additionalData.")`. The previous behavior silently `continue`d past these phases, which allowed a malicious client to attach an unrelated presigned tx that would never be validated. The reject closes that surface. **Layer 2 — Phase handlers verify the user-reported tx hash by reading the on-chain receipt and transaction**, then comparing against the server-issued unsigned payload (`txData.to`, `txData.data`, `txData.value`, and `signer`) plus receipt status. The shared helper is `verifyUserSubmittedTxByHash` in `apps/api/src/api/services/phases/helpers/user-tx-verifier.ts`. It is invoked from: -- `squidrouter-permit-execution-handler.ts` → `waitForUserHash` — covers `squidRouterNoPermit{Approve,Swap,Transfer}` during the permit-execution phase. -- `fund-ephemeral-handler.ts` → `verifyUserSubmittedSquidHashes` — covers SELL standard EVM `squidRouterApprove` + `squidRouterSwap` at the top of `executePhase`, gated on `SELL && from!==AssetHub && !isAlfredpayToken(outputCurrency) && isNetworkEVM(from)`. This closes the historical F-041 gap (SELL squid runtime validation). `squidRouterSwapHash` is mandatory — the phase parks recoverably until it is reported. `squidRouterApproveHash` is optional: a user whose wallet already holds a sufficient router allowance never broadcasts the approve tx, so its absence must not block the ramp. When an approve hash IS reported, it is verified against the blueprint with the same rigor (receipt status, `from`, `to`, calldata, value). Skipping an approve that was actually needed is safe: the swap's `transferFrom` reverts on-chain, so the swap hash verification fails on receipt status. +- `phases/blocks/phases/alfredpay-offramp/execution.ts` → `AlfredpayOfframpExecutor.waitForUserHash` covers `squidRouterNoPermit{Approve,Swap,Transfer}` during the permit-execution phase. +- `phases/blocks/phases/fund-ephemeral/execution.ts` → `FundEphemeralExecutor.verifyUserSubmittedSourceTransactions` covers SELL EVM `squidRouterApprove` + `squidRouterSwap` and the Base-USDC `squidRouterNoPermitTransfer` before platform funding. This closes the historical F-041 gap (SELL source runtime validation). `squidRouterSwapHash` is mandatory. `squidRouterApproveHash` is optional when an existing allowance lets the user skip approval; when reported, it is verified against the blueprint with the same rigor. Skipping a required approval is safe because the swap reverts and fails receipt verification. The two layers together guarantee that the client cannot (a) sneak a malicious presigned tx through validation by labeling it with a user-wallet phase, nor (b) point the backend at an arbitrary on-chain tx hash that does not match the server-issued payload. @@ -42,53 +42,46 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p 1. **Every server-submitted presigned transaction MUST have its content validated against server-generated expected values** — Phase, network, signer, AND transaction payload (amounts, destinations, assets, method calls) must all match. Metadata-only matching (phase+network+nonce+signer) is insufficient for transactions the server may later broadcast. 2. **EVM typed data (EIP-712) MUST be validated with the same rigor as raw transactions** — Permit signatures, SquidRouter executions, and any other EIP-712 signed data must have their structured fields (spender, value, deadline, target contract) verified against expected values. -3. **Stellar payment transactions MUST validate amount, destination, and asset** — A payment operation that passes the "is a payment" type check but sends to an attacker address or sends the wrong amount is equally dangerous. -4. **Stellar account setup transactions MUST validate startingBalance, cosigner in SetOptions, and ChangeTrust asset** — Each operation in the multi-operation setup XDR has security-critical parameters beyond just "correct operation type." -5. **Substrate extrinsic content MUST be decoded and validated** — Signer-only validation is insufficient. The extrinsic method, call parameters, amounts, and destination addresses must match expected values. -6. **Skipped user-wallet phases MUST have equivalent post-submission binding** — If `validatePresignedTxs` skips a phase because the transaction is submitted by the user's wallet, the phase handler must bind the reported transaction hash back to the server-issued expected payload before advancing. -7. **`areAllTxsIncluded` is only an inclusion guard** — It may remain metadata-only (`phase + network + nonce + signer`) if each submitted non-skipped transaction is content-bound in `validatePresignedTxs` against the unsigned transaction selected with the same identity keys. -8. **No chain type or transaction format may be silently skipped during validation** — If a new chain or transaction format is added, the validator must either handle it or reject it. Silent pass-through (`return` without validation) is forbidden. -9. **Validation MUST occur before any presigned transaction is persisted or executed** — The `updateRamp` and `startRamp` flows must reject invalid transactions before merging them into ramp state. -10. **Ephemeral addresses submitted at `registerRamp` MUST be proven fresh on every supported chain of their type before transactions are built** — Address format validation is insufficient. For each ephemeral type the client submits, the server MUST query every supported chain of that type (not only the chains the specific ramp route will use) and reject the registration if any check finds non-zero nonce, non-zero free balance, or (for Stellar) an account that already exists on-chain. Checking the full supported set prevents future phase-handler additions from silently reopening the freshness gap. Fail-closed on RPC errors. Without this, the server builds presigned transactions with assumed-fresh nonces, and execution halts mid-ramp on the first chain where the assumption breaks. +3. **Substrate extrinsic content MUST be decoded and validated** — Signer-only validation is insufficient. The extrinsic method, call parameters, amounts, and destination addresses must match expected values. +4. **Skipped user-wallet phases MUST have equivalent post-submission binding** — If `validatePresignedTxs` skips a phase because the transaction is submitted by the user's wallet, the phase handler must bind the reported transaction hash back to the server-issued expected payload before advancing. +5. **`areAllTxsIncluded` is only an inclusion guard** — It may remain metadata-only (`phase + network + nonce + signer`) if each submitted non-skipped transaction is content-bound in `validatePresignedTxs` against the unsigned transaction selected with the same identity keys. +6. **No chain type or transaction format may be silently skipped during validation** — If a new chain or transaction format is added, the validator must either handle it or reject it. Silent pass-through (`return` without validation) is forbidden. +7. **Validation MUST occur before any presigned transaction is persisted or executed** — The `updateRamp` and `startRamp` flows must reject invalid transactions before merging them into ramp state. +8. **Ephemeral addresses submitted at `registerRamp` MUST be proven fresh on every chain their route signs on, before transactions are built** — Address format validation is insufficient. For each ephemeral type the client submits, the server MUST query the chains the quote's route actually signs on (`quoteToSigningNetworks`) and reject the registration if any check finds a non-fresh account. Freshness covers nonce **and** balance: Substrate `nonce === 0 && free === 0`; EVM `nonce === 0 && native balance === 0` (a nonce-0 EVM account can still hold funds). The route-derived set MUST be kept in sync with the route builders — under-listing a chain the ephemeral signs on silently reopens the freshness gap. Fail-closed on RPC errors. Without this, the server builds presigned transactions with assumed-fresh nonces, and execution halts mid-ramp on the first chain where the assumption breaks. See `02-signing-keys/ephemeral-accounts.md` invariant 7. +9. **Multi-account block preparation MUST preserve signer capabilities and nonce domains** — EVM and Substrate account metadata are supplied as typed capabilities to each phase. Nonces are allocated independently per `(network, signer)`. A transaction that consumes more than one nonce declares a positive `nonceSpan`. ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| -| **Fund redirection via Stellar payment** | Client signs a Stellar payment to an attacker address instead of the expected anchor deposit address. Current validation enforces shape, source, destination presence, positive amount, asset presence, and a single operation, but does not bind destination/amount/asset to the quote. | **OPEN (F-039)**: Validate payment destination, amount, and asset against the quote and expected anchor address. | | **EIP-712 permit exploitation** | Client submits an EIP-712 permit that authorizes an attacker's spender address for unlimited token allowance. | **MITIGATED (F-038)**: Signed typed data is deep-compared against the server-issued unsigned typed data (`domain`, `primaryType`, `types`, `message`) before signature recovery, so spender/token/value/deadline/verifyingContract substitutions are rejected. | -| **Stellar account setup manipulation** | Client omits the server cosigner in SetOptions, or sets a tiny startingBalance, or adds trust for a worthless token. Current validation enforces operation count/order and required fields but does not bind the exact cosigner, startingBalance threshold, or ChangeTrust asset to expected quote/server values. | **OPEN (F-040)**: Validate startingBalance against minimum required, verify SetOptions includes the server cosigner public key, and verify ChangeTrust asset matches the expected ramp asset. | | **Substrate extrinsic substitution** | Client submits a different Substrate extrinsic (e.g., `balances.transferAll` to an attacker) instead of the expected swap or XCM call. Current validation checks signer and method decodability, but not expected section/method/arguments. | **OPEN (F-042)**: Decode the extrinsic and validate method name, call parameters, amounts, and destination addresses. | | **Off-ramp SquidRouter bypass** | SELL-direction ramps previously skipped SquidRouter swap/approve validation entirely. Client could submit a swap routing funds to an attacker's EVM address. | **MITIGATED (F-041)**: SELL-direction `squidRouterApprove`/`squidRouterSwap` are now (a) rejected by `validatePresignedTxs` if a presigned tx is submitted for them, and (b) verified by-hash at the top of `FundEphemeralPhaseHandler.executePhase` via `verifyUserSubmittedSquidHashes` against the server-issued `to`/`data`/`value`/`signer`. The swap hash is mandatory; the approve hash is verified only when reported (pre-existing allowances make the approve tx optional — an unapproved swap fails its own on-chain receipt check). | -| **User-wallet phase presigned-tx smuggling** | Client submits an unrelated EVM/Substrate/Stellar presigned tx labeled with a user-wallet phase name (`moneriumOnrampMint`, `squidRouterApprove`/`Swap` for SELL, `squidRouterNoPermit*`). Previously `validatePresignedTxs` `continue`d on these phases, letting the tx through without content validation. | **MITIGATED**: `validatePresignedTxs` now throws `APIError(BAD_REQUEST)` for any presigned tx whose phase is in the user-wallet set. User-wallet phases are verified by on-chain hash + receipt + calldata only. | +| **User-wallet phase presigned-tx smuggling** | Client submits an unrelated EVM/Substrate presigned tx labeled with a user-wallet phase name (`moneriumOnrampMint`, `squidRouterApprove`/`Swap` for SELL, `squidRouterNoPermit*`). Previously `validatePresignedTxs` `continue`d on these phases, letting the tx through without content validation. | **MITIGATED**: `validatePresignedTxs` now throws `APIError(BAD_REQUEST)` for any presigned tx whose phase is in the user-wallet set. User-wallet phases are verified by on-chain hash + receipt + calldata only. | | **Transaction data substitution via metadata matching** | Client submits transactions with correct phase/network/nonce/signer metadata but different txData content. | **MITIGATED (F-043)**: `validatePresignedTxs` resolves the matching unsigned transaction by the same identity keys and performs content validation before `areAllTxsIncluded` is used as the final inclusion guard. | | **EVM contract target or execution-parameter substitution** | Client signs a raw EVM transaction to an attacker-controlled contract, or signs the expected transaction with gas/fee parameters too low to execute reliably. | **MITIGATED (F-050)**: Raw signed EVM transactions are recovered and compared to the server-issued unsigned `to`, `data`, `value`, and `nonce`; gas limit and fee caps must be at least the server-issued values, and contract-creation transactions are rejected. | | **New phase/format added without validation** | A developer adds a new phase and the validator silently treats it as EVM because the phase type falls through to a default. | **MITIGATED (F-047)**: `getTransactionTypeForPhase` now throws for unknown phases instead of defaulting to EVM. | -| **Non-fresh ephemeral submitted at registration** | Client submits an ephemeral address that already has on-chain history (non-zero nonce on Substrate/EVM, or an existing Stellar account). Backend builds presigned transactions assuming nonce 0; execution halts mid-ramp on the first signed broadcast after subsidies/funding have already been committed. | **MITIGATED (F-072)**: `registerRamp` invokes `validateEphemeralAccountsFresh()` after `normalizeAndValidateSigningAccounts`. For each ephemeral type the client provides, it checks every supported chain of that type (Substrate: pendulum, hydration, assethub; EVM: all configured EVM networks including Moonbeam; Stellar). Substrate: requires `nonce === 0 && free === 0`. EVM: requires `nonce === 0`. Stellar: account must not exist on Horizon. Fail-closed on RPC errors. | +| **Non-fresh ephemeral submitted at registration** | Client submits an ephemeral address that already has on-chain history — non-zero nonce, or a funded native balance on a nonce-0 account. Backend builds presigned transactions assuming a clean account; execution halts mid-ramp on the first signed broadcast after subsidies/funding have already been committed. | **MITIGATED (F-072)**: `registerRamp` invokes `validateEphemeralAccountsFresh(ephemerals, quote)` after `normalizeAndValidateSigningAccounts`. For each ephemeral type the client provides, it checks the chains the quote's route signs on (`quoteToSigningNetworks`). Substrate: `nonce === 0 && free === 0`. EVM: `nonce === 0 && native balance === 0`. Fail-closed on RPC errors. | ## Audit Checklist - [x] **F-038**: EVM typed data (`SignedTypedData` / `SignedTypedDataArray`) is bound to the server-issued unsigned typed data and the recovered signer. -- [EXISTING FINDING] **F-039**: Stellar payment validation checks shape, source, destination presence, positive amount, asset presence, and operation count, but NOT quote-bound amount, destination, or asset identity. -- [EXISTING FINDING] **F-040**: Stellar `createAccount` validation checks operation count/order and required fields, but NOT exact startingBalance threshold, expected SetOptions cosigner, or expected ChangeTrust asset. - [x] **F-041**: SELL-direction `squidRouterApprove`/`squidRouterSwap` are rejected at `validatePresignedTxs` and verified by on-chain hash + receipt + calldata via `verifyUserSubmittedSquidHashes` at the top of `FundEphemeralPhaseHandler.executePhase`. The swap hash is required; the approve hash is optional (pre-existing allowance) but content-verified whenever reported. -- [EXISTING FINDING] **F-042**: Substrate transaction validation checks signer and decodable method, but NOT expected method, parameters, amounts, or destinations. +- [ ] **F-042**: Substrate transaction validation checks signer and decodable method, but NOT expected method, parameters, amounts, or destinations. - [x] **F-043**: `areAllTxsIncluded` remains metadata-only, but content substitution is blocked earlier by identity-keyed unsigned transaction lookup plus per-format content validation. - [x] **F-047**: `getTransactionTypeForPhase` throws on unknown phases instead of defaulting to EVM. -- [x] **F-048**: Stellar payment validation requires exactly one operation. -- [x] **F-049**: `stellarCleanup` no longer falls through with only parse/signature checks; it validates transaction source and an expected cleanup operation count range. - [x] **F-050**: EVM validation checks raw transaction `to`, `data`, `value`, `nonce`, signer, chain ID, gas limit, and fee caps against the server-issued unsigned transaction; contract creation is rejected. Native-token destination transfers (where viem's `parseTransaction` returns `data: undefined`) are normalized: both sides of the calldata equality check coerce empty/undefined calldata to `"0x"` so legitimate native transfers are not rejected (`apps/api/src/api/services/transactions/validation.ts:126`). - [x] `validatePresignedTxs` is called in both `updateRamp` and `startRamp` — dual validation confirmed - [x] `validateAllPresignedTransactionsSigned` checks every expected transaction has a corresponding signed entry - [x] EVM raw transaction validation (`validateEvmTransaction`) checks `from`, `chainId`, `nonce`, `to`, `data`, `value`, gas limit, and fee caps against expected signer, chain, and server-issued unsigned payload - [x] Onramp-specific validation (`validateAveniaOnramp`, `validateMoneriumOnramp`) checks quote amounts and integration-specific fields -- [x] Offramp-specific validation (`validateOfframpQuote`, `validateBRLOfframp`, `validateStellarOfframp`) checks quote consistency +- [x] Offramp-specific validation (`validateOfframpQuote`, `validateBRLOfframp`) checks quote consistency - [x] `RAMP_START_EXPIRATION_TIME_SECONDS` enforces a time window between registration and start — prevents stale presigned transactions from being executed - [x] Default rejection for unrecognized phases — `getTransactionTypeForPhase` throws instead of defaulting to EVM (see F-047) -- [EXISTING FINDING] **F-055**: Backup presigned transactions (`backupApprove`) use unlimited `maxUint256` ERC-20 approval amount — excessive blast radius if funding key is compromised. -- [EXISTING FINDING] **F-056**: `sandboxEnabled` bypasses chainId validation in `validateEvmTransaction` and skips entire ramp flow in `initial-phase-handler` — no production guard prevents accidental activation. +- [ ] **F-055**: Backup presigned transactions (`backupApprove`) use unlimited `maxUint256` ERC-20 approval amount — excessive blast radius if funding key is compromised. +- [ ] **F-056**: `sandboxEnabled` bypasses chainId validation in `validateEvmTransaction` and skips entire ramp flow in `initial-phase-handler` — no production guard prevents accidental activation. - [x] **F-057**: `destinationTransfer` decodes native transfers and ERC-20 `transfer` calldata and verifies the recipient matches `state.destinationAddress` before broadcasting. -- [EXISTING FINDING] **F-058**: No per-presigned-transaction TTL after ramp starts — `getPresignedTransaction` performs no age check, presigned txs remain valid indefinitely through recovery retries. +- [ ] **F-058**: No per-presigned-transaction TTL after ramp starts — `getPresignedTransaction` performs no age check, presigned txs remain valid indefinitely through recovery retries. - [x] Presigned-tx partitioning via `partitionUnsignedTxs` + `filterUnsignedTxsForResponse`. **PASS** — ephemeral txs hidden from SDK response until `ephemeralPresignChecksPass` flips true. - [x] Deposit QR code (BRL onramp) gated on `ephemeralPresignChecksPass`. **PASS** — verified in `meta-state-types.ts`. - [x] Signed presigned transaction matching accepts normal signed payload mutations while still binding EVM raw transactions to the unsigned server-built `to`/`data`/`value`/`nonce` and minimum gas/fee parameters, and typed-data payloads to the unsigned typed-data content with signatures stripped for comparison. @@ -97,6 +90,6 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [x] **Typed-data full-field binding (F-038 hardening)**: `validateSignedTypedData` deep-compares the signed typed data against the server-issued unsigned typed data (`domain`, `primaryType`, `types`, `message`) before recovering the signature, so the user cannot substitute spender/token/value/deadline/nonce/verifyingContract while still producing a valid signature over a tampered struct. - [x] **Unsigned-tx lookup is identity-keyed (F-043 hardening)**: per-tx content validation now resolves the matching unsigned slot on `phase + network + nonce + signer` (same keys `areAllTxsIncluded` uses), so a presigned tx whose phase/network collide with a different unsigned slot is rejected rather than validated against the wrong reference. - [x] **Chainless EVM tx rejection**: `verifySignedEvmTransaction` rejects raw txs whose decoded `chainId` is `undefined` (pre-EIP-155 legacy txs), closing a cross-chain replay bypass that existed even when `sandboxEnabled` was false. -- [x] **Backup re-verification**: `meta.additionalTxs` must contain exactly the expected backup set, and every backup is re-run through the primary's validator (EVM signer + nonce + content; Substrate signer + call-equality via `method.toHex()`; Stellar signer + per-phase shape), so a malicious client cannot register ignored extras or backups that encode a different call or signer than the primary tx. +- [x] **Backup re-verification**: `meta.additionalTxs` must contain exactly the expected backup set, and every backup is re-run through the primary's validator (EVM signer + nonce + content; Substrate signer + call-equality via `method.toHex()`), so a malicious client cannot register ignored extras or backups that encode a different call or signer than the primary tx. - [x] **`updateRamp` subset submissions**: `validatePresignedTxs` accepts `{ requireComplete: false }` for partial submissions but still rejects extra/unknown txs and still applies full per-tx content validation; `requireComplete` defaults to `true` for `startRamp`. -- [x] **F-072**: `registerRamp` proves each submitted ephemeral fresh on every supported chain of its type before building transactions. `validateEphemeralAccountsFresh()` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`) is invoked after `normalizeAndValidateSigningAccounts`. The supported-network lists (`SUPPORTED_SUBSTRATE_NETWORKS`: pendulum, hydration, assethub; `SUPPORTED_EVM_NETWORKS`: all configured EVM networks including Moonbeam) MUST be kept in sync with any chain an ephemeral may ever sign on. Substrate `nonce === 0 && free === 0`; EVM `nonce === 0`; Stellar account must not exist. RPC errors fail closed with `SERVICE_UNAVAILABLE`. +- [x] **F-072**: `registerRamp` proves each submitted ephemeral fresh on the chains its route signs on before building transactions. `validateEphemeralAccountsFresh(ephemerals, quote)` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`) is invoked after `normalizeAndValidateSigningAccounts`. The chain set comes from `quoteToSigningNetworks(quote)` and MUST be kept in sync with the route builders whenever a route's chains change (pinned per branch by `ephemeral-freshness.test.ts`). Substrate `nonce === 0 && free === 0`; EVM `nonce === 0 && native balance === 0n`. RPC errors fail closed with `SERVICE_UNAVAILABLE`. Scoping to the route also prevents an unrelated chain's RPC outage from blocking all registrations. diff --git a/docs/security-spec/04-smart-contracts/token-relayer.md b/docs/security-spec/04-smart-contracts/token-relayer.md index f3a93a1c9..1a463aba3 100644 --- a/docs/security-spec/04-smart-contracts/token-relayer.md +++ b/docs/security-spec/04-smart-contracts/token-relayer.md @@ -2,7 +2,7 @@ ## What This Does -`TokenRelayer.sol` (Solidity ^0.8.20, ~175 lines) is a meta-transaction relayer deployed on EVM chains (Moonbeam/Polygon). It enables gasless ERC-20 token operations by combining ERC-2612 `permit` with EIP-712 signed payloads: +`TokenRelayer.sol` (Solidity ^0.8.28) is a meta-transaction relayer deployed on EVM chains. It enables gasless ERC-20 token operations by combining ERC-2612 `permit` with EIP-712 signed payloads: 1. User signs an ERC-2612 `permit` (off-chain) granting the relayer an allowance 2. User signs an EIP-712 "Payload" authorizing the relayer to execute a specific action @@ -10,29 +10,32 @@ 4. The contract calls `permit()`, `transferFrom()` (pulling tokens into the relayer), `approve()` (to destination), and forwards an arbitrary call to a fixed `destinationContract` The contract uses: -- **Nonce tracking**: `usedPayloadNonces[owner][nonce]` prevents replay -- **Execution tracking**: `executedCalls[keccak256(owner, nonce)]` marks completed executions -- **Token approval caching**: `tokenApproved[token]` → first use grants `type(uint256).max` approval to `destinationContract` -- **Deployer-only access**: `withdrawToken()` restricted to the deployer address -### Prior Security Reviews +- **Nonce tracking**: `usedPayloadNonces[owner][nonce]` prevents replay. +- **Execution-local token accounting**: pre-transfer, post-transfer, and post-destination balances prove that the signed amount was received and fully consumed without touching a pre-existing balance. +- **Exact transient approval**: the destination receives an allowance no larger than the measured receipt, and the allowance is revoked after the call. +- **Current-owner recovery**: OpenZeppelin `Ownable` restricts token/ETH recovery to the current, transferable owner rather than permanently to the deployer. +- **Executor refund accounting**: native currency returned by the destination is returned in the same transaction to `msg.sender`, which supplied `msg.value`. -Two independent security reviews have been conducted: -- `docs/token-relayer-security-review-2026-03-04.md` (first review) -- `contracts/relayer/SECURITY_AUDIT.md` (second review, more detailed) +### Prior Security Reviews -Both found overlapping but not identical issues. All findings from both reviews are incorporated below. +The point-in-time review reports were consolidated into this specification because their +unresolved findings described an older contract version. Their findings remain in Git history; +all lasting threats, fixes, and deployment caveats are incorporated below. -> **Note (verified 2026-04-02):** All findings from both reviews have been **fixed** in the current contract (`TokenRelayer.sol`, pragma ^0.8.28). The contract now uses OpenZeppelin `Ownable`, `ReentrancyGuard`, `EIP712`, `ECDSA`, and `SafeERC20`. The status column below reflects the verified current state. The audit checklist items remain as verification steps to confirm fixes are complete and correct. +> **Status note:** The findings from the two 2026-04-02 reviews were fixed in the then-current deployment. The post-#1232 review added execution-local token/native balance accounting and a codeless-destination guard to the source. Those later changes require new deployments and address-registry updates on every supported chain; source conformance MUST NOT be described as production remediation until that rollout is verified (RISK-007). ## Security Invariants -1. **Each (owner, nonce) pair MUST be usable exactly once** — `usedPayloadNonces[owner][nonce]` is set to `true` before any external call (line 69). Replay MUST be impossible. +1. **Each (owner, nonce) pair MUST be usable exactly once** — `usedPayloadNonces[owner][nonce]` is set to `true` before any external call. Replay MUST be impossible. 2. **Signature verification MUST recover the correct signer** — The EIP-712 digest must be correctly constructed from the domain separator and struct hash. The recovered address must match the `owner` parameter. -3. **The `permit` and the payload MUST be independently verified** — The ERC-2612 permit is verified by the token contract. The EIP-712 payload is verified by the relayer's `_recoverSigner`. Both must succeed. -4. **Only the deployer MAY withdraw tokens** — `withdrawToken()` uses `require(msg.sender == deployer)`. +3. **Token spending authority and payload authority MUST be independently verified** — The token contract verifies the ERC-2612 permit; if that permit was already consumed, a sufficient existing allowance is required. Separately, the relayer computes the EIP-712 payload digest and requires `ECDSA.recover(...) == owner`. +4. **Only the current owner MAY withdraw tokens or native currency** — `withdrawToken()` and `withdrawETH()` use OpenZeppelin `onlyOwner`. Ownership is transferable; the deployer is only the initial owner. 5. **The forwarded call MUST target the immutable `destinationContract`** — The relayer always calls the same destination, set at construction time. -6. **Token transfers MUST match the signed amounts** — `transferFrom` pulls exactly `value` tokens from the owner into the relayer. The same `value` is available for the forwarded call. +6. **A successful execution MUST be isolated from every other token balance** — The relayer measures its token balance around `safeTransferFrom` and requires the receipt to equal signed `value`. It approves only the measured receipt. After the destination call and allowance revocation, the balance MUST equal its pre-execution baseline. A fee-on-transfer/rebasing receipt mismatch or partial destination consumption therefore reverts the whole transaction, including nonce use and token movement. +7. **The signed payload authorizes exact arbitrary calldata to the immutable destination** — The EIP-712 digest binds `keccak256(payloadData)`, token, value, ETH value, owner, nonce, deadline, relayer address, chain ID, and immutable destination address. The relayer does not interpret or allowlist destination selectors. Users and transaction construction MUST treat the payload as authorization for those exact bytes, not as proof of a higher-level business outcome beyond the enforced token-balance postcondition. +8. **Token shortfalls MUST NOT be automatically subsidized without a separate bounded design** — Current behavior is fail-closed through `TokenReceiptMismatch`. Any future platform-funded top-up requires a separately accounted funding source, a signed or normative policy, and an immutable per-call cap; it MUST NOT draw from unrelated relayer balances. +9. **Destination refunds MUST return to the executor** — The execution snapshots native balance excluding `msg.value` and sends any post-call excess to `msg.sender`. A failed refund reverts the entire execution. ## Threat Vectors & Mitigations @@ -42,9 +45,12 @@ These incorporate all findings from both prior security reviews: |---|---|---|---| | **C-1** | 🔴 Critical | **Reentrancy in `execute()`** — `executedCalls` is set AFTER all external calls (permit, transferFrom, approve, destinationContract.call). If `destinationContract` is malicious, it can reenter. Nonce prevents same-nonce replay but not cross-state reentrancy. | ✅ **Fixed** — `ReentrancyGuard` added (`nonReentrant` on `execute()`), CEI pattern followed (`usedPayloadNonces` set before external calls at line 106), `executedCalls` mapping removed. | | **C-2** | 🔴 Critical | **Signature malleability** — `ecrecover` in `_recoverSigner` doesn't validate that `s` is in the lower half of the secp256k1 curve. Malleable signatures enable front-running/griefing. | ✅ **Fixed** — Uses `ECDSA.recover()` from OpenZeppelin (line 100), which enforces low-s and rejects `address(0)`. | -| **H-1** | 🟠 High | **Unlimited token approval** — First use of any token grants `type(uint256).max` approval to `destinationContract`. If destination is upgradeable/compromised, all token types held by relayer can be drained. | ✅ **Fixed** — Exact approval via `forceApprove(destinationContract, params.value)` before call (line 121), then revoked to 0 after call (line 127). | -| **H-2** | 🟠 High | **Destination mismatch** — The signed `destination` field in the EIP-712 struct is never validated against the actual `destinationContract`. User may believe they're signing for a different contract. | ✅ **Fixed** — `_computeDigest` hardcodes `destinationContract` as the destination in the struct hash (line 145), so the signed destination is always the contract's immutable `destinationContract`. | -| **M-1** | 🟡 Medium | **No ETH recovery** — `execute()` is `payable` but no `receive()`/`fallback()` or ETH withdrawal exists. Trapped ETH is permanently lost. | ✅ **Fixed** — `receive() external payable` added (line 75), `withdrawETH()` function added (line 208) with `onlyOwner` and event. | +| **H-1** | 🟠 High | **Unlimited token approval** — First use of any token grants `type(uint256).max` approval to `destinationContract`. If destination is upgradeable/compromised, all token types held by relayer can be drained. | ✅ **Fixed** — Exact approval uses the measured `received` amount before the call, then is revoked to zero. | +| **H-2** | 🟠 High | **Destination mismatch** — The signed `destination` field in the EIP-712 struct is never validated against the actual `destinationContract`. User may believe they're signing for a different contract. | ✅ **Fixed** — `_computeDigest` hardcodes `destinationContract` as the destination in the struct hash, so the signed destination is always the contract's immutable `destinationContract`. | +| **H-3** | 🟠 High | **Nominal amount can consume a pre-existing relayer balance** — A fee-on-transfer token can credit less than signed `value` while the destination receives an allowance for the nominal amount. | ✅ **Fixed in source; deployment pending** — `execute` measures actual receipt, requires `received == value`, approves only `received`, and requires the post-call balance to return exactly to its pre-execution baseline. No automatic subsidy is performed. | +| **H-4** | 🟠 High | **Successful partial consumption strands signer funds** — EVM-level destination success previously consumed the nonce even if the destination used only part of the execution's token contribution. | ✅ **Fixed in source; deployment pending** — the post-call token-balance equality check makes partial consumption revert atomically. Successful executions log requested, received, and consumed amounts. | +| **M-1** | 🟡 Medium | **No ETH recovery or execution attribution** — `execute()` is payable and the destination can return unused native currency. | ✅ **Fixed in source; deployment pending** — `receive()` accepts destination refunds, execution returns its native balance delta to the executor that supplied `msg.value`, and only unrelated/pre-existing native currency remains owner-recoverable. | +| **M-4** | 🟡 Medium | **Codeless immutable destination appears successful** — A low-level call to an EOA returns success while executing no payload and can strand all transferred tokens. | ✅ **Fixed in source; deployment pending** — construction rejects zero or codeless destinations; execution also refuses to forward if code is no longer present. | | **M-2** | 🟡 Medium | **Permit front-running** — Attacker extracts permit signature from mempool and calls `permit()` directly, causing the relayer's tx to revert. | ✅ **Fixed** — Permit wrapped in try-catch in `_executePermitAndTransfer()` (lines 172-180). Falls back to checking existing allowance. | | **M-3** | 🟡 Medium | **Test ABI mismatch** — Test file missing `payloadValue` field in struct, potentially masking bugs. | ✅ **Fixed** — Both test files (`relayer-execution.ts`, `relayer-execution-squid.ts`) include `payloadValue` in their type definitions. | | **L-1** | 🔵 Low | **Redundant `executedCalls` mapping** — Duplicates `usedPayloadNonces` information. Wastes ~20k gas per execution. | ✅ **Fixed** — `executedCalls` removed. `isExecutionCompleted()` now queries `usedPayloadNonces` (line 215-216). | @@ -61,14 +67,16 @@ These incorporate all findings from both prior security reviews: - [x] C-2: Uses `ECDSA.recover()` from OpenZeppelin (line 100) — validates `s` value and rejects `address(0)` - [x] Contract compiles successfully with all OpenZeppelin imports resolved (verify with `bun compile:contracts:relayer`). **PASS** — compilation verified. -### High (all fixed — verify correctness) +### High -- [x] H-1: Exact approval via `forceApprove(destinationContract, params.value)` (line 121), revoked to 0 after call (line 127) +- [x] H-1: Exact approval uses the measured receipt and is revoked to zero after the call. - [x] H-2: `_computeDigest` hardcodes `destinationContract` as destination in struct hash (line 145) — signed destination always matches +- [x] H-3/H-4: source measures token receipt and destination consumption against an execution-local balance baseline; fee-on-transfer shortfalls and residuals revert without consuming the nonce. +- [ ] Deploy the hardened bytecode on every supported chain, update the configured relayer-address registry, verify bytecode, and retire the previous deployments before treating H-3/H-4 as remediated in production. -### Medium (all fixed — verify correctness) +### Medium -- [x] M-1: `receive() external payable` (line 75) + `withdrawETH()` (line 208) with `onlyOwner` +- [x] M-1: destination native refunds are returned to the executor; `receive()` plus owner withdrawal remains the recovery path for unrelated native transfers. - [x] M-2: Permit wrapped in try-catch in `_executePermitAndTransfer()` (lines 172-180), falls back to allowance check - [x] M-3: Both test files include `payloadValue` in type definitions @@ -81,9 +89,9 @@ These incorporate all findings from both prior security reviews: ### General -- [PARTIAL] All OpenZeppelin dependencies are pinned to specific versions (not floating). **PARTIAL** — uses caret range `^5.2.0` instead of exact pin; allows minor/patch updates which could introduce changes. -- [x] Contract constructor verifies `destinationContract` is not the zero address (line 70) -- [x] Owner set via `Ownable(msg.sender)` in constructor (line 67) +- [x] OpenZeppelin contracts are pinned to exact version `5.6.1` in both the relayer package and lockfile. **PASS** +- [x] Contract constructor verifies `destinationContract` is neither zero nor codeless; forwarding also fails if code is no longer present. +- [x] Owner set via `Ownable(msg.sender)` in constructor and all recovery authority follows current `owner()`, including after ownership transfer. - [x] Nonce check (`usedPayloadNonces`) happens before any external call (line 86) - [x] No `selfdestruct` or `delegatecall` to untrusted addresses. **PASS** — verified: neither pattern present in contract source. -- [N/A] Verify deployed contract bytecode matches source (if already on mainnet). **N/A** — requires on-chain verification, not a source code audit item. +- [ ] Verify deployed contract bytecode matches source (if already on mainnet). **N/A** — requires on-chain verification, not a source code audit item. diff --git a/docs/security-spec/05-integrations/_template.md b/docs/security-spec/05-integrations/_template.md index d8c8b661f..183dab5d8 100644 --- a/docs/security-spec/05-integrations/_template.md +++ b/docs/security-spec/05-integrations/_template.md @@ -20,7 +20,7 @@ Describe: **Provider type:** {on-ramp | off-ramp | both} **Fiat currencies:** {BRL, EUR, ARS, etc.} -**Chains involved:** {Moonbeam, Polygon, Stellar, etc.} +**Chains involved:** {Moonbeam, Polygon, Base, etc.} **Phase handlers:** {list the phase handler files that interact with this provider} **API auth method:** {API key, OAuth, HMAC signature, etc.} diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 99c40bdeb..c1c437fae 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -17,11 +17,11 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **Stuck-submission recovery (PENDING):** Alfredpay reports a created-but-never-finalized (or invalid-data) submission as `PENDING`. It is not a rejection: status sync maps it to the canonical `pending` state (resumable in the dashboard), and `submitKybInformation` probes the last submission first — when it is `PENDING`/`CREATED`, the controller updates it in place via `PUT …/customers/kyb` (`updateKybInformation`) and returns the existing `submissionId` instead of POSTing a new submission, which Alfredpay refuses while one is pending (error `111405 "Customer KYB already exists"`; the controller also recovers from that POST error, but rechecks that Alfredpay still reports `PENDING`/`CREATED` before updating). Submission-id resolution (`resolveAlfredpayKybSubmissionId`) reconciles the persisted `kyc_cases.providerCaseId` with IDs from the last-submission endpoint and KYB details: a persisted ID is preferred only when Alfredpay still returns it, otherwise the first provider ID wins; the persisted ID is used alone whenever discovery yields no IDs (calls failing or answering empty — an empty last-submission response is not authoritative in sandbox). The latest observed Alfredpay `submissionId` is persisted by the submit, status, redirect-link, and retry paths so recovery survives the wizard closing. -**Phase handlers:** -- `alfredpay-onramp-mint-handler.ts` — On-ramp: waits for Alfredpay payment confirmation and credits the Alfredpay on-chain token (`ALFREDPAY_EVM_TOKEN`) to the ephemeral on Polygon. -- `alfredpay-offramp-transfer-handler.ts` — Off-ramp: transfers the Alfredpay on-chain token to Alfredpay's settlement address for fiat payout. Recovers from expired upstream quotes by re-quoting at execute time (see [Quote Lifecycle — AlfredPay Provider Quote TTL](../03-ramp-engine/quote-lifecycle.md)). -- `subsidize-pre-swap-handler.ts` — Subsidy: tops up the ephemeral's Alfredpay on-chain token balance on Polygon to the discount-engine's `targetOutputAmountRaw` before the next stage. Uses `getEvmSubsidyConfig` to pick the Alfredpay-specific funding account and token (`ALFREDPAY_EVM_TOKEN`). -- `squid-router-phase-handler.ts` — Cross-chain bridge for non-Polygon EVM destinations. Same-chain same-token routes short-circuit via `isSameChainSameTokenPassthrough` (no SquidRouter call when source and destination are both Polygon `ALFREDPAY_EVM_TOKEN`). +**Block phases:** +- `phases/blocks/phases/alfredpay-mint/` — On-ramp simulation, registration, start lifecycle, transaction preparation, and execution. The executor waits for Alfredpay payment settlement in the ephemeral's Polygon balance. +- `phases/blocks/phases/alfredpay-offramp/` — Off-ramp simulation, registration, transaction preparation, user-hash validation, provider transfer, and expired-provider-quote recovery. +- `phases/blocks/phases/subsidize-pre/` — Tops up the ephemeral's Alfredpay on-chain token balance on Polygon to the phase-owned subsidy target before routing. +- `phases/blocks/phases/squid-router-swap/` — Cross-chain, same-chain swap, and explicit same-token passthrough blocks selected by the Alfredpay flow definitions. **On-ramp flow:** 1. Quote stage emits `ctx.alfredpayOnramp` with provider `quoteId` (30s upstream TTL) and `ctx.subsidy` with the discount-engine target. @@ -35,9 +35,9 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations For routed Alfredpay onramps (any non-passthrough output), the final quote output is the Squid destination-token amount. `quote.outputAmount` MUST be stored with the destination token's decimals, and `evmToEvm.outputAmountRaw` MUST preserve Squid's destination-token raw output. The Polygon-minted Alfredpay token remains the Squid source amount; the spec must not treat Polygon source-token decimals as final settlement precision. **Off-ramp flow:** -1. Quote stage emits `ctx.alfredpayOfframp` with provider `quoteId` and the AlfredPay fiat order is created during `prepareOfframpEvmToAlfredpay...` (see `transactions/offramp/routes/evm-to-alfredpay.ts:229`). At prep time, if the quote carries `metadata.alfredpayOfframp`, the service calls `refreshAlfredpayOfframpQuoteIfMatching` to swap in a fresh provider `quoteId` (strict: `toAmount` and `fee` must be identical; drift throws an `INTERNAL_SERVER_ERROR`). The order is authoritative from prep time; `processAlfredpayOfframpStart` only re-validates state before phase execution. +1. The catalog `AlfredpayOfframp` block stores provider quote facts under `metadata.blocks.alfredpayOfframp` and returns the provider expiration as the Vortex quote TTL. Its registration hook validates `fiatAccountId` and wallet address, resolves the authenticated KYC-approved Alfredpay customer, refreshes the provider quote with exact `toAmount` and fee equality, updates only that block's `quoteId`/expiration, and creates the order transactionally. Drift hard-fails registration. 2. `squidRouterPermitExecute` or `squidRouterNoPermitTransfer/Approve/Swap` phase: executes the user-signed permit (or the no-permit equivalent) and lands the Alfredpay on-chain token on Polygon. -3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps — the direct-transfer skip in `FinalSettlementSubsidyHandler` explicitly excludes `SELL && isAlfredpayToken(outputCurrency)` so the subsidy top-up is never bypassed. +3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps because `AlfredpayOfframp` declares it between funding and provider transfer for every source variant. 4. `alfredpayOfframpTransfer` phase: transfers the Alfredpay on-chain token to Alfredpay's settlement address for fiat payout. If Alfredpay rejects the stored `quoteId` as expired, the handler requests a fresh provider quote at execute time and re-attempts (`alfredpayOfframpTransferFallback` phase records the re-attempt). 5. `polygonCleanupAxlUsdc` → `complete`. @@ -54,16 +54,16 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 5. **Subsidy MUST run before the Alfredpay-bound transfer** — `subsidizePreSwap` (onramp) and the Squid-side stages plus `alfredpayOfframpTransfer` (offramp) MUST be ordered so the ephemeral holds the exact subsidized amount before the final transfer step. 6. **Alfredpay API responses MUST be validated** — Status codes, transaction IDs, and amounts confirmed before phase advancement. 7. **Alfredpay interactions MUST be retryable** — Transient failures should use `RecoverablePhaseError`. -8. **Provider quote refresh MUST be strict** — `refreshAlfredpayOnrampQuoteIfMatching` re-binds the provider `quoteId` only when the new provider response is byte-identical on `toAmount` and `fee`. Any drift forces the route into the bounded fallback path. -9. **Off-ramp expired-quote recovery MUST re-create the AlfredPay order, not the Vortex quote** — `alfredpay-offramp-transfer-handler.ts` re-quotes against the provider and re-issues `createOfframp` against the same Vortex quote; it MUST NOT mutate the Vortex `QuoteTicket`. +8. **Provider quote refresh MUST be strict** — At on-ramp start, immediately before order creation, `AlfredpayMint.start` re-binds the provider `quoteId` only when the new provider response is byte-identical on `toAmount` and `fee`. Any drift keeps the original quote ID and preserves the bounded fallback path. +9. **Off-ramp expired-quote recovery MUST re-create the AlfredPay order, not the Vortex quote** — `phases/blocks/phases/alfredpay-offramp/execution.ts` re-quotes against the provider and re-issues `createOfframp` against the same Vortex quote; it MUST NOT mutate the Vortex `QuoteTicket`. 10. **KYB and KYC status mapping MUST be branched by `AlfredpayCustomerType`** — Business customers use `mapKybStatus`; individuals use `mapKycStatus`. Treating one as the other would allow incomplete due-diligence states to pass as `Success`. -11. **Polygon passthrough MUST preserve amount integrity** — The same-chain same-token shortcut in `squid-router-phase-handler.ts` MUST round down (`toFixed(0, 0)`) and MUST use `evmToEvm.inputAmountRaw` as the source-of-truth amount (matching the subsidy target). -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. +11. **Polygon passthrough MUST preserve amount integrity** — `AlfredpayOnrampDirect` selects `SquidRouterPassthrough` only for the same-chain same-token case. The passthrough MUST round down (`toFixed(0, 0)`) and use its phase-owned input amount as the source of truth. +12. **The Polygon passthrough MUST be gated on the output token, not on the destination network alone** — Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) directly on Polygon, so `flows/alfredpay-onramp-direct.ts` composes `SquidRouterPassthrough` only when the requested output token is `ALFREDPAY_EVM_TOKEN`. Any other Polygon output composes `SameChainSquidRouterSwap`; gating on network alone would mis-deliver USDT instead of the requested asset. +13. **Offramp quote refresh at prep time MUST be strict and transactional** — `AlfredpayOfframp.register` re-fetches a provider quote and compares `toAmount` and `fee`. Any drift throws `INTERNAL_SERVER_ERROR`, aborting registration. Only the phase metadata `quoteId` and `expirationDate` are updated in the registration transaction, so partial refresh/order state cannot persist. +14. **`finalSettlementSubsidy` MUST NOT be skipped for Alfredpay offramps** — `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. This ensures the ephemeral on Polygon is topped up before provider settlement. 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. +16. **Alfredpay ramp registration MUST bind to a completed KYC/KYB customer** — `AlfredpayMint.register` and `AlfredpayOfframp.register` MUST reject customer records whose Alfredpay status is not `Success`. On-ramp registration stores only the verified customer ID as phase-owned facts; quote refresh, order creation, and payment instructions remain at start time. 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 on-ramp and off-ramp flow registration hooks, on-ramp start-time quote refresh, and off-ramp transfer recovery path all resolve `alfredPayId` via the strict, KYC-gated `resolveAlfredpayCustomerId(fiatCurrency, effectiveUserId)`. Quote creation is anonymous-eligible: the quote blocks 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. 19. **Argentina business onboarding MUST fail before provider access** — Alfredpay does not support AR company KYB. Client hosts using the shared Alfredpay machine MUST pass the account type in machine input; `AR + business` transitions directly to local failure without status, customer creation, or redirect requests. Account selectors MUST not offer the AR business combination. 20. **A provider `PENDING` submission MUST map to canonical `pending`, never to a decided state** — `PENDING` means the submission was never finalized or its data was invalid; treating it as `in_review`/`approved` would let un-reviewed due diligence advance, and treating it as `rejected` would dead-end a recoverable flow. Re-submission against a `PENDING`/`CREATED` submission MUST update it in place (`updateKybInformation`) rather than create a new one. @@ -71,6 +71,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 22. **Uploaded filenames MUST be sanitized to ASCII before reaching Alfredpay** — `AlfredpayApiService` rewrites the multipart filename of every KYC/KYB upload to `[A-Za-z0-9._-]` (accents transliterated, everything else replaced) rather than forwarding the name the user's file happened to carry. Alfredpay's relate-person endpoint answers a non-ASCII filename with a bare 5xx `111301 UNKNOWN_ERROR` that names no field, which stranded MX company onboardings at the representative's ID upload. The trigger is invisible: macOS separates the time from AM/PM with U+202F, so `Screenshot 2026-07-09 at 12.23.56 PM.png` is rejected while the same name retyped with an ordinary space is accepted, and accented filenames fail for the same reason — the provider stores every upload under a generated `{uuid}.{ext}`, so the submitted name is discarded on arrival and nothing is lost by rewriting it. This also keeps user-controlled text out of a downstream `Content-Disposition` header. The sanitizer MUST copy the bytes into a new `File`: under Bun, `new File([file], name)`, `new Blob([file])` and `FormData.append(field, file, name)` all alias or ignore their way back to the original name, so the guarantee is asserted on the value that reaches the wire (`alfredpayApiService.test.ts`), not on the helper alone. 23. **Dashboard Alfredpay BUY confirmation MUST only start processing, never assert settlement** — The dashboard renders the server-issued MXN/USD/COP/ARS payment instructions after registration and keeps the ramp unstarted. `I have made the payment` may call `/ramp/start`, but token crediting still depends on Alfredpay's independently verified payment status; the client confirmation is not proof of payment. +24. **Reported Alfredpay usage MUST be user-scoped and provider-leg denominated** — `POST /v1/limits` derives the effective user from authentication and counts only that user's ramps whose `complete` phase-history timestamp falls in the current UTC calendar month. Routed BUY usage is the Alfredpay fiat input; routed SELL usage is `metadata.blocks.alfredpayOfframp.inputAmountDecimal` in `ALFREDPAY_EVM_TOKEN`, not the public source-token amount. The aggregate is cached in memory for 60 seconds. Alfredpay does not document whether its cumulative quota resets by calendar month or uses a rolling window, so the calendar-month period is an explicit Vortex assumption rather than provider-confirmed semantics. ## Threat Vectors & Mitigations @@ -83,11 +84,11 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu | **Alfredpay API compromise** | Attacker manipulates Alfredpay API responses | Validate response amounts against quote; HTTPS enforcement; monitor for discrepancies | | **Multi-country regulatory complexity** | Different countries have different KYC/AML requirements | Country-specific validation at Alfredpay level; KYB vs KYC mapping branched by `AlfredpayCustomerType` | | **Provider quote-quote-fall fallback abuse** | Attacker times provider quote drift between Vortex quote and ramp start to maximise the discount-engine fallback subsidy | Provider quote TTL is ~30s; `refreshAlfredpayOnrampQuoteIfMatching` only re-binds on byte-identical `toAmount`/`fee`; otherwise the fallback path is bounded by `maxSubsidy × expectedOutput` and only fires when `targetDiscount > 0` | -| **Expired provider quote on offramp transfer** | Provider rejects the stored `quoteId` at transfer time, blocking settlement | `alfredpay-offramp-transfer-handler.ts` re-quotes at execute time and emits `alfredpayOfframpTransferFallback`; the Vortex `QuoteTicket` is untouched | +| **Expired provider quote on offramp transfer** | Provider rejects the stored `quoteId` at transfer time, blocking settlement | `phases/blocks/phases/alfredpay-offramp/execution.ts` re-quotes at execute time and emits `alfredpayOfframpTransferFallback`; the Vortex `QuoteTicket` is untouched | | **Offramp quote drift at prep time** | Market moves between quote creation and ramp registration; the refreshed Alfredpay offramp quote has different `toAmount`/`fee` | `refreshAlfredpayOfframpQuoteIfMatching` compares `toAmount` and `fee` exactly; any drift throws `INTERNAL_SERVER_ERROR`, aborting registration. The user must re-quote. | -| **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 | +| **Alfredpay offramp skipping subsidy** | An Alfredpay offramp reaches provider transfer without `finalSettlementSubsidy`, under-funding the settlement | The `AlfredpayOfframp` block declares subsidy before transfer for every source variant; flow tests pin the sequence | | **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. | +| **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the flow skips the swap on destination-network alone and transfers the minted USDT | `AlfredpayOnrampDirect` selects passthrough only for `ALFREDPAY_EVM_TOKEN`; non-USDT Polygon outputs compose `SameChainSquidRouterSwap` | | **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. | @@ -98,25 +99,25 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] `validateResultCountry` middleware applied to all Alfredpay-related endpoints. **PASS** — middleware applied in route definitions. - [x] Country validation uses `Object.values(AlfredPayCountry).includes()` — not string matching. **PASS** — enum-based validation confirmed. - [x] `alfredpayOnrampMint` handler verifies Alfredpay payment confirmation before crediting. **PASS** — handler waits for Alfredpay confirmation. -- [x] `alfredpayOfframpTransfer` handler sends the correct amount (from stored quote, post-subsidy) and recovers expired provider quotes via re-quote + `createOfframp`. **PASS** — `alfredpay-offramp-transfer-handler.ts:127-136`. +- [x] `alfredpayOfframpTransfer` sends the correct amount (from stored quote, post-subsidy) and recovers expired provider quotes via re-quote + `createOfframp`. **PASS** — `phases/blocks/phases/alfredpay-offramp/execution.ts`. - [x] SquidRouter permit execution validates the permit data before executing. **PASS** — permit data validated via `isSignedTypedDataArray`. -- [x] All Alfredpay phase handlers use `RecoverablePhaseError` for transient failures. **PASS** — verified in all handlers. +- [x] Alfredpay block executors use `RecoverablePhaseError` for transient failures. **PASS** — verified in the block execution modules. - [x] HTTPS enforced for Alfredpay API calls. **PASS** — base URL uses `https://`. - [x] No Alfredpay credentials or user payment details in logs. **PASS** — no credential leakage observed in log statements. -- [FAIL] Timeout configured for Alfredpay API calls. **FAIL F-014** — no explicit HTTP client timeout configured; relies on default system timeouts. -- [x] `subsidizePreSwap` runs before `squidRouterSwap` on the onramp flow and before `alfredpayOfframpTransfer` on the offramp flow. **PASS** — phase ordering confirmed in `fund-ephemeral-handler.ts` transition and offramp route definition. -- [x] Onramp fallback emits `alfredOnrampMintFallback` when the discount engine's `expectedOutput` supersedes the provider's `finalOutput`. **PASS** — `transactions/onramp/routes/alfredpay-to-evm.ts:269`. Phase is registered as an EVM phase in `transactions/validation.ts:249`. +- [ ] Timeout configured for Alfredpay API calls. **FAIL F-014** — no explicit HTTP client timeout configured; relies on default system timeouts. +- [x] `subsidizePreSwap` runs before `squidRouterSwap` on the onramp flow, and `finalSettlementSubsidy` runs before `alfredpayOfframpTransfer` on the offramp flow. **PASS** — flow tests pin both sequences. +- [x] Onramp fallback emits `alfredOnrampMintFallback` from the `AlfredpayMint` block using the phase-owned mint output. **PASS** — `phases/blocks/phases/alfredpay-mint/transactions.ts`. Phase is registered as an EVM phase in `transactions/validation.ts`. - [x] Offramp fallback emits `alfredpayOfframpTransferFallback` for expired-quote recovery; phase is registered as an EVM phase in `transactions/validation.ts:250`. **PASS**. - [x] KYB vs KYC status mapping is branched by `AlfredpayCustomerType.BUSINESS` in `alfredpay.controller.ts`. **PASS** — `mapKybStatus` for business, `mapKycStatus` for individual. -- [x] Polygon same-chain same-token passthrough uses `isSameChainSameTokenPassthrough` shortcut, rounds down (`toFixed(0, 0)`), and uses `evmToEvm.inputAmountRaw` as the source amount. **PASS** — `squid-router-phase-handler.ts` + `squidrouter/index.ts` finalize. -- [x] Alfredpay Polygon onramp swap short-circuit is gated on `quote.outputCurrency === ALFREDPAY_EVM_TOKEN`, not on `quote.metadata.request.to === Networks.Polygon` alone. **PASS** — `squid-router-phase-handler.ts` checks both; `onramp-polygon-to-evm-alfredpay.ts` only sets `skipRouteCalculation` for the same-token case. Prevents a non-USDT Polygon output (e.g. USDC) from being delivered as the minted USDT. -- [x] `refreshAlfredpayOnrampQuoteIfMatching` only re-binds the provider `quoteId` when `toAmount` and `fee` match byte-identically. **PASS** — `ramp.service.ts:1480-1491`. -- [x] `refreshAlfredpayOfframpQuoteIfMatching` re-fetches a fresh provider quote at prep time, compares `toAmount` and `fee` exactly, and throws on drift. Quote metadata update (new `quoteId` + `expirationDate`) runs within the registration transaction. **PASS** — `ramp.service.ts`. -- [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**. +- [x] Polygon same-chain same-token passthrough rounds down (`toFixed(0, 0)`) and uses the phase-owned input amount. **PASS** — `phases/blocks/phases/squid-router-swap/` flow and transaction tests. +- [x] Alfredpay Polygon onramp passthrough is gated on `outputCurrency === ALFREDPAY_EVM_TOKEN`, not on Polygon alone. **PASS** — `phases/blocks/flows/alfredpay-onramp-direct.ts`; other Polygon outputs compose `SameChainSquidRouterSwap`. +- [x] `AlfredpayMint.start` only re-binds the provider `quoteId` when `toAmount` and `fee` match byte-identically, creates the order once, and returns/persists the provider payment instructions through the generic flow lifecycle. **PASS** — block lifecycle tests. +- [x] `AlfredpayOfframp.register` re-fetches a fresh provider quote, compares `toAmount` and `fee` exactly, updates only its own metadata identity/expiry, and throws on drift before order creation. **PASS** — block registration tests. +- [x] `AlfredpayOfframp` always includes `finalSettlementSubsidy` before provider transfer. **PASS** — explicit phase list and flow tests. +- [x] AlfredPay offramp order is created by the block phase registration hook; `AlfredpayOfframp.start` retains the defensive validation-only no-op and is idempotent after registration. **PASS** — block lifecycle tests. +- [x] Routed Alfredpay onramp quote output precision follows destination token decimals; direct Polygon same-token passthrough remains at minted-token precision. **PASS** — Alfredpay flow and transaction tests. +- [x] Alfredpay onramp registration rejects missing customer context before customer lookup and requires a `Success` Alfredpay customer status. **PASS** — `phases/blocks/phases/alfredpay-mint/registration.ts`. +- [x] Alfredpay quote simulation resolves tracking-only `metadata.customerId` via `resolveAlfredpayQuoteCustomerId` (real id for KYC-completed users, `"anonymous"` sentinel otherwise); provider *orders* always resolve via strict `resolveAlfredpayCustomerId`. **PASS**. - [x] Fiat-account routes resolve the authenticated effective user's Alfredpay customer; the dashboard lists/adds sender self accounts without persisting raw bank-account fields locally, and registration carries only the selected provider `fiatAccountId`. **PASS**. - [x] Dashboard Alfredpay onramps register with an editable EVM `destinationAddress`, require no connected wallet, preserve provider instructions across reload, and do not call `/ramp/start` before explicit payment confirmation. Provider-side payment verification remains authoritative. **PASS**. @@ -135,8 +136,18 @@ Alfredpay identity moved from `alfredpay_customers` (keyed by `user_id`) to `IN_REVIEW`, `COMPLETED`, and `FAILED` map to `in_review`, `approved`, and `rejected` respectively. - All controller lookups go through `findAlfredpayCustomer(userId, country[, type])`, which - resolves the caller's `customer_entity` first and preserves the legacy updatedAt-DESC - tie-break across a user's individual/business rows; `lookupAlfredpayCustomerType` keeps the - type-ASC precedence ('business' < 'individual'). + preserves the legacy updatedAt-DESC tie-break across a user's individual/business rows. + Type-less lookups resolve the caller's active `customer_entity` (the quote/ramp account + context). Typed lookups scope by the row's `customer_type` across every entity the profile + owns, and never create entities: migration 040 attached legacy business rows to the + profile's (038-backfilled) individual entity, so scoping to the same-typed entity made + migrated business customers invisible to every KYB endpoint and findOrCreate'd stray + empty business entities as a side effect of reads. `createAlfredpayCustomer` homes a new + row on the entity already carrying the profile's alfredpay rows of that `customer_type`, + preferring the *active* entity when such rows are split across entities (a pre-fix + duplicate can sit on a stray business entity) and falling back to the typed entity, so + ramp registration — which resolves the active entity — keeps seeing every corridor of a + migrated profile. `lookupAlfredpayCustomerType` keeps the type-ASC precedence + ('business' < 'individual'). - Canonical and external status transitions mirror into the account's `kyc_cases` row in the same code path. - The legacy `alfredpay_customers` table is a read-only backup with no remaining readers. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 95c11b90a..8e41c629c 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -4,14 +4,14 @@ BRLA is the Brazilian Real stablecoin used for BRL on/off-ramp operations, accessed via the **Avenia API** (operator of BRLA). All BRL liquidity flow happens on **Base (Ethereum L2)**: there is no BRLA on Moonbeam or Polygon, no XCM/teleport for BRL, and no Pendulum-side BRL handling. -**Temporary disablement:** BRL↔AssetHub on/off-ramps are disabled while the new BRL rail runs on Base. The quote engine should not return quotes for BRL→AssetHub or AssetHub→BRL, even though legacy route/transaction files still exist in the repository. Active BRL corridors are BRL↔supported EVM destinations via Base. +**Temporary disablement:** BRL↔AssetHub on/off-ramps are disabled while the new BRL rail runs on Base. Quote creation does not return those quotes. Both USDC topologies are nevertheless represented by the block catalog so persisted quotes, transaction preparation, and recovery remain deterministic. No separate route assembler remains. Active BRL corridors are BRL↔supported EVM destinations via Base. **Provider type:** Both (on-ramp and off-ramp) **Fiat currency:** BRL (Brazilian Real) **Chain involved:** Base (BRLA is an ERC-20 on Base) -**Phase handlers:** -- `brla-onramp-mint-handler.ts` — On-ramp: After PIX payment is confirmed by Avenia, BRLA tokens land on the Base ephemeral account; the handler polls the Base RPC until the expected balance arrives. -- `brla-payout-base-handler.ts` — Off-ramp: Sends a presigned ERC-20 transfer of BRLA from the Base ephemeral to the Avenia-controlled deposit address, then triggers an Avenia PIX payout via API. +**Block phases:** +- `phases/blocks/phases/avenia-mint/` and `avenia-direct-mint/` — BRL quote simulation, registration, transaction preparation, and Base mint settlement. +- `phases/blocks/phases/avenia-offramp-payout/` — Presigned BRLA transfer to the Avenia-controlled address and PIX payout execution. ### On-ramp flow (BRL → Base USDC → optional Squid → user EVM destination) @@ -19,17 +19,19 @@ BRLA is the Brazilian Real stablecoin used for BRL on/off-ramp operations, acces 2. User makes PIX payment to the Avenia-managed account. 3. `brlaOnrampMint`: Avenia mints BRLA on Base directly to the user's Base ephemeral. The handler first polls the Avenia subaccount balance every 5s (`waitUntilTrueWithTimeout`, 5-minute chunks — `AVENIA_BALANCE_CHECK_TIMEOUT_MS`), then the `evmEphemeralAddress` balance every 1s (`checkEvmBalancePeriodically`, 5-minute chunks — `EVM_BALANCE_CHECK_TIMEOUT_MS`). Each chunk timeout is a recoverable error; the overall payment window of **30 minutes** (`PAYMENT_TIMEOUT_MS`, wall clock since phase entry via `phaseHistory`) is re-checked on every chunk timeout and cancels the ramp (`failed`) when exceeded. Both waits accept the processor's `AbortSignal` so abandoned executions stop polling. (Before 2026-07-08 the Avenia wait ran 30 minutes per execution, which always outlived the processor's 10-minute execution timeout — so the payment-window cancellation never ran for recovered ramps and never-paid onramps churned indefinitely.) 4. `subsidizePreSwap` (if needed) → `nablaApprove` → `nablaSwap`: Nabla DEX **on Base** swaps BRLA → USDC. -5. `subsidizePostSwap` (if needed) → `distributeFees` (Multicall3 batch on Base, see `fee-integrity.md`). -6. If destination is Base + USDC → direct `destinationTransfer` (Squid skipped — see `squid-router.md`). Otherwise → `squidRouterApprove` / `squidRouterSwap` → bridge to user's supported destination EVM chain → optional fallback `backupSquidRouter*` swap on the destination chain → `destinationTransfer`. BRL→AssetHub is temporarily disabled at quote eligibility and should not reach registration. +5. `distributeFees` (Multicall3 batch on Base, see `fee-integrity.md`) → `subsidizePostSwap` (if needed). +6. If destination is Base + USDC → direct `destinationTransfer` (Squid omitted). For Base USDT, ETH, AXLUSDC, or EURC → same-chain `squidRouterApprove` / `squidRouterSwap` followed immediately by `destinationTransfer`, with no pay, backup bridge, or final-settlement work. Non-Base EVM outputs use the cross-chain Squid path with pay/fallback/final settlement. BRL→AssetHub is temporarily disabled at quote eligibility and should not reach registration. -For non-Base EVM destinations, the final quote output is the Squid destination-token amount. `quote.outputAmount` MUST be stored with the destination token's decimals (for example, 18 decimals for BSC USDT) because `avenia-to-evm-base.ts` derives the final `destinationTransfer` raw amount by multiplying the stored quote output by the destination token decimals. Truncating all BRL on-ramp outputs to 6 decimals would create tiny but real under-delivery for 18-decimal destination tokens and would make `evmToEvm.outputAmountRaw` disagree with the destination-token raw amount returned by Squid. +For non-Base EVM destinations, the final quote output is the Squid destination-token amount. `quote.outputAmount` MUST be stored with the destination token's decimals (for example, 18 decimals for BSC USDT), and `phases/blocks/phases/destination-transfer/transactions.ts` derives the final raw amount at those decimals. Truncating all BRL on-ramp outputs to 6 decimals would create under-delivery and make phase metadata disagree with Squid's destination raw amount. #### Degenerate BRL→BRLA-on-Base route (direct bypass) -When the user on-ramps BRL and asks for **BRLA delivered on Base** (input BRL, output BRLA, network Base), the generic pipeline would pointlessly swap BRLA→USDC on Nabla and then bridge/swap USDC→BRLA back to itself — burning two swaps of slippage and fees, and inviting the over-subsidy race documented in `06-cross-chain/fund-routing.md`. Avenia already mints BRLA directly on the Base ephemeral, so the route builder detects this case via `isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)` (`api/services/quote/utils.ts`) and emits a **single** `destinationTransfer` of the quoted output amount straight from the ephemeral to the user — no `nablaApprove`/`nablaSwap`, no `distributeFees`, no `squidRouter*`, no `finalSettlementSubsidy`, no Base cleanup. `stateMeta.isDirectTransfer = true` is set so the downstream `squidRouterSwap` and `finalSettlementSubsidy` handlers also short-circuit defensively if ever reached (`avenia-to-evm-base.ts`). The destination-transfer nonce is `0` (the ephemeral has signed nothing else on this corridor). This mirrors the EUR→EURC-on-Base bypass (`mykobo.md`). +When the user on-ramps BRL and asks for **BRLA delivered on Base** (input BRL, output BRLA, network Base), the generic pipeline would pointlessly swap BRLA→USDC on Nabla and then bridge/swap USDC→BRLA back to itself. The block catalog selects `BrlOnrampBaseDirect`, whose only monetary transaction is a `destinationTransfer` of the quoted output from the Base ephemeral to the user — no Nabla, fee distribution, Squid, final settlement subsidy, or cleanup. `stateMeta.isDirectTransfer = true`, and the destination-transfer nonce is `0`. This mirrors the intended EUR→EURC-on-Base bypass (`mykobo.md`). ### Off-ramp flow (User EVM → Base USDC → BRLA → PIX) +The catalog resolves every supported EVM source through `BrlOfframpBase`. Its source block owns quote simulation, registration-time source-wallet binding, and the user-wallet transaction blueprints. Its payout block owns authenticated Avenia registration, the trusted deposit wallet, PIX facts, the presigned BRLA transfer, ticket recovery, and Base cleanup approvals. Base USDC uses a direct user transfer; other Base tokens use same-chain Squid; other EVM sources use cross-chain Squid. All variants derive the same runtime phase sequence. + 1. User signs Squid permit / no-permit fallback / direct transfer (depending on source chain) → tokens arrive on Base ephemeral as USDC. 2. `distributeFees` runs **before** Nabla swap so partner/vortex fees are taken in USDC. 3. `subsidizePreSwap` → `nablaApprove` → `nablaSwap`: Nabla DEX on Base swaps USDC → BRLA. @@ -72,17 +74,19 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 10. **Recovery on resumed `brlaPayoutOnBase` MUST detect existing tickets** — If `payOutTicketId` is already in state, the handler skips re-issuing the PIX ticket and only polls status (prevents double-payout). 11. **Recovery on resumed on-chain transfer MUST detect existing tx hashes** — If `brlaPayoutTxHash` is in state, the handler waits for that receipt rather than re-broadcasting (prevents double on-chain BRLA transfer). 12. **PIX deposit details (QR code) MUST be generated server-side** — Returned via API response only after presigned transactions are validated, never client-modifiable. -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. +13. **BRL↔AssetHub MUST stay disabled while the Base BRL rail is active-only** — The quote engine MUST reject both BRL→AssetHub and AssetHub→BRL before block simulation even though both USDC flows are cataloged for persisted-quote preparation/recovery. Catalog presence alone MUST NOT imply quote eligibility. 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. +16. **BRL register paths MUST derive tax ID / subaccount from the effective user** — The catalog flow's `AveniaMint.register` and `AveniaOfframpPayout.register` hooks resolve the Avenia account via `resolveAveniaAccountForRamp(userId, additionalData.taxId)` and call the block-owned `createAveniaOnrampTicket` / `validateAveniaOfframpRecipient` logic in `phases/blocks/core/avenia-registration.ts`. That module owns pending-BRL aggregation, BRL/global limit enforcement, PIX-owner masked-tax-ID matching, trusted subaccount wallet resolution, and onramp ticket creation. 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. `RampService` only dispatches through the flow recorded in quote metadata and projects phase-owned facts/artifacts into legacy ramp state; it does not own Avenia registration operations. 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. 21. **Avenia company KYB completion MUST be provider-confirmed and ownership-bound** — `POST /v1/brla/kyb/new-level-1/web-sdk` stores the returned Avenia `attemptId` as the owned business `kyc_cases.provider_case_id`. `GET /v1/brla/kyb/attempt-status` accepts only a case owned by the effective user, queries that exact attempt, persists normalized status on both the case and provider customer, and returns only `status`, optional `result`, and optional normalized `failureReason`. Client-side events cannot assert completion: only provider `COMPLETED` plus `APPROVED` may complete onboarding; `REJECTED`, `EXPIRED`, `PENDING`, and `PROCESSING` must not pass the parent verification gate. 22. **A KYB attempt Avenia has not started processing MUST stay canonical `pending`, never `in_review`** — Company subaccount creation and KYB link initiation record `pending` (the attempt is `PENDING` at Avenia until the user completes the hosted steps); `in_review` is set only once Avenia reports `PROCESSING`. While the bound attempt's stored external status is still `PENDING`, re-initiation by the owner is allowed and rebinds the case to the fresh `attemptId` (the hosted URLs are never stored, so this is the only resume path); the `409` conflict applies once the attempt is `PROCESSING` or decided. Because the stored status can lag, re-initiation additionally probes the live attempt and refuses (`409`) when Avenia reports it processing or approved — a rejected decision stays re-initiable, and a failing probe falls back to allowing the resume. This cannot be used to bypass verification: a fresh attempt restarts at `PENDING` and invariant 21's completion gate is unchanged. To support form-less resume, `GET /v1/onboarding/status` exposes `taxReference` (the CNPJ) for **business** rows only — the response is already scoped to the caller's own entities, and individual CPFs remain unexposed. -23. **Dashboard BRL BUY confirmation MUST not bypass PIX verification** — The dashboard displays the server-generated `depositQrCode`, keeps the ramp unstarted, and calls `/ramp/start` only after the user confirms submitting PIX. That click is not proof of settlement; `brlaOnrampMint` must still verify the Avenia/Base balance before advancing. +23. **BRL Base destination variants MUST use token-specific static topology** — Base USDC MUST omit Squid entirely. Other configured non-BRLA Base outputs MUST execute exactly one same-chain `squidRouterSwap` phase before `destinationTransfer`; transaction preparation MUST use the Base builder, omit `squidRouterPay` and backup transactions, and allocate `destinationTransfer` at the nonce immediately after the Squid swap. BRLA remains the direct bypass in invariant 14. +24. **Dashboard BRL BUY confirmation MUST not bypass PIX verification** — The dashboard displays the server-generated `depositQrCode`, keeps the ramp unstarted, and calls `/ramp/start` only after the user confirms submitting PIX. That click is not proof of settlement; `brlaOnrampMint` must still verify the Avenia/Base balance before advancing. +25. **Unified BRL limit reads MUST use the authenticated user's provider account** — `POST /v1/limits` MUST derive the Avenia subaccount through `resolveAveniaAccountForUser`; it MUST NOT accept a caller-supplied tax ID or subaccount. BRL `max`, `used`, year, and month are mapped directly from Avenia's BRL fiat-in/fiat-out limit row. Tax IDs and provider subaccount IDs are never returned. ## Threat Vectors & Mitigations @@ -96,8 +100,8 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Amount manipulation between quote and payout** | Attacker modifies the payout amount between quote and execution | `quote.outputAmount` read from DB at execution time; quote is immutable post-creation. | | **Avenia service outage or partial ticket failure** | Avenia API is unreachable mid-ramp, or a ticket reaches `PARTIAL-FAILED` after one leg completed and a later leg failed | `RecoverablePhaseError` → phase processor retries transient outages. `PARTIAL-FAILED` must be treated as ticket-specific failure with prior completed legs preserved; callers may retry only after reconciling source/destination balances. | | **Subaccount data leak** | Avenia subaccount details exposed via API | Only `subAccountId`, EVM wallet address, and balances are stored locally; no PII beyond CPF (which is itself a regulatory requirement). | -| **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. | +| **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 is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (no floor). Both fractions default to `0.05`. | +| **Disabled AssetHub corridor accidentally re-enabled** | A developer mistakes either cataloged recovery flow for an eligible production corridor | Quote eligibility rejects both BRL→AssetHub and AssetHub→BRL before simulation. Flow and transaction tests may resolve the flows directly, but any successful public quote remains a regression until intentional re-enablement. | | **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. | @@ -108,31 +112,32 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] Avenia API credentials loaded from environment variables (not hardcoded). **PASS** — credentials loaded via env config. - [x] `brlaOnrampMint` polls Base RPC for BRLA arrival before advancing. **PASS** — `checkEvmBalancePeriodically` against `evmEphemeralAddress` for up to 30 minutes. -- [x] BRL↔AssetHub temporarily disabled. **PASS** — active docs and expected quote behavior treat BRL→AssetHub and AssetHub→BRL as disabled while Base is the BRL rail. Regression test manually by ensuring the quote API returns no quote for both corridors. +- [x] BRL↔AssetHub temporarily disabled. **PASS** — `QuoteService` explicitly rejects both directions before simulation. `BrlOnrampAssethubUsdc` and `BrlOfframpAssethubUsdc` remain cataloged only for persisted preparation/recovery; catalog and disabled-gate tests enforce the distinction. - [x] `brlaPayoutOnBase` PIX amount equals `quote.outputAmount`. **PASS** — `createPayOutQuote.outputAmount = amountForQuote = new Big(quote.outputAmount).round(2,0)`. -- [x] On-chain BRLA transfer amount equals `nablaSwapEvm.outputAmountRaw`. **PASS** — `brlaTransferAmountRaw = quote.metadata.nablaSwapEvm.outputAmountRaw` in `evm-to-brl-base.ts`. +- [x] On-chain BRLA transfer amount equals the subsidy-adjusted full swap output. **PASS** — `metadata.blocks.aveniaOfframpPayout.transferAmountRaw` is derived from the post-subsidy BRLA phase input and is used unchanged by the payout transaction preparer; the PIX amount remains immutable `quote.outputAmount`. - [x] User CPF/tax ID is validated at ramp registration (not at payout). **PASS** — CPF validation present in registration flow. - [x] Avenia subaccount creation is idempotent. **PASS** — checks existing subaccount before creating. -- [x] Recovery: `payOutTicketId` short-circuits ticket re-creation. **PASS** — verified in `brla-payout-base-handler.ts`. -- [x] Recovery: `brlaPayoutTxHash` short-circuits on-chain transfer re-broadcast. **PASS** — verified in `brla-payout-base-handler.ts`. -- [PARTIAL] Avenia API responses are validated (status, amount, ticket ID). **PARTIAL** — ticket status checked for `PAID`/`FAILED`; `PARTIAL-FAILED` is modeled and the rebalancer handles it for Polygon transfer tickets, but API payout handlers still treat only `FAILED` as terminal; no explicit amount cross-check on `getAccountBalance` response shape. +- [x] Recovery: `payOutTicketId` short-circuits ticket re-creation. **PASS** — verified in `phases/blocks/phases/avenia-offramp-payout/execution.ts`. +- [x] Recovery: `brlaPayoutTxHash` short-circuits on-chain transfer re-broadcast. **PASS** — verified in `phases/blocks/phases/avenia-offramp-payout/execution.ts`. +- [ ] Avenia API responses are validated (status, amount, ticket ID). **PARTIAL** — ticket status checked for `PAID`/`FAILED`; `PARTIAL-FAILED` is modeled and the rebalancer handles it for Polygon transfer tickets, but API payout handlers still treat only `FAILED` as terminal; no explicit amount cross-check on `getAccountBalance` response shape. - [x] `RecoverablePhaseError` used for transient Avenia API failures. **PASS** — `createRecoverableError` wraps `sendBrlaPayoutTransaction` failures and ticket-status timeouts. - [x] HTTPS enforced for all Avenia API calls. **PASS** — base URL uses `https://`. -- [PARTIAL] No Avenia API credentials or user tax IDs appear in logs. **PARTIAL** — `payOutTicketId` is debug-logged with the literal CPF subaccount; review log redaction. +- [ ] No Avenia API credentials or user tax IDs appear in logs. **PARTIAL** — `payOutTicketId` is debug-logged with the literal CPF subaccount; review log redaction. - [x] Dashboard BRL onramps render only the server-issued PIX QR/copy payload, use ephemeral-only signing, and do not call `/ramp/start` before explicit payment confirmation; Avenia/Base balance verification remains authoritative. **PASS**. -- [FAIL] **F-014**: Timeout configured for Avenia HTTP client. **FAIL** — relies on default system/library timeouts; no explicit `AbortController` on `BrlaApiService` calls. +- [ ] **F-014**: Timeout configured for Avenia HTTP client. **FAIL** — relies on default system/library timeouts; no explicit `AbortController` on `BrlaApiService` calls. - [x] PIX deposit details (QR code) generated server-side. **PASS** — comes from Avenia API response. - [x] PIX deposit details released to user only after presign validation. **PASS** — gated by `ephemeralPresignChecksPass` (see `transaction-validation.md`). -- [PARTIAL] Avenia interactions logged for reconciliation (amounts, not credentials). **PARTIAL** — info logs include amounts; no formal reconciliation log with structured fields. +- [ ] Avenia interactions logged for reconciliation (amounts, not credentials). **PARTIAL** — info logs include amounts; no formal reconciliation log with structured fields. - [x] **FINDING F-064 (MEDIUM)**: BRLA KYC callback endpoint requires authentication. **PASS (FIXED)** — `/kyc/record-attempt` uses `requireAuth`. -- [x] BRL→BRLA-on-Base on-ramps (`isBrlToBrlaBaseDirect`) emit a single `destinationTransfer` with `isDirectTransfer = true` — no Nabla swap, `distributeFees`, SquidRouter, `finalSettlementSubsidy`, or Base cleanup phases. **PASS** — `avenia-to-evm-base.ts` early direct branch (single tx at nonce 0). -- [x] `squidRouterSwap` and `finalSettlementSubsidy` honor `isDirectTransfer` / `isBrlToBrlaBaseDirect` and short-circuit to `destinationTransfer` if ever reached on a direct route. **PASS** — `squid-router-phase-handler.ts`, `final-settlement-subsidy.ts`. -- [x] BRL→EVM destination-token precision preserved. **PASS** — `OnRampFinalizeEngine` stores BRL/EVM `quote.outputAmount` using destination token decimals, and `BaseSquidRouterEngine` preserves Squid's destination raw output in `evmToEvm.outputAmountRaw`. +- [x] BRL→BRLA-on-Base on-ramps emit only provider mint, funding, and `destinationTransfer` — no Nabla, fee distribution, Squid, final settlement, or Base cleanup transaction. **PASS** — `phases/blocks/flows/brl-onramp-base-direct.ts`. +- [x] The BRL→BRLA direct flow omits Squid and final settlement rather than relying on executor short-circuits. **PASS** — `phases/blocks/flows/brl-onramp-base-direct.ts`. +- [x] BRL→EVM destination-token precision preserved. **PASS** — block flow simulation preserves Squid destination raw output and destination-token decimals. +- [x] BRL Base output topology is token-specific. **PASS** — block catalog resolution maps USDC to the no-Squid flow, BRLA to the direct bypass, and USDT/ETH/AXLUSDC/EURC to the one-phase same-chain Squid flow; flow and transaction tests enforce Base construction and contiguous destination nonce ordering. ## Remediation Notes - **Hardcoded BRL offramp validation amount:** Resolved in the remediation pass; BRL offramp validation now derives the pre-anchor amount from quote metadata instead of a literal placeholder. -- **EVM subsidy USD caps:** Resolved for the Base EVM subsidy handlers via env-configured quote-relative cap fractions. `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` and `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` both default to `0.05` and can be overridden through environment variables. Over-cap cases are intentionally recoverable retries: no subsidy transfer is submitted, and the ramp remains waiting for operator action rather than becoming unrecoverably failed. +- **EVM subsidy USD caps:** Resolved for the Base EVM subsidy handlers via env-configured quote-relative cap fractions. `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` and `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` both default to `0.05` and can be overridden through environment variables; the pre-swap and post-swap discrepancy caps additionally floor at $1.00 so small quotes retain a workable subsidy allowance. Over-cap cases are intentionally recoverable retries: no subsidy transfer is submitted, and the ramp remains waiting for operator action rather than becoming unrecoverably failed. ## Provider-customers cutover (2026-07) @@ -142,7 +147,7 @@ Key properties: - Lookups key off `tax_reference_hash` (sha256 of the normalized tax id). The raw normalized value is retained in `tax_reference` because it is the join/aggregation key for in-flight - ramp state (`ramp_states.state.taxId`, `getPendingBrlVolume`) — a documented deviation from + ramp state (`ramp_states.state.taxId`, block-owned `getPendingBrlVolume`) — a documented deviation from the unified doc's "no raw tax IDs" non-goal, to be revisited once legacy ramp state drains. No masked copy is persisted; masked display is derived with `maskTaxReference` at read time. - `status` uses the shared canonical verification enum. Avenia's unmodified attempt status is @@ -160,8 +165,16 @@ Key properties: - Ownership gaps closed in the cutover: `fetchSubaccountKycStatus` (which also WRITES status transitions) and `getSelfieLivenessUrl` require the effective user to own the account. KYB initiation stores Avenia's opaque attempt ID in the owned `kyc_cases.provider_case_id`; - `getKybAttemptStatus` resolves that binding and verifies entity ownership before querying Avenia. + `getKybAttemptStatus` resolves that binding and verifies ownership before querying Avenia. The browser receives only normalized status/result fields, never provider submission data. +- Ownership is profile-level: a row must belong to one of the effective user's customer + entities, never to a specific typed entity. Migration 040 attached legacy business rows to + the profile's individual entity, so comparing against the typed business entity 403'd/409'd + the legitimate owner (`getUploadUrls`, `getKybAttemptStatus`, the `createSubaccount` + conflict check) and findOrCreate'd stray empty business entities as a read side effect. + `createSubaccount` defers typed-entity creation to the branches that persist a new row, + so a retry that updates an existing row creates no entity. Cross-profile requests still + fail closed. - KYC/KYB state transitions update canonical status and provider status on both `provider_customers` and the account's `kyc_cases` row in the same code path. `updateAveniaKycOutcome` treats `approved` as terminal (a stale attempt read never diff --git a/docs/security-spec/05-integrations/fastforex.md b/docs/security-spec/05-integrations/fastforex.md index 07b6df04f..c41b4871e 100644 --- a/docs/security-spec/05-integrations/fastforex.md +++ b/docs/security-spec/05-integrations/fastforex.md @@ -46,7 +46,7 @@ The full provider priority for `getUsdToFiatExchangeRate()` is Binance USDT spot - [x] FastForex response status and rate are validated. **PASS** — non-OK responses throw; missing, zero, or negative rates throw. - [x] FastForex rates are sanity-checked against CoinGecko when the reference is available. **PASS** — `assertRateWithinSanityBand("fastforex", ...)` compares the spread with per-currency limits when CoinGecko returns a valid reference; otherwise it warns and accepts the valid FastForex rate. The same helper guards Binance rates. - [x] FastForex failures fall back to CoinGecko. **PASS** — failures are caught and logged before requesting the CoinGecko fallback. -- [x] CoinGecko fallback/reference uses USDC as the USD proxy. **PASS / OPERATIONAL RISK** — accepted by current code, but operators should monitor depeg conditions because this is not a pure fiat FX reference. +- [ ] CoinGecko fallback/reference uses USDC as the USD proxy. **ACCEPTED RISK RISK-014** — operators must monitor depeg conditions because this is not a pure fiat FX reference. - [x] Both-provider failure fails closed. **PASS** — `convertCurrency()` rethrows provider failures instead of returning the original amount. - [x] Accepted fiat rates use the configured short cache TTL. **PASS** — `fiatExchangeRateCache` entries expire after `FIAT_CACHE_TTL_MS`. - [x] No FastForex secret is logged. **PASS** — logs include provider URL and error context, not `FASTFOREX_API_KEY`. diff --git a/docs/security-spec/05-integrations/mykobo.md b/docs/security-spec/05-integrations/mykobo.md index 99cb808ef..56becd201 100644 --- a/docs/security-spec/05-integrations/mykobo.md +++ b/docs/security-spec/05-integrations/mykobo.md @@ -2,7 +2,7 @@ ## What This Does -Mykobo is the EUR fiat anchor used by Vortex for EUR on/off-ramp operations on **Base (Ethereum L2)**. Mykobo settles SEPA bank transfers into / out of EURC (Circle's EUR stablecoin, ERC-20) on Base. There is no Stellar, Pendulum, or Moonbeam involvement for EUR liquidity: all EUR flow now happens on Base, mirroring the BRLA-on-Base architecture. +Mykobo is the EUR fiat anchor used by Vortex for EUR on/off-ramp operations on **Base (Ethereum L2)**. Mykobo settles SEPA bank transfers into / out of EURC (Circle's EUR stablecoin, ERC-20) on Base. There is no Pendulum or Moonbeam involvement for EUR liquidity: all EUR flow now happens on Base, mirroring the BRLA-on-Base architecture. EUR ramp registration is currently disabled at `RampService.registerRamp`: any quote whose input or output currency is `FiatToken.EURC` is rejected with `503 SERVICE_UNAVAILABLE` before Mykobo intents or phase transactions are prepared. The flow details below describe the intended Mykobo behavior for when the EUR rail is re-enabled. @@ -10,49 +10,49 @@ Monerium now owns EU dashboard KYC/KYB and recipient onboarding eligibility; Myk Mykobo replaces two earlier EUR rails: -- The **Stellar SEP-24 EUR off-ramp** (Mykobo anchor reached via Spacewalk) — removed for EUR. See `stellar-anchors.md` for the deprecation note. +- The **Stellar SEP-24 EUR off-ramp** (Mykobo anchor reached via Spacewalk) — removed; Stellar/Spacewalk support was fully removed from the platform (migration 028). - The legacy **Monerium EUR on-ramp** (Monerium EURe minted on Moonbeam) — removed. The new Monerium OAuth onboarding flow is separate and does not restore that settlement path; see `monerium.md`. **Provider type:** Both (on-ramp and off-ramp) **Fiat currency:** EUR (Euro, SEPA) **Chain involved:** Base (EURC is an ERC-20 on Base; USDC on Base is the Nabla swap counter-asset) -**Phase handlers:** -- `mykobo-onramp-deposit-handler.ts` — On-ramp: After the user's SEPA transfer is received, Mykobo settles EURC on Base to the user's Base ephemeral; the handler polls the Base RPC until the expected balance arrives. -- `mykobo-payout-handler.ts` — Off-ramp: Sends a presigned ERC-20 EURC transfer from the Base ephemeral to the Mykobo-controlled `receivables` address, then polls Mykobo's transaction status until `COMPLETED`. +**Block phases:** +- `phases/blocks/phases/mykobo-mint/` — SEPA deposit intent registration, transaction preparation, and Base EURC settlement polling. +- `phases/blocks/phases/mykobo-offramp-payout/` — Withdrawal intent registration, presigned EURC payout, and provider status polling. **API surface:** Mykobo HTTP API client `MykoboApiService` (`packages/shared/src/services/mykobo/mykoboApiService.ts`). Singleton. **API auth method:** Access key + secret key exchanged for a short-lived bearer token via `POST /auth/token`; refresh token via `POST /auth/refresh`. Cached in-process; re-acquired on `401`. Credentials sourced from `MYKOBO_ACCESS_KEY`, `MYKOBO_SECRET_KEY`, `MYKOBO_BASE_URL`, `MYKOBO_CLIENT_DOMAIN` env vars. **`MYKOBO_CLIENT_DOMAIN` operational note:** The client domain is sent as `client_domain` on every Mykobo API call (`MykoboApiService`). It identifies the Vortex deployment to Mykobo and determines the **fee tier** applied to that deployment's intents. When unset, Mykobo falls back to its default tier (observed: ~0.31 EUR fixed deposit fee vs. ~0.06 EUR for the negotiated Vortex tier on `satoshipay.io`). Because the constant is loaded via `getEnvVar` with no default, a missing value silently degrades fees rather than failing fast — operators MUST verify it is set at deploy time. -**Fee lookup and quote-time display fallback:** EUR quote creation looks up the Mykobo fee live via `GET /fees` — on-ramp through `defaultDepositFee` (`OnRampInitializeMykoboEngine`), off-ramp through `defaultWithdrawFee` (`OffRampFeeMykoboEngine`). Both call sites go through `quote/engines/mykobo-fee.ts`. If the lookup fails or Mykobo is unreachable, the helper throws `MykoboFeeUnavailableError`, which `QuoteService` maps to `QuoteError.AnchorTemporarilyUnavailable` (`503`) instead of the generic `FailedToCalculateQuote` — so a Mykobo outage is distinguishable in logs and to the user. An **optional, env-gated display fallback** (`MYKOBO_FEE_FALLBACK_ENABLED=true` with flat, boot-validated `MYKOBO_FALLBACK_DEPOSIT_FEE` / `MYKOBO_FALLBACK_WITHDRAW_FEE` EUR values) lets EUR quotes still render during a `/fees` outage by returning the configured fee instead of throwing. It is **display-only** — it affects only the fee shown on the quote, never ramp execution — and is flat, so it does not model Mykobo's percentage component; quotes rendered during an outage are approximate. +**Fee lookup and quote-time display fallback:** EUR quote creation looks up the Mykobo fee live via `GET /fees` — onramps through `MykoboMint.simulate` and offramps through `MykoboOfframpFee`. Both use `phases/blocks/core/mykobo-fee.ts`. On offramp, `EvmOfframpSource` first installs the simulated Squid network fee and the Mykobo fee phase replaces only the anchor component, preserving network/Vortex/partner fees. If lookup fails, `MykoboFeeUnavailableError` maps to `QuoteError.AnchorTemporarilyUnavailable` (`503`). The optional env-gated fallback remains display-only; EUR registration is still disabled before provider side effects. ### On-ramp flow (EUR SEPA → Base EURC → Nabla swap → user EVM destination) -1. At ramp registration (`prepareMykoboOnrampTransactions` in `ramp.service.ts`), Vortex calls Mykobo `POST /transactions/intent` with `transaction_type=DEPOSIT`, `currency=EURC`, the user's email + IP, the **Base ephemeral address** as `wallet_address`, and `value` as the EUR amount floored to **2 decimal places** (Mykobo silently truncates any extra precision; see invariant below). Mykobo returns IBAN payment instructions (IBAN, bank account name, transaction reference). +1. At ramp registration, `RampService` calls the catalog flow's `Flow.register`. `MykoboMint.register` derives the approved Mykobo customer from the authenticated user and calls Mykobo `POST /transactions/intent` with `transaction_type=DEPOSIT`, `currency=EURC`, the user's derived email + IP, the **Base ephemeral address** as `wallet_address`, and `value` as the EUR amount floored to **2 decimal places**. It returns IBAN payment artifacts and phase-owned transaction facts; `Flow.prepareTxs` supplies only those own facts to `MykoboMint`, which persists them under `state.blockState.mykoboMint`. 2. IBAN instructions are returned to the user **only after** presigned-transaction validation passes (see `transaction-validation.md`). 3. User makes the SEPA bank transfer to Mykobo's IBAN with the returned reference. 4. `mykoboOnrampDeposit`: handler polls the Base RPC for EURC arrival at `evmEphemeralAddress`. - **Outer timeout** (`PAYMENT_TIMEOUT_MS`): **24 hours**, matching SEPA business-day cutoffs. - **Inner balance-arrival timeout** (`EVM_BALANCE_CHECK_TIMEOUT_MS`): 5 minutes per `checkEvmBalancePeriodically` invocation. Inner timeouts throw `RecoverablePhaseError` and the phase processor re-enters the handler until the outer 24h cap is reached. - - **Recovery shortcut**: if the ephemeral already holds ≥ 95% of `quote.metadata.mykoboMint.outputAmountRaw` EURC (`EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95`), the handler skips the wait. The 5% tolerance absorbs fee variance between quote-creation time and SEPA settlement time. + - **Recovery shortcut**: if the ephemeral already holds ≥ 95% of `quote.metadata.blocks.mykoboMint.mint.outputAmountRaw` EURC (`EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95`), the block executor skips the wait. The 5% tolerance absorbs fee variance between quote creation and SEPA settlement. - On outer-timeout expiry, the ramp transitions to `failed` (the user did not pay). 5. `fundEphemeral` (Base ETH gas top-up; same as BRL onramp) → `subsidizePreSwap` (if needed) → `nablaApprove` → `nablaSwap`: Nabla DEX **on Base** swaps EURC → USDC. -6. `subsidizePostSwap` (if needed) → `distributeFees` (Multicall3 batch on Base, see `fee-integrity.md`). The EVM post-swap branch uses the split subsidy caps documented in `fund-routing.md`: swap-output discrepancy and discount subsidy are bounded separately before any transfer is submitted. -7. If destination is Base + USDC → direct `destinationTransfer` (Squid skipped — see `squid-router.md`). Otherwise → `squidRouterApprove` / `squidRouterSwap` → bridge to user's destination EVM chain → optional `backupSquidRouter*` fallback → `destinationTransfer`. +6. `distributeFees` (Multicall3 batch on Base, see `fee-integrity.md`) → `subsidizePostSwap` (if needed). The EVM post-swap branch uses the split subsidy caps documented in `fund-routing.md`: swap-output discrepancy and discount subsidy are bounded separately before any transfer is submitted. +7. If destination is Base + USDC → direct `destinationTransfer` after Nabla (Squid omitted). For Base USDT, ETH, AXLUSDC, or BRLA → one same-chain `squidRouterApprove` / `squidRouterSwap` followed immediately by `destinationTransfer`, with no pay, backup, or final-settlement work. Non-Base EVM outputs use the cross-chain Squid path with pay/fallback/final settlement. #### Degenerate EUR→EURC-on-Base route (direct bypass) -When the user on-ramps EUR and asks for **EURC delivered on Base** (input EURC, output EURC, network Base), the generic pipeline would pointlessly swap EURC→USDC on Nabla and then bridge/swap USDC→EURC back to itself — burning two swaps of slippage and fees, and inviting the over-subsidy race documented in `06-cross-chain/fund-routing.md`. Mykobo already settles EURC directly on the Base ephemeral, so the route builder detects this case via `isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)` (`api/services/quote/utils.ts`) and emits a **single** `destinationTransfer` of the quoted output amount straight from the ephemeral to the user — no `nablaApprove`/`nablaSwap`, no `squidRouter*`, no `finalSettlementSubsidy`, no Base cleanup. `stateMeta.isDirectTransfer = true` is set so the downstream `finalSettlementSubsidy` handler also short-circuits defensively if ever reached (`mykobo-to-evm.ts`). The quote engine produces the matching single-phase plan, and the destination-transfer nonce is `0` (the ephemeral has signed nothing else on this corridor). +When the user on-ramps EUR and asks for **EURC delivered on Base** (SEPA, input EURC, output EURC, destination Base), the generic pipeline would pointlessly swap EURC→USDC on Nabla and then bridge/swap USDC→EURC back to itself — burning two swaps of slippage and fees, and inviting the over-subsidy race documented in `06-cross-chain/fund-routing.md`. The exact catalog predicate resolves `EurOnrampBaseDirect`, which composes `MykoboMint` → `FundEphemeral` → `DestinationTransfer` and derives `initial` → `mykoboOnrampDeposit` → `fundEphemeral` → `destinationTransfer` → `complete`. It emits a **single** presigned `destinationTransfer` of the quoted provider-delivered EURC from the ephemeral to the user: no Nabla, fee distribution, Squid, final settlement, or Base cleanup transactions. `stateMeta.isDirectTransfer = true`, and the destination-transfer nonce is `0` because the ephemeral has signed nothing else on this corridor. EUR registration remains kill-switched before these provider side effects. ### Off-ramp flow (User EVM → Base USDC → Base EURC → SEPA payout) -1. User signs Squid permit / no-permit fallback / direct transfer → tokens arrive on Base ephemeral as USDC. If the source is already Base USDC, Squid is skipped. -2. At registration (`prepareEvmToMykoboOfframpTransactions`), Vortex calls Mykobo `POST /transactions/intent` with `transaction_type=WITHDRAW`, `currency=EURC`, the Base ephemeral as `wallet_address`, and `value` set to `quote.metadata.nablaSwapEvm.outputAmount` floored to **2 decimal places** via `Big.toFixed(2, 0)` (Mykobo silently truncates anything beyond 2 dp; intent value, on-chain transfer amount, and Mykobo's accounting MUST agree on the same floored figure). Mykobo returns withdraw instructions including the **`receivables` Base address** (the EVM address that Mykobo monitors for incoming EURC). The Mykobo transaction id and reference are stored in `state.state.mykoboTransactionId` / `mykoboTransactionReference`. +1. `EurOfframpBase` issues user-wallet source transactions that deliver Base USDC: one direct transfer for Base USDC, same-chain Squid for another Base token, or cross-chain Squid for another EVM source. Reported hashes are content-verified before `fundEphemeral` spends platform funds. +2. At registration, `MykoboOfframpPayout.register` derives the approved Mykobo email from the authenticated user, validates any supplied email as a match-only field, and calls `POST /transactions/intent` with the effective IP, `transaction_type=WITHDRAW`, `currency=EURC`, the Base ephemeral as `wallet_address`, and the phase-owned post-subsidy transfer amount floored to **2 decimal places**. Only validated withdrawal instructions may supply the **`receivables` Base address**. Identity, transaction id/reference, and receivables are stored under `state.blockState.mykoboOfframpPayout` and projected to legacy top-level fields for active API/recovery compatibility. 3. `distributeFees` runs **before** Nabla swap so partner/vortex fees are taken in USDC (consistent with the BRLA-on-Base flow; see `fee-integrity.md`). 4. `subsidizePreSwap` → `nablaApprove` → `nablaSwap`: Nabla DEX on Base swaps USDC → EURC. 5. `mykoboPayoutOnBase`: - 1. Sends the presigned ERC-20 EURC transfer of the **2dp-floored** Mykobo intent value (`mykoboFlooredValue × 10^ERC20_EURC_BASE_DECIMALS`) from the ephemeral to the Mykobo `receivables` address. The transfer amount is fixed at registration time and **MUST equal the Mykobo intent `value`** so on-chain credit and Mykobo accounting agree. The sub-cent EURC remainder between `nablaSwapEvm.outputAmountRaw` and the floored transfer amount stays on the ephemeral and is swept by `baseCleanupEurc` in step 6. + 1. Sends the presigned ERC-20 EURC transfer of `metadata.blocks.mykoboOfframpPayout.transferAmountRaw` from the ephemeral to the provider-derived `receivables` address. The transfer amount is fixed at registration and **MUST equal the Mykobo intent `value`**. Sub-cent EURC remains for `baseCleanupEurc`. 2. On retry, if `mykoboPayoutTxHash` is already in state, the handler waits for that receipt instead of re-broadcasting. If the prior tx reverted, it re-sends the same presigned tx (EVM nonce uniqueness still prevents double-spend). 3. After the on-chain transfer is confirmed, the handler polls Mykobo `GET /transactions/{id}` every **5s for up to 10 minutes**, looking for `MykoboTransactionStatus.COMPLETED`. `FAILED`, `CANCELLED`, or `EXPIRED` raise an **unrecoverable** error. Polling-error timeouts raise an unrecoverable error if the last polling attempt errored, otherwise a recoverable error. 6. `baseCleanupUsdc` / `baseCleanupEurc` / `baseCleanupAxlUsdc`: sweep dust from the Base ephemeral back to the Base funding account. `baseCleanupEurc` is load-bearing here — it claims the sub-cent EURC remainder left behind by the 2dp floor in step 5.1. @@ -75,7 +75,7 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do 1. **Mykobo API credentials MUST be stored as environment variables** — `MYKOBO_ACCESS_KEY`, `MYKOBO_SECRET_KEY`, `MYKOBO_BASE_URL`, and `MYKOBO_CLIENT_DOMAIN` are loaded via `packages/shared` config. Never hardcoded, never in the database. 2. **The Mykobo bearer token MUST never appear in logs or error messages** — `MykoboApiError` captures status + body but not the request headers; review log redaction for any context that includes `Authorization`. 3. **SEPA payment confirmation MUST come from on-chain EURC arrival, not from user input** — `mykoboOnrampDeposit` polls the Base RPC for the ephemeral's EURC balance; it never accepts a "user claims paid" signal. -4. **The on-chain EURC transfer amount (off-ramp) MUST equal the Mykobo intent `value` floored to 2 decimal places** — Computed in `evm-to-mykobo.ts` as `Big(quote.metadata.nablaSwapEvm.outputAmount).toFixed(2, 0)` and converted to raw via `× 10^ERC20_EURC_BASE_DECIMALS`. The presigned `mykoboPayoutOnBase` tx, the Mykobo intent `value`, and the Mykobo `receivables` credit MUST all reference the same floored figure. The sub-cent EURC remainder is intentionally left on the ephemeral for `baseCleanupEurc`. The Mykobo anchor fee was already factored into `quote.outputAmount` at quote-creation time. +4. **The on-chain EURC transfer amount (off-ramp) MUST equal the Mykobo intent `value` floored to 2 decimal places** — `MykoboOfframpPayout.simulate` derives one `transferAmountDecimal`/`transferAmountRaw` pair from the post-subsidy EURC input. Registration uses the decimal value and transaction preparation uses the raw value. The presigned `mykoboPayoutOnBase` tx, intent value, and receivables credit MUST reference that same figure. Sub-cent EURC is left for `baseCleanupEurc`. 5. **The Mykobo `receivables` address MUST come from the Mykobo intent response, not from any client-supplied field** — `mykoboReceivablesAddress` is read from `intent.instructions.address` server-side and stored in `stateMeta`. The user has no way to redirect the off-ramp transfer. 6. **The Mykobo `transaction_type` MUST match the ramp direction** — `DEPOSIT` for on-ramp intents, `WITHDRAW` for off-ramp intents. A mismatch is rejected by Mykobo, but Vortex must not allow client-controlled selection of the type. 7. **The on-ramp intent's `wallet_address` MUST be the Base ephemeral, not the user's destination address** — EURC is settled to the ephemeral so the Nabla swap pipeline can run. Using the user's destination address would bypass the swap and fee distribution. @@ -94,10 +94,11 @@ 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 both the provider-customer and KYC-case mirrors from the live profile, storing canonical `approved` in `status` and the unmodified Mykobo review status in `status_external`, then rejects with `400` unless 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. **EUR ramp registration MUST derive the Mykobo email from the effective user and require approved Mykobo KYC** — `MykoboMint.register` and `MykoboOfframpPayout.register` resolve `email_address` via `resolveMykoboCustomerForUser(userId, providedEmail?)`, which (a) reads the canonical email from the effective user's profile, (b) accepts `additionalData.email` only as a match-only compatibility check, and (c) refreshes the live provider/KYC mirrors and rejects unless canonically approved. The provided email never selects a customer. Combined with the effective-user requirement, one user cannot register against another user's Mykobo identity. 24. **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. 25. **`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. 26. **The Mykobo fee display fallback MUST be display-only and MUST NOT price a ramp execution** — When `MYKOBO_FEE_FALLBACK_ENABLED=true`, a failed `defaultDepositFee` / `defaultWithdrawFee` lookup returns the configured flat `MYKOBO_FALLBACK_DEPOSIT_FEE` / `MYKOBO_FALLBACK_WITHDRAW_FEE` (validated non-negative at boot) so EUR quotes still render during a `/fees` outage. This value is used only for the quote's anchor-fee display. Ramp execution MUST NOT run on a fallback fee: the EUR registration kill-switch (`registerRamp` rejects EURC quotes with `503`) currently blocks all EUR execution, and when the rail is re-enabled, ramp start MUST re-validate the live Mykobo fee before executing. When the fallback is disabled (default) or a needed value is unset, the lookup failure MUST surface as `MykoboFeeUnavailableError` → `QuoteError.AnchorTemporarilyUnavailable` (`503`), never as an invented fee. +27. **EUR Base destination variants MUST use token-specific static topology** — Base EURC belongs exclusively to the direct bypass. Base USDC MUST run Nabla EURC→USDC and omit Squid. Base USDT, ETH, AXLUSDC, and BRLA MUST execute exactly one same-chain `squidRouterSwap` phase before `destinationTransfer`; preparation MUST use the Base builder, omit `squidRouterPay` and backup transactions, and allocate `destinationTransfer` immediately after the Squid swap. ## Threat Vectors & Mitigations @@ -123,7 +124,7 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do | **Fabricated fee during Mykobo `/fees` outage** | Mykobo `/fees` is down; the display fallback lets a quote render, and if such a quote could be executed the user might be charged a fee that differs from Mykobo's real charge | The fallback is env-gated (`MYKOBO_FEE_FALLBACK_ENABLED`), display-only, and flat; it never reaches execution — EUR registration is disabled and, when re-enabled, ramp start MUST re-validate the live Mykobo fee. With the fallback off (default), the outage surfaces as `AnchorTemporarilyUnavailable` (`503`) rather than an invented fee. | | **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`. | | **EUR→EURC-Base self-swap drain** | The generic pipeline swaps the user's already-settled EURC to USDC and back, charging two swaps of slippage/fees and triggering `finalSettlementSubsidy` against bridge-less dust (over-subsidy + strand) | `isEurToEurcBaseDirect` collapses the corridor to a single `destinationTransfer` with `isDirectTransfer = true`; Nabla/Squid/finalSettlementSubsidy/cleanup are skipped at both route-build and handler level. | -| **Underdelivery from Nabla-on-Base** | Nabla swap returns less USDC/EURC than quoted and the ramp reaches `subsidizePostSwap`. | `subsidizePostSwap` (EVM branch) tops up eligible shortfalls subject to split caps: actual-vs-quoted swap discrepancy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; discount subsidy uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Over-cap cases are recoverable waits with no transfer submitted. | +| **Underdelivery from Nabla-on-Base** | Nabla swap returns less USDC/EURC than quoted and the ramp reaches `subsidizePostSwap`. | `subsidizePostSwap` (EVM branch) tops up eligible shortfalls subject to split caps: actual-vs-quoted swap discrepancy is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output; discount subsidy uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (no floor). Over-cap cases are recoverable waits with no transfer submitted. | ## Audit Checklist @@ -157,3 +158,4 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do - [ ] `finalSettlementSubsidy` honors `isDirectTransfer` / `isEurToEurcBaseDirect` and short-circuits to `destinationTransfer` if ever reached on a direct route - [ ] While EUR ramps are disabled, `registerRamp` rejects EURC input/output quotes with `503 SERVICE_UNAVAILABLE` before any Mykobo or ramp-state side effects - [ ] Mykobo `/fees` outage during quote creation surfaces as `QuoteError.AnchorTemporarilyUnavailable` (`503`), not generic `FailedToCalculateQuote`; the env-gated display fallback (`MYKOBO_FEE_FALLBACK_ENABLED`) is display-only and never prices a ramp execution +- [x] EUR Base output topology is token-specific. **PASS** — catalog predicates map EURC only to the direct flow, USDC to the no-Squid flow, and USDT/ETH/AXLUSDC/BRLA to the one-phase same-chain Squid flow; flow and transaction tests enforce Base construction and contiguous nonce ordering. diff --git a/docs/security-spec/05-integrations/squid-router.md b/docs/security-spec/05-integrations/squid-router.md index 4810f21be..44e370abf 100644 --- a/docs/security-spec/05-integrations/squid-router.md +++ b/docs/security-spec/05-integrations/squid-router.md @@ -16,10 +16,10 @@ It handles cross-chain swap execution, Axelar bridge status monitoring, and gas **Provider type:** Cross-chain router **Chains involved:** Base, Polygon, Moonbeam, Ethereum, Arbitrum, BSC, Avalanche, etc. (any EVM destination supported by Squid) -**Phase handlers:** -- `squid-router-phase-handler.ts` — Submits presigned approve + swap transactions on the source EVM chain. -- `squid-router-pay-phase-handler.ts` — Monitors Axelar bridge status, funds Axelar gas, waits for cross-chain settlement (with finite arrival timeout). Honors the phase processor's `AbortSignal` so timed-out executions stop polling instead of leaking loops against the Squid rate limit. When axelarscan reports a failed validator confirmation poll (`status: "called"` + `confirm_failed` — Axelar's relayer never retries these), it auto-recovers by fetching a signed `ConfirmGatewayTx` from Axelar's public recovery signing service and broadcasting it to the Axelar RPC (`recoverAxelarStuckConfirm` in shared). This uses only the public tx hash — no Vortex keys sign anything and no funds move; attempts are rate-limited by a cooldown timestamp persisted in ramp state (`axelarConfirmRecoveryAt`). Additionally, once a ramp has sat in `squidRouterPay` past a stuck threshold (`SQUID_ROUTER_PAY_STUCK_ALERT_MS`, default 20 minutes, measured from the `phaseHistory` entry so it spans retried executions), the handler classifies the GMP via `classifyGmpStatus` (shared) and (a) attempts the `ConfirmGatewayTx` recovery for transfers still waiting on source confirmation even without `confirm_failed`, (b) sends **at most one** supplemental `addNativeGas` top-up when axelarscan reports the paid gas as insufficient (the `"pending"` sentinel on `squidRouterExtraGasTxHash` is claimed via a **conditional UPDATE** — marker must still be absent in the database — **before** broadcasting and reconciled to the tx hash after, so neither a crash in between nor a concurrent execution can cause a second payment; an unknown-outcome top-up is left for manual handling via the alert; the monitor also refuses all recovery actions once the processor's `AbortSignal` has fired, so an abandoned execution cannot pay alongside its retry; overpayment is refunded by the gas service to the funding wallet; the top-up is not recorded as a `Subsidy` row — the per-ramp/phase dedup guard holds the initial payment — and is instead logged as `SQUIDROUTER_EXTRA_GAS_PAID`), and (c) emits a Slack/log alert (`SQUIDROUTER_PAY_STUCK`) with ramp id, source tx, Squid quote id, axelarscan link, classification, elapsed time, and last error, rate-limited to one per 6 hours via the persisted `squidRouterStuckAlertedAt` (claimed by compare-and-set, so concurrent executions cannot double-alert). All monitor-related state writes (top-up marker claim and reconciliation, recovery/alert timestamps) patch single JSONB keys via `jsonb_set` instead of writing the whole `state` blob, so a stale execution's write cannot erase a marker a concurrent execution just persisted. A failure occurring after a successful status fetch (e.g. in gas funding) passes the fetched status to the failure-path monitor, so the alert classifies the real GMP state instead of reporting an API outage. The stuck check also runs on the status-API failure path so a Squid/axelarscan outage still alerts; individual status requests are bounded at 30 seconds and honor the processor's `AbortSignal` (`statusRequestSignal`), so a hung API request surfaces as a classifiable failure instead of stalling past the processor timeout. The alert's "action taken" field reports the recovery helper's actual outcome (broadcast, cooldown-skipped, unavailable, or failed). The monitor never marks the phase successful — completion still requires an executed status or arrived destination balance. -- `squidrouter-permit-execution-handler.ts` — Calls `TokenRelayer.execute()` with EIP-2612 permit + payload for off-ramp permit flows. Also handles the no-permit fallback path where the user's wallet submits the substituting transactions directly. +**Block executors:** +- `phases/blocks/phases/squid-router-swap/execution.ts` — Submits presigned approve + swap transactions on the source EVM chain. +- The same `phases/blocks/phases/squid-router-swap/execution.ts` module owns `squidRouterPay`: abort-aware Axelar status/arrival monitoring, gas payment, stuck-confirm recovery, deduplicated supplemental gas, and stuck alerts. Monitoring never marks the phase successful without executed status or destination arrival. +- `phases/blocks/phases/alfredpay-offramp/execution.ts` — Calls `TokenRelayer.execute()` with EIP-2612 permit + payload for Alfredpay off-ramp flows and handles no-permit user transactions. ### On-ramp flow (BRL onramp post-Nabla, e.g. Base USDC → user's Polygon ERC-20) @@ -30,10 +30,14 @@ It handles cross-chain swap execution, Axelar bridge status monitoring, and gas 5. Optional `backupSquidRouterApprove` / `backupSquidRouterSwap` on the destination chain if the bridged token (axlUSDC / USDC) needs further conversion to the user's requested output token. **F-054: these `backup*` presigned txs have no registered phase handler.** 6. `destinationTransfer` to the user. +For BRL or EUR onramps to a different token on Base after the Nabla output is USDC, the route is a same-chain swap rather than a bridge: `squidRouterApprove` → `squidRouterSwap` → `destinationTransfer`. The static block expands only to `squidRouterSwap`; it does not register `squidRouterPay`, prepare destination backup transactions, or run `finalSettlementSubsidy`. Transaction preparation selects the Base source builder, and the transfer uses the next nonce after the swap. Base USDC omits Squid entirely; EUR→Base EURC and BRL→Base BRLA are separate direct bypasses. + For quote metadata, Squid's `route.estimate.toAmount` is already denominated in the **destination token's raw units**. The bridge metadata (`evmToEvm.outputAmountRaw`, `moonbeamToEvm.outputAmountRaw`, etc.) MUST preserve that raw value directly instead of rebuilding it from the human-readable decimal amount with source-token decimals. This matters for routes like Base USDC (6 decimals) → BSC USDT (18 decimals), where using the source decimals would under-scale the metadata by 12 decimal places. The same invariant applies to routed Alfredpay onramps: even when the Squid source is the Polygon-minted Alfredpay token, `route.estimate.toAmount` remains authoritative for the destination token's raw units and `quote.outputAmount` must retain destination-token precision. ### Off-ramp flow (user EVM source → Base USDC) +For BRL and EUR, `BrlOfframpBase` and `EurOfframpBase` share the EVM source block: Base USDC skips Squid and issues one direct ERC-20 transfer blueprint; a different Base token requests a same-chain Squid route; another EVM source requests a cross-chain route to Base USDC. These are user-wallet transactions, never ephemeral presigns. The block `FundEphemeralExecutor` binds each reported hash to the issued signer, target, calldata, and value before any platform gas or subsidy is spent. + 1. User signs one of four paths (depending on source-token capabilities and direction): - **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`). @@ -44,9 +48,9 @@ For quote metadata, Squid's `route.estimate.toAmount` is already denominated in ### Skip-Squid trivial path -When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is already the requested output token. The route builder in `avenia-to-evm-base.ts` skips the `squidRouterApprove`/`squidRouterSwap`/`backup*` presigned transactions entirely and emits only a `destinationTransfer`. The quote engine `BaseSquidRouterEngine` (`squidrouter/index.ts`) emits 1:1 passthrough bridge meta with `networkFeeUSD = "0"` so downstream stages (discount, finalize) work without fetching a Squid route (which would fail with "same token same chain"). Discount engine (`onramp.ts`) and fee engine (`onramp-brl-to-evm.ts`) likewise short-circuit to a 1:1 rate / zero network fee in this case. +When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is already the requested output token. The block flow omits Squid transaction phases and emits only `destinationTransfer` after the prior Base phases. `simulateSquidRouterPassthrough` in `phases/blocks/phases/squid-router-swap/simulation.ts` emits 1:1 metadata with `networkFeeUSD = "0"` for flows that retain an explicit passthrough block, avoiding a Squid request that would fail with "same token same chain". Direct flows preserve a zero network fee without adding the Squid block. -**No security checks are bypassed by this path** — destination address validation runs in the quote `validate` step regardless; the only thing skipped is the Squid HTTP call. +**No security checks are bypassed by this path** — flow resolution and destination transaction preparation still validate the configured destination; the only thing skipped is the Squid HTTP call and its execution phases. ## Security Invariants @@ -61,11 +65,12 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is 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. +12. **Skip-Squid path MUST NOT lose destination validation** — The block catalog only selects the direct flow for its exact same-chain corridor, and `destinationTransfer` remains the final on-chain step. 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. +14. **Permit execution MUST confirm the owner's token balance before spending the single-use permit** — An EIP-2612 permit is single-use. `assertOwnerHasBalance` in `phases/blocks/phases/alfredpay-offramp/execution.ts` checks both direct-transfer and relayer paths and throws a recoverable error when the owner cannot cover `value`. Retries skip `permit()` when standing allowance already covers `value`. 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. -16. **Native-token offramps MUST NOT generate or await an ERC-20 approval** — Route construction emits only the Squid swap at nonce zero for native input. User-hash verification requires an approval only when an approval blueprint exists; the swap hash remains mandatory. +16. **Same-chain source builders and nonce topology MUST match the source network** — Base-internal BRL and EUR routes MUST use `createOnrampSquidrouterTransactionsFromBaseToEvm`, while Polygon-internal Alfredpay routes use the Polygon builder. Same-chain routes MUST omit bridge-pay and backup transactions, and `destinationTransfer` MUST be the first nonce after `squidRouterSwap`. +17. **Native-token offramps MUST NOT generate or await an ERC-20 approval** — Route construction emits only the Squid swap at nonce zero for native input. User-hash verification requires an approval only when an approval blueprint exists; the swap hash remains mandatory. ## Threat Vectors & Mitigations @@ -84,10 +89,10 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is | **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)` 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. | +| **Skip-Squid trivial-case manipulation** | The catalog selects the direct flow only for its exact same-chain token corridor before any transaction is generated. An attacker cannot force the direct flow for a routed destination. | | **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. | -**⚠️ FINDING F-CARRIED**: In `squid-router-phase-handler.ts` line 147, `getPublicClient()` defaults to Moonbeam if `inputCurrency` doesn't match any known case and logs "This is a bug." Same handler also catches errors and silently defaults to Moonbeam (line 151-152). This fallback could cause transactions to be submitted to the wrong network. +The removed input-currency-to-RPC fallback no longer exists. The block executor uses phase-owned `fromNetwork` metadata for source submission. ## Audit Checklist @@ -95,9 +100,9 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is - [x] Verify `Promise.any` correctly races bridge status check vs balance check. **PASS** — `AggregateError` handling confirmed. - [x] Verify `calculateGasFeeInUnits()` cannot produce negative or astronomically large values. **PASS** - [x] Verify `addNativeGas` call targets the correct Axelar gas service address (`0x2d5d7d31F671F86C782533cc367F14109a082712`) on the correct chain. **PASS** -- [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** +- [ ] 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`). +- [x] Source RPC selection uses phase-owned `fromNetwork`; there is no Moonbeam fallback. **PASS** — `phases/blocks/phases/squid-router-swap/execution.ts`. +- [x] `isSignedTypedDataArray` validation in `phases/blocks/phases/alfredpay-offramp/execution.ts` is 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** @@ -105,10 +110,10 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is - [x] `DEFAULT_SQUIDROUTER_GAS_ESTIMATE` (1,600,000) reasonable upper bound. **PASS** - [x] `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` cap is enforced. **PASS (FIXED F-001)** — `throw` added. - [x] `squidRouterPermitExecutionValue` validated before `msg.value`. **PASS (FIXED F-027)**. -- [PARTIAL] `sendTransactionWithBlindRetry` nonce safety. **PARTIAL** — by design. +- [ ] `sendTransactionWithBlindRetry` nonce safety. **PARTIAL** — by design. - [x] **FINDING F-063 (MEDIUM)**: SquidRouter slippage rejection (>2.5%) enforced. **PASS (FIXED)**. - [x] **No-permit fallback receipt validation**: `waitForUserHash` verifies receipt `from`, receipt `to`, and transaction `input` against the expected user address and presigned EVM transaction payload before advancing. -- [x] **Skip-Squid trivial path**: emits passthrough bridge meta in `BaseSquidRouterEngine` and short-circuits discount/fee engines. Destination address validated by quote engine `validate()`. **PASS** — no security checks bypassed. +- [x] **Skip-Squid trivial path**: the block catalog selects the direct flow for exact same-chain corridors; direct quote simulation preserves zero network fee and transaction preparation omits Squid phases. **PASS** — no security checks bypassed. - [x] **Destination-token raw output metadata**: `evmToEvm.outputAmountRaw` preserves Squid's `route.estimate.toAmount` in destination raw units, including routed Alfredpay onramps. **PASS** — prevents Base/Polygon 6-decimal source → BSC USDT-style 18-decimal destination under-scaling. - [x] **Squid 429 rate-limit retry**: exponential backoff. **PASS — verify backoff cap.** - [x] **Arrival timeout**: `waitUntilTrue` accepts a timeout argument. **PASS** — verify all callers pass a finite value. diff --git a/docs/security-spec/05-integrations/stellar-anchors.md b/docs/security-spec/05-integrations/stellar-anchors.md deleted file mode 100644 index 808020bb8..000000000 --- a/docs/security-spec/05-integrations/stellar-anchors.md +++ /dev/null @@ -1,59 +0,0 @@ -# Stellar Anchors Integration - -> **⚠️ FULLY DEPRECATED.** The Stellar-anchored off-ramp path (Spacewalk + Stellar payment) is no longer an active corridor. EUR has migrated to **Mykobo on Base** (see `mykobo.md`) and ARS has been removed entirely. The `spacewalkRedeem` and `stellarPayment` phase handlers are **not registered** in `register-handlers.ts`; presigned-transaction builders for these flows have been removed. This document is retained for historical reference and to document the security model of the prior implementation. **Do not treat any flow below as currently reachable.** - -## What This Does - -Stellar anchors were historically used for off-ramp flows that terminated on the Stellar network (EUR via wrapped EURC; ARS via the Anclap anchor). Both corridors are now removed. The historical flow bridged assets from Pendulum to Stellar via the Spacewalk bridge, then made a Stellar payment from the ephemeral account to the user's off-ramp destination. - -**Provider type:** Off-ramp (deprecated) -**Fiat currencies:** None active. EUR migrated to Mykobo on Base; ARS removed. -**Chains involved:** Pendulum (Nabla swap output) → Stellar (via Spacewalk bridge) → Stellar anchor -**Phase handlers (no longer registered):** -- `spacewalk-redeem-handler.ts` — Submitted a Spacewalk redeem request on Pendulum, then waited up to 10 minutes for tokens to arrive on the ephemeral Stellar account -- `stellar-payment-handler.ts` — Submitted the presigned Stellar payment transaction to Horizon, sending tokens from the ephemeral to the user's destination - -**Flow (off-ramp, historical):** -1. After Nabla swap on Pendulum, the output token (e.g., wrapped EURC) is held by the substrate ephemeral account -2. `spacewalkRedeem` phase: Calls a Spacewalk vault to redeem Pendulum-wrapped tokens for native Stellar tokens. The redeem extrinsic is presigned and submitted from the substrate ephemeral. The handler polls the Stellar ephemeral account balance until tokens arrive (1s polling, 10min timeout). -3. `stellarPayment` phase: Submits the presigned XDR transaction to Horizon. This transaction moves tokens from the Stellar ephemeral account to the user's Stellar address (the anchor's deposit address). - -**Key detail:** Stellar ephemeral accounts use 2-of-2 multisig. The presigned payment transaction is constructed at ramp creation time with a specific sequence number. If the sequence number has advanced (due to prior execution or crash recovery), the handler verifies whether the payment already succeeded by checking the ephemeral account's remaining balance. - -## Security Invariants - -1. **Stellar ephemeral MUST be funded and have the required trustline before Spacewalk redeem** — `isStellarEphemeralFunded()` check prevents redeems that would result in unclaimable claimable-balance operations. -2. **Stellar payment sequence number MUST be validated before Spacewalk redeem** — `validateStellarPaymentSequenceNumber()` ensures the presigned payment transaction will still be submittable after the redeem completes. -3. **Spacewalk redeem MUST use a presigned transaction** — The redeem extrinsic is signed at ramp creation and stored; the handler decodes and submits it. Server cannot forge different redeem parameters. -4. **Spacewalk nonce re-execution guard MUST prevent double-redeem** — If `currentEphemeralAccountNonce > executeSpacewalkNonce`, the handler skips re-submission and proceeds directly to waiting for Stellar balance. -5. **Recovery from `AmountExceedsUserBalance` MUST be treated as prior-execution** — This error indicates a previous redeem already consumed the Pendulum tokens. The handler waits for Stellar balance arrival instead of failing. -6. **Stellar payment MUST use the presigned XDR transaction** — The handler submits the transaction as-is to Horizon. No server-side modification of payment destination or amount. -7. **`tx_bad_seq` error MUST trigger payment verification** — If Horizon returns `tx_bad_seq`, the handler calls `verifyStellarPaymentSuccess()` to check whether tokens already left the ephemeral. Only transitions to `complete` if the ephemeral is empty. -8. **Stellar network passphrase MUST match deployment** — `SANDBOX_ENABLED` toggles between testnet and public network. Mismatch would cause transaction rejection. - -## Threat Vectors & Mitigations - -| Threat | Mitigation | -|---|---| -| **Redeem to unclaimable balance** — If the Stellar ephemeral doesn't exist or lacks a trustline, the Spacewalk vault creates a claimable balance that the system cannot claim | Pre-check via `isStellarEphemeralFunded()`. Fails the phase before submitting the redeem. | -| **Double-redeem burning Pendulum tokens** — A crash after redeem submission but before phase transition could cause re-execution | Nonce guard: `currentEphemeralAccountNonce > executeSpacewalkNonce` skips re-submission. `AmountExceedsUserBalance` catch also handles this. | -| **Stellar payment replay** — If the payment transaction is somehow re-submitted | Stellar sequence numbers prevent replay. Each transaction is valid for exactly one sequence number. | -| **Sequence number desync** — If another transaction is submitted to the ephemeral between presigning and execution, the payment sequence becomes invalid | `validateStellarPaymentSequenceNumber()` is called before the redeem. If it fails, the phase fails early rather than executing the redeem and leaving tokens stranded on Stellar without a valid payment. | -| **Vault liveness failure** — The Spacewalk vault fails to process the redeem and tokens remain locked on Pendulum | 10-minute polling timeout. If tokens don't arrive, the error propagates up and the phase processor retries. The vault must execute within the timeout. | -| **Horizon submission failure** — Network errors or Horizon downtime prevent payment submission | Errors are thrown (not swallowed), allowing the phase processor's retry mechanism to re-execute. | -| **Presigned transaction tampering** — Server-side modification of the Stellar payment XDR | XDR is stored as a signed transaction. Modifying it would invalidate the signature. Horizon will reject invalid signatures. | - -## Audit Checklist - -- [x] Verify `isStellarEphemeralFunded()` checks both account existence AND trustline for the specific Stellar asset. **PASS** — both checks confirmed in code. -- [x] Verify `validateStellarPaymentSequenceNumber()` compares the presigned sequence against the current account sequence on Stellar. **PASS** — sequence number comparison verified. -- [x] Verify the nonce re-execution guard: `currentEphemeralAccountNonce > executeSpacewalkNonce` correctly identifies a previously-executed redeem. **PASS** — guard logic correct. -- [x] Verify `AmountExceedsUserBalance` error recovery path does NOT re-submit the redeem — only waits for Stellar balance. **PASS** — catch block enters waiting path, no re-submission. -- [x] Verify `verifyStellarPaymentSuccess()` checks that tokens are genuinely gone from the ephemeral (not just that some arbitrary condition holds). **PASS** — checks remaining balance on ephemeral. -- [x] Verify `NETWORK_PASSPHRASE` is correctly derived from `SANDBOX_ENABLED` and matches the Horizon server URL. **PASS** — conditional logic maps sandbox flag to correct passphrase. -- [PARTIAL] Verify `HORIZON_URL` points to the correct Stellar network (public vs testnet). **PARTIAL F-025** — URL is configurable but no runtime validation that the URL matches the selected network passphrase. -- [x] Verify the Spacewalk redeem extrinsic is decoded from stored presigned data and not constructed on the server at execution time. **PASS** — extrinsic decoded from stored hex. -- [x] Verify the Stellar payment XDR is submitted as-is without server-side modification of destination or amount. **PASS** — XDR submitted unmodified to Horizon. -- [x] Verify `checkBalancePeriodically` timeout (10 minutes) is reasonable for Spacewalk vault execution times in production. **PASS** — 10-minute timeout appropriate for normal vault operations. -- [x] Verify no sensitive data (Stellar secret keys) is logged in error handlers. **PASS** — no secret key logging found. -- [PARTIAL] **@ts-ignore on line 72-73 of spacewalk-redeem-handler** — Verify the `.nonce.toNumber()` call returns the correct value; unchecked type assertions may hide API changes. **PARTIAL F-026** — `@ts-ignore` suppresses type checking; if the Spacewalk API changes the nonce type, the code would fail silently at runtime. diff --git a/docs/security-spec/06-cross-chain/bridge-security.md b/docs/security-spec/06-cross-chain/bridge-security.md deleted file mode 100644 index 4527c7b25..000000000 --- a/docs/security-spec/06-cross-chain/bridge-security.md +++ /dev/null @@ -1,56 +0,0 @@ -# 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. 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. - -**Key components:** -- `spacewalk-redeem-handler.ts` — Phase handler that submits the redeem extrinsic on Pendulum and waits for tokens on Stellar -- `createVaultService()` — Selects a vault based on asset code, issuer, and requested amount -- Presigned Stellar payment transaction — Moves tokens from the Stellar ephemeral to the user's destination after redeem -- Nonce guard — Prevents double-execution of the redeem extrinsic - -**Trust model:** Vortex trusts the Spacewalk bridge protocol and the selected vault to faithfully process redeems. The vault selection is automated based on available capacity. There is no Vortex-operated vault — all vaults are third-party operators. - -## Security Invariants - -1. **Vault selection MUST match the redeemed asset exactly** — `createVaultService()` filters vaults by `assetCode` and `assetIssuer`. A mismatch would send tokens to a vault that cannot redeem the correct Stellar asset. -2. **Vault MUST have sufficient capacity for the requested amount** — The vault selection logic checks available capacity. Requesting more than available capacity would fail the redeem or result in partial execution. -3. **Redeem extrinsic MUST be presigned** — The handler decodes and submits a presigned extrinsic from stored ramp state. The server cannot forge different redeem parameters (different vault, different amount, different destination) at execution time. -4. **Nonce guard MUST prevent double-redeem** — If `currentEphemeralAccountNonce > executeSpacewalkNonce`, the redeem has already been submitted. The handler skips re-submission and proceeds to wait for Stellar balance. -5. **`AmountExceedsUserBalance` MUST be treated as prior execution** — This Spacewalk error indicates the wrapped tokens were already burned (by a prior redeem attempt). The handler enters the waiting path instead of failing. -6. **Stellar ephemeral MUST be funded before redeem** — `isStellarEphemeralFunded()` verifies the Stellar ephemeral account exists and has the required trustline. Without this, the vault would create an unclaimable claimable-balance operation on Stellar. -7. **Bridge timeout MUST be enforced** — The handler polls Stellar ephemeral balance with a 10-minute timeout. If the vault fails to execute, the error propagates for retry. -8. **No Vortex-operated vaults** — All vaults are third-party. Vortex has no ability to guarantee vault liveness, honest execution, or collateral sufficiency beyond what the Spacewalk protocol enforces. - -## Threat Vectors & Mitigations - -| Threat | Mitigation | -|---|---| -| **Vault liveness failure** — Selected vault goes offline after redeem is submitted, tokens burned on Pendulum but never released on Stellar | Spacewalk protocol has a built-in timeout and vault collateral slash mechanism. If the vault doesn't execute within the protocol timeout, the redeemer can cancel the redeem and the vault's collateral is slashed. Vortex's 10-minute polling timeout causes the handler to fail (recoverable), allowing the phase processor to retry and eventually either succeed or escalate. | -| **Vault collateral insufficiency** — Vault doesn't have enough collateral to back the redeem, and the protocol allows it anyway | This is a Spacewalk protocol-level concern. If the protocol's collateral checks are insufficient, Vortex has no additional mitigation. The redeem could succeed nominally but the vault may default. | -| **Malicious vault** — Vault operator intentionally delays or fails to process redeems | Same collateral slash mechanism as liveness failure. The economic incentive (losing collateral) deters malicious behavior. Vortex cannot independently verify vault honesty beyond what Spacewalk enforces. | -| **Double-redeem burning tokens twice** — Crash after redeem submitted but before phase transition causes re-execution | Nonce guard and `AmountExceedsUserBalance` catch both prevent double-submission. The handler detects prior execution and skips to the waiting phase. | -| **Vault selection manipulation** — Attacker influences which vault is selected to route funds to a colluding vault | Vault selection is server-side using `createVaultService()`. An attacker would need server compromise to influence selection. The selection logic is deterministic based on asset and capacity. | -| **Stellar ephemeral not funded** — Redeem succeeds but tokens arrive as unclaimable balance on Stellar | `isStellarEphemeralFunded()` pre-check prevents this. Phase fails before the redeem extrinsic is submitted. | -| **Bridge protocol upgrade** — Spacewalk upgrades change redeem mechanics, breaking assumptions | Presigned extrinsics may become invalid after protocol upgrades. No automatic detection — requires manual monitoring of Spacewalk releases and parachain runtime upgrades. | -| **Claimable balance stuck** — If the pre-check is bypassed or has a bug, tokens end up as a claimable balance that the system cannot automatically claim | The current code has no claimable-balance recovery mechanism. Tokens would require manual intervention to recover from the Stellar ephemeral. | - -## Audit Checklist - -- [x] Verify `createVaultService()` filters by both `assetCode` AND `assetIssuer` — not just one. **PASS** — both fields used in vault selection filter. -- [x] Verify vault capacity check is performed before vault selection — not after. **PASS** — capacity checked during selection. -- [x] Verify the redeem extrinsic is decoded from stored presigned data, not constructed at execution time. **PASS** — decoded from stored hex. -- [x] Verify nonce guard: `currentEphemeralAccountNonce > executeSpacewalkNonce` correctly identifies prior execution. **PASS** — nonce guard logic verified. -- [x] Verify `AmountExceedsUserBalance` catch path does NOT re-submit the redeem — only enters the Stellar balance waiting loop. **PASS** — catch enters waiting path only. -- [x] Verify `isStellarEphemeralFunded()` checks both account existence AND the trustline for the specific Stellar asset being redeemed. **PASS** — both checks present. -- [x] Verify the 10-minute balance polling timeout is enforced and throws a recoverable error on expiry. **PASS** — timeout with recoverable error confirmed. -- [x] Verify no fallback to a default vault if the selected vault fails — the error should propagate, not silently pick another vault mid-execution. **PASS** — error propagates; no silent fallback. -- [PARTIAL] Verify Spacewalk protocol's vault slash/cancel mechanism is understood and documented for operational runbooks. **PARTIAL** — protocol mechanism understood but no operational runbook exists. -- [EXISTING FINDING] Verify the `@ts-ignore` annotations in `spacewalk-redeem-handler.ts` (lines 72-73) — check that `.nonce.toNumber()` returns the correct value and the type assertion hasn't hidden an API change. **EXISTING FINDING F-026** — `@ts-ignore` suppresses type safety; API changes would fail silently. -- [PARTIAL] Check whether Spacewalk has a maximum redeem amount per vault per transaction — if so, verify Vortex respects it. **PARTIAL** — vault capacity is checked but no explicit max-per-transaction enforcement verified at Spacewalk protocol level. -- [x] Verify there is no claimable-balance recovery mechanism — document as a known operational gap if absent. **PASS (confirmed absent)** — no recovery mechanism exists; documented as known gap. diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index d051312c9..260ccba40 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -4,21 +4,23 @@ Fund routing covers the mechanisms by which the platform ensures ephemeral accounts have the correct token amounts at each stage of a ramp. This includes **subsidization** (topping up ephemeral accounts with platform funds) and **final settlement** (transferring tokens from EVM ephemeral accounts to the user's destination). -There are now **five** subsidization-related phase handlers and one settlement phase, split between Substrate (Pendulum) and EVM (Base + legacy chains): +The block-flow catalog is production-authoritative. Native call value is declared structurally through `TxIntent.prefundNativeValueRaw`, aggregated per `(network, signer)` into `state.transactionPlan.nativePrefunding`, and consumed by `FundEphemeralExecutor`. Before broadcasting a Squid bridge, `SquidRouterSwapExecutor` snapshots the destination balance into `state.transactionPlan.settlementBaselines`. `SquidRouterPayExecutor` then persists route-bound completion evidence in `state.squidRouterDeliveryEvidence`: terminal Squid/Axelar status is preferred, while a destination-balance delta is an explicit fallback for provider-indexing gaps. `FinalSettlementSubsidyExecutor` validates that evidence against the source hash, destination network/token, expected amount, and baseline before using the structural baseline to distinguish delivery from pre-existing funds. -**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`). 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 +The block catalog owns the subsidization and settlement executors across Substrate (Pendulum) and EVM chains: -**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. +**Block executors:** +- `phases/blocks/phases/subsidize-pre/execution.ts` — Tops up the ephemeral before a Nabla swap to ensure it has the expected input amount +- `phases/blocks/phases/subsidize-post/execution.ts` — Tops up the ephemeral after a Nabla swap +- `phases/blocks/phases/final-settlement-subsidy/execution.ts` — Tops up an EVM ephemeral for cross-chain settlement, enforces `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, and records confirmed subsidies +- `phases/blocks/phases/destination-transfer/execution.ts` — Sends the presigned EVM transfer from the ephemeral to the user's destination address + +The pre/post executors dispatch by the block's chain context. The EVM pre-swap branch tops the ephemeral up before `nablaSwap` and enforces a quote-relative cap of the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`) × quote output. The EVM post-swap branch splits the required top-up into a swap-discrepancy component and a discount component: the actual-vs-quoted swap-output discrepancy is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output. Discount-derived top-ups below $1 bypass the separate runtime percentage safety cap; top-ups of $1 or more are capped by `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (default `0.05`). Quote-time partner `maxSubsidy` remains enforced for every amount. **How subsidization works:** 1. Read the ephemeral account's current balance 2. Compare against the expected amount (from ramp state metadata, e.g. `quote.metadata.nablaSwapEvm.inputAmountForSwapRaw` for pre-swap on the EVM branch) 3. If balance < expected, transfer the difference from the **funding account** (a platform-controlled account with pooled funds) -4. The funding account is derived from `FUNDING_SECRET` / `PENDULUM_FUNDING_SEED` (Pendulum/Stellar) or `EVM_FUNDING_PRIVATE_KEY` through `getEvmFundingAccount(network)` (EVM — used on **Moonbeam, Base, and any other EVM chain**; `MOONBEAM_EXECUTOR_PRIVATE_KEY` remains a backward-compatible fallback) +4. The funding account is derived from `PENDULUM_FUNDING_SEED` (Pendulum) or `EVM_FUNDING_PRIVATE_KEY` through `phases/blocks/core/evm-funding.ts` (EVM — used on **Moonbeam, Base, and any other EVM chain**; `MOONBEAM_EXECUTOR_PRIVATE_KEY` remains a backward-compatible fallback) **Why this matters for security:** Subsidization uses platform funds. If the amount calculations are wrong, the expected amounts are manipulated, or cap enforcement fails, the platform loses money. The funding accounts hold pooled assets — their compromise would affect all ramps, not just one. @@ -27,60 +29,60 @@ There are now **five** subsidization-related phase handlers and one settlement p The EVM funding key is used on **all EVM chains** the platform operates on: - Moonbeam (EUR/USD subsidization) - Base (BRL on/off-ramp pre/post-swap subsidization) -- Destination chain `backupApprove` spender for BRL on-ramp (`avenia-to-evm-base.ts`) +- Destination-chain `backupApprove` spender for routed onramps (`phases/blocks/phases/squid-router-swap/transactions.ts`) The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvmFundingAccount(network)` helper. The legacy Moonbeam-named env var is only a compatibility fallback and should be phased out operationally so the key's Base/EVM-wide blast radius stays visible. ## Security Invariants -1. **Subsidization MUST only top up to the expected amount, never more** — Both `subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` calculate `expectedAmount - currentBalance` and transfer exactly that difference. If the balance already meets or exceeds the expected amount, no transfer occurs. +1. **Subsidization MUST only top up to the expected amount, never more** — Both `subsidize-pre/execution.ts` and `subsidize-post/execution.ts` calculate `expectedAmount - currentBalance` and transfer exactly that difference. If the balance already meets or exceeds the expected amount, no transfer occurs. 2. **Expected amounts MUST come from ramp state set at creation time** — The expected input/output amounts are derived from the quote and stored in ramp state. Handlers read these values, not recalculate them. This prevents manipulation via price changes between quote and execution. 3. **Funding account private keys MUST only be used for subsidization transfers** — `getFundingAccount()` derives a keypair from `PENDULUM_FUNDING_SEED`. This keypair should only sign subsidization transfers, not arbitrary transactions. -4. **Final settlement subsidy MUST enforce a USD cap** — `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` limits the maximum value the platform will subsidize per EVM settlement. -5. **Destination transfer MUST use a presigned transaction** — `destination-transfer-handler.ts` submits the presigned transfer from state. The server cannot modify the recipient address or amount at execution time. +4. **Every final settlement subsidy MUST enforce a USD cap before funds move** — The full observable shortfall is converted from the destination token into USD and compared with `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, regardless of whether the funding wallet already holds the output token or first needs a swap. The swap-input check remains a second bound on routes that acquire the token. +5. **Destination transfer MUST use a presigned transaction** — `destination-transfer/execution.ts` submits the presigned transfer from state. The server cannot modify the recipient address or amount at execution time. 6. **Destination transfer MUST verify balance before submission** — The handler checks that the ephemeral has sufficient balance for the transfer. If insufficient, the phase fails rather than submitting a transaction that would revert. -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. +7. **Post-swap phase ordering MUST be deterministic** — the resolved block flow owns the phase sequence, and `subsidize-post/execution.ts` must not select a corridor-specific successor independently. 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 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. +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 before submitting a single transfer. The discrepancy cap always applies. The discount runtime percentage cap applies only when that component is at least $1; smaller discount components remain bounded by the quote-time partner `maxSubsidy`. 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 distinguish authoritative completion from the EVM balance fallback** — Terminal Squid/Axelar evidence is preferred. Because provider status can fail to index a transfer that did arrive, Squid-routed EVM flows may fall back to `settlementBaseline + floor(expectedBridgeOutput × 9000 / 10000)`. That threshold is route-scoped and persisted as `kind = destination-balance`; it is a bounded settlement heuristic, not proof of bridge finality. The subsidy remains clamped to `expected settlement balance - observed balance`. The percentage MUST NOT be reused for XCM, provider minting, or another bridge without a separate risk decision. +11. **Degenerate same-token routes MUST omit `finalSettlementSubsidy` from their cataloged flow** — Same-chain routes that need no bridge do not include the settlement phase. The phase itself must not infer corridor topology from legacy booleans. 12. **Subsidy cap currency conversions MUST fail closed** — Any USD-denominated subsidy cap check that depends on `PriceFeedService.convertCurrency()` must stop the phase if fiat/crypto price providers fail or return invalid rates. It must not continue with the original unconverted amount, because that can understate the USD value of a funding-account transfer. ## Threat Vectors & Mitigations | Threat | Mitigation | |---|---| -| **Final settlement subsidy cap bypass** — A missing or bypassed `throw` on the USD cap would allow a single ramp to drain the funding account's native token balance via an unbounded SquidRouter swap. | **Mitigated.** `final-settlement-subsidy.ts` throws when `requiredNativeInUsd > MAX_FINAL_SETTLEMENT_SUBSIDY_USD`; keep this as a regression check because the blast radius is direct funding-key loss. | +| **Final settlement subsidy cap bypass** — A direct token transfer could bypass a cap enforced only in the optional native-to-token acquisition branch. | **Mitigated.** `final-settlement-subsidy/execution.ts` values every positive shortfall in USD and enforces `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` before either a direct transfer or funding swap. The route-spend check remains defense in depth. | | **Funding account balance drain** — Repeated ramps with incorrect expected amounts could drain the funding account | Expected amounts are bound to the quote at creation time. An attacker cannot change them after the fact. However, a bug in quote calculation or a stale price could result in over-subsidization at scale. | | **Expected amount manipulation** — Attacker modifies ramp state to inflate expected amounts, causing the platform to over-subsidize | Ramp state expected amounts are set at creation and not modifiable via the API. An attacker would need database access. No DB-level constraint prevents modifying these values. | | **Funding key compromise** — Attacker obtains `PENDULUM_FUNDING_SEED` or `MOONBEAM_FUNDING_PRIVATE_KEY` | Full drain of the funding account. These keys should be rotated immediately on suspicion of compromise. There is no rate limiting on funding account transactions at the chain level. | | **SquidRouter swap manipulation in final settlement** — The SquidRouter swap (native → ERC-20) uses an API-provided route. If the SquidRouter API returns a malicious route, funds could be lost. | The handler trusts the SquidRouter API response. There is no independent verification that the swap output matches expectations. The 5-attempt retry loop could amplify losses if the route is consistently malicious. | | **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 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. | +| **Post-swap routing logic inconsistency** — A block flow orders a successor that does not match the ramp's intended flow | The resolved catalog flow owns a fixed executor sequence and the phase processor advances through that sequence. | +| **Final settlement over-subsidy race** — The balance fallback may observe 90% before an unindexed remainder arrives. | **Accepted, bounded heuristic (RISK-008).** The evidence records the exact route, source hash, baseline, destination, expected amount, threshold, and observation. Per-ramp settlement caps bound platform outflow; logs expose fallback use. Late-arrival recovery is not automatic. Provider-terminal evidence avoids this fallback where available. | | **Price-provider failure during cap conversion** — A subsidy handler cannot convert native-token requirements into USD and falls back to the unconverted amount, allowing a cap check to pass with the wrong unit. | **Mitigated.** `PriceFeedService.convertCurrency()` throws on provider failure; handlers must fail the phase rather than continue with unchecked unit assumptions. | ## Audit Checklist -- [x] **F-001 fixed**: `final-settlement-subsidy.ts` throws the cap error when `requiredNativeInUsd > MAX_FINAL_SETTLEMENT_SUBSIDY_USD`; the cap is enforced before the Squid swap is submitted. -- [x] Verify `subsidize-pre-swap-handler.ts` calculates subsidy as `expectedAmount - currentBalance` and transfers exactly that amount. **PASS** — difference calculation and exact transfer confirmed. -- [x] Verify `subsidize-post-swap-handler.ts` calculates subsidy the same way — no off-by-one, no rounding errors. **PASS** — same calculation pattern confirmed. +- [x] **F-001 fixed**: `final-settlement-subsidy/execution.ts` enforces `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` on every positive destination-token shortfall before any transfer, with an additional route-spend bound when a funding swap is needed. +- [x] Verify `phases/blocks/phases/subsidize-pre/execution.ts` calculates subsidy as `expectedAmount - currentBalance` and transfers exactly that amount. **PASS**. +- [x] Verify `phases/blocks/phases/subsidize-post/execution.ts` calculates subsidy the same way — no off-by-one, no rounding errors. **PASS**. - [x] Verify both pre/post swap handlers skip subsidization when `currentBalance >= expectedAmount` (no negative transfers). **PASS** — skip condition verified in both handlers. - [x] Verify `getFundingAccount()` derives the keypair from `PENDULUM_FUNDING_SEED` and this seed is not reused for other purposes. **PASS** — seed used only for funding account derivation. -- [FAIL] Verify `MOONBEAM_FUNDING_PRIVATE_KEY` is used only for EVM subsidization, not other Moonbeam operations. **FAIL F-029** — `MOONBEAM_FUNDING_PRIVATE_KEY` equals `MOONBEAM_EXECUTOR_PRIVATE_KEY`; same key used for funding, executor, legacy Monerium signing, Mykobo-related Base operations, and SquidRouter operations. With the BRL-on-Base and EUR-on-Base (Mykobo) flows this key is now also used for ephemeral subsidization on Base, BRLA + Mykobo EURC payouts on Base, and EVM fee distribution on Base — a single private key compromise drains funds across Moonbeam, Base, Polygon, and any other EVM chain in scope, including the dedicated BRLA and Mykobo payout paths. -- [x] Verify `destination-transfer-handler.ts` checks ephemeral balance before submitting the presigned transaction. **PASS** — balance check before submission confirmed. +- [ ] Verify `MOONBEAM_FUNDING_PRIVATE_KEY` is used only for EVM subsidization, not other Moonbeam operations. **FAIL F-029** — `MOONBEAM_FUNDING_PRIVATE_KEY` equals `MOONBEAM_EXECUTOR_PRIVATE_KEY`; same key used for funding, executor, legacy Monerium signing, Mykobo-related Base operations, and SquidRouter operations. With the BRL-on-Base and EUR-on-Base (Mykobo) flows this key is now also used for ephemeral subsidization on Base, BRLA + Mykobo EURC payouts on Base, and EVM fee distribution on Base — a single private key compromise drains funds across Moonbeam, Base, Polygon, and any other EVM chain in scope, including the dedicated BRLA and Mykobo payout paths. +- [x] Verify `phases/blocks/phases/destination-transfer/execution.ts` checks ephemeral balance before submitting the presigned transaction. **PASS**. - [x] Verify the presigned destination transfer is submitted as-is — no server-side modification of recipient or amount. **PASS** — presigned transaction submitted unmodified. -- [PARTIAL] Verify `final-settlement-subsidy.ts` SquidRouter swap: check that the swap input amount is bounded and that the swap output is verified against expectations. **PARTIAL** — input amount is capped (F-001 fixed); no output verification against expectations. -- [FAIL] Verify the 5-attempt retry loop in `final-settlement-subsidy.ts` does not retry on swap failures that indicate a malicious route (e.g., output far below expected). **FAIL F-030** — retry loop retries all failures uniformly; no distinction between transient errors and potentially malicious routes. -- [PARTIAL] Verify `subsidize-post-swap-handler.ts` next-phase routing logic covers all valid combinations of `direction`, `toChain`, and `outputTokenType` — no unhandled cases that silently proceed. **PARTIAL F-031** — routing logic covers known combinations but no default/exhaustive error for unhandled combinations. -- [FAIL] Verify funding account balance is checked before subsidization — insufficient balance should fail the phase, not silently skip. **FAIL F-032** — no pre-check of funding account balance; insufficient balance causes transaction revert at chain level, not a graceful phase error. -- [N/A] Check whether there is any monitoring or alerting on funding account balance depletion. **N/A** — no monitoring infrastructure audited. +- [x] `phases/blocks/phases/final-settlement-subsidy/execution.ts` bounds the funding swap input and rejects a Squid route whose estimated output is below 80% of the required subsidy before broadcast. **PASS** +- [x] Final-settlement funding uses one durable operation claim rather than the deleted five-attempt handler loop; an ambiguous broadcast is not automatically repeated. **PASS** +- [x] Post-swap routing is explicit in catalog flow composition; the subsidy executor does not select the next phase. **PASS**. +- [ ] Verify funding account balance is checked before subsidization — insufficient balance should fail the phase, not silently skip. **FAIL F-032** — no pre-check of funding account balance; insufficient balance causes transaction revert at chain level, not a graceful phase error. +- [ ] Check whether there is any monitoring or alerting on funding account balance depletion. **N/A** — no monitoring infrastructure audited. - [x] Verify `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` value is reasonable for the expected settlement amounts (check the constant's actual value). **PASS** — value reviewed and reasonable for expected settlement sizes. - [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] **EVM subsidy block executors enforce env-configured USD caps**. Pre-swap subsidy is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`) × quote output. Post-swap subsidy uses split caps: the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output for actual-vs-quoted swap-output discrepancy, and `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (default `0.05`) for discount components of at least $1. Sub-$1 discount components bypass the runtime percentage check but remain quote-capped by partner `maxSubsidy`. Over-cap subsidies throw `RecoverablePhaseError` before transfer. - [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` 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] **Squid completion evidence is explicit and route-bound.** **PASS** — provider-terminal evidence is preferred; the EVM balance fallback requires the snapshotted baseline plus 90% of the exact block output, persists the evidence kind and threshold, and is validated again by `FinalSettlementSubsidyExecutor`. +- [x] **Degenerate same-token routes omit `finalSettlementSubsidy`.** **PASS** — catalog composition, rather than legacy handler branching, keeps the phase out of routes with no bridge. - [x] Subsidy cap currency conversion fails closed on price-provider errors. **PASS** — `PriceFeedService.convertCurrency()` rethrows provider failures, so handlers do not continue with original unconverted amounts when cap conversions fail. - [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/06-cross-chain/xcm-transfers.md b/docs/security-spec/06-cross-chain/xcm-transfers.md index 3f9518e9a..3bbe008e1 100644 --- a/docs/security-spec/06-cross-chain/xcm-transfers.md +++ b/docs/security-spec/06-cross-chain/xcm-transfers.md @@ -2,64 +2,53 @@ ## What This Does -XCM (Cross-Consensus Messaging) is the inter-parachain transfer protocol used to move tokens between Polkadot parachains. Vortex uses XCM transfers across four chains: **Pendulum**, **Moonbeam**, **AssetHub**, and **Hydration**. These transfers are integral to both on-ramp and off-ramp flows — they shuttle tokens between chains where swaps, bridging, or final settlement occur. - -**Chains involved:** Pendulum, Moonbeam (EVM parachain), AssetHub (Polkadot system chain), Hydration (DEX parachain) - -**Phase handlers:** -- `moonbeam-to-pendulum-xcm-handler.ts` — XCM from Moonbeam to Pendulum using RPC submission with shuffle-based retry -- `moonbeam-to-pendulum-handler.ts` — Calls `executeXCM` on the Moonbeam receiver contract using the executor private key, waits for hash registration -- `pendulum-to-moonbeam-xcm-handler.ts` — XTokens transfer from Pendulum to Moonbeam with 3-tier recovery -- `pendulum-to-assethub-phase-handler.ts` — XTokens from Pendulum to AssetHub -- `pendulum-to-hydration-xcm-phase-handler.ts` — XTokens from Pendulum to Hydration, waits for balance arrival -- `hydration-swap-handler.ts` — Executes a presigned swap on Hydration DEX -- `hydration-to-assethub-xcm-phase-handler.ts` — XCM from Hydration to AssetHub, skips finalization - -**Key patterns across all handlers:** -- Presigned transactions are decoded from stored state and submitted from ephemeral accounts -- Recovery logic checks whether a prior attempt already succeeded before re-submitting -- Balance polling is used to confirm token arrival on the destination chain -- Phase transitions are returned to the processor, never directly mutated +XCM moves assets between Pendulum, Moonbeam, and AssetHub for the cataloged +BRL/AssetHub recovery flows. Public BRL↔AssetHub quote creation is currently +disabled, but the flows remain cataloged so persisted ramps have deterministic +transaction preparation and executors. The former Hydration topology is not +cataloged or registered. + +**Block phases:** +- `phases/blocks/phases/moonbeam-to-pendulum-xcm/` submits the presigned Moonbeam XCM, waits for source finalization, persists the finalized block hash, and waits for the phase-owned output amount on Pendulum. +- `phases/blocks/phases/avenia-pendulum-offramp/` submits Pendulum→Moonbeam XCM, waits for the trusted Avenia wallet balance, then runs the BRLA payout. +- `phases/blocks/phases/pendulum-to-assethub-xcm/` submits the presigned Pendulum XCM and persists its hash. +- `phases/blocks/phases/assethub-offramp-source/` prepares the AssetHub→Pendulum user-wallet blueprint; `FundEphemeralExecutor` verifies its authority fields before platform-funded phases run. + +Moonbeam XCM transaction intents declare `nonceSpan: 2`, preserving the next +usable Moonbeam nonce for cleanup. The AssetHub off-ramp keeps fee distribution, +Nabla, Pendulum→Moonbeam, and Pendulum cleanup in one contiguous Substrate +nonce sequence. ## Security Invariants -1. **Moonbeam→Pendulum XCM MUST use RPC shuffling on retry** — `moonbeam-to-pendulum-xcm-handler.ts` maintains a `submittedToRpcIndexes` array per ramp. On retry, it selects a different RPC node. When all RPCs are exhausted, it throws `RecoverablePhaseError` with a 30-minute wait to allow chain recovery. -2. **Moonbeam receiver contract `executeXCM` MUST only be callable by the executor key** — `moonbeam-to-pendulum-handler.ts` uses `MOONBEAM_EXECUTOR_PRIVATE_KEY` to call the receiver contract. This key is a server-side secret; the call cannot be forged by clients. -3. **Moonbeam receiver contract flow MUST verify hash registration before XCM** — The handler first waits for `getHashRegistered()` to return `true` for the pending nonce, confirming the split receiver contract has recorded the expected parameters. Only then does it call `executeXCM`. -4. **Pendulum→Moonbeam XCM MUST use 3-tier recovery** — (a) If transaction hash is stored, check Pendulum for success. (b) If tokens already left Pendulum, wait for Moonbeam arrival. (c) Only submit fresh if neither condition is met. This prevents double-XCM. -5. **Pendulum→Moonbeam MUST verify Moonbeam arrival with a 2-minute timeout** — After XCM submission, the handler polls the Moonbeam ephemeral balance. Timeout throws a recoverable error for retry. -6. **Hydration→AssetHub XCM MUST NOT wait for finalization** — `submitExtrinsic` is called with `waitForFinalization=false` because finalization does not work on Hydration. The handler proceeds after inclusion. **This means the transfer can theoretically be reverted by a chain reorganization.** -7. **Hydration→AssetHub MUST use nonce-based re-execution detection** — If `currentNonce > executeNonce`, the handler skips re-submission and transitions directly to `complete`. -8. **Hydration swap MUST use a presigned transaction** — The swap extrinsic is presigned at ramp creation and stored. The handler decodes and submits it. Server cannot modify swap parameters at execution time. -9. **All XCM handlers MUST treat already-executed transfers as success, not error** — Re-execution detection (nonce checks, balance checks, hash checks) must transition forward, never re-submit. -10. **Moonbeam→Pendulum handler retry loop MUST be bounded** — The handler retries `executeXCM` up to 5 attempts with 20-second delays. After exhaustion, the error propagates to the phase processor for higher-level retry. +1. **Moonbeam→Pendulum XCM MUST rotate RPCs after an execution error** — `MoonbeamToPendulumXcmExecutor` uses `ApiManager.getApiWithShuffling("moonbeam", rampId)` after a prior phase error and raises `RecoverablePhaseError` with a 30-minute wait when all RPC options are exhausted. +2. **Moonbeam→Pendulum MUST wait for its planned Pendulum amount** — The executor requires the phase-owned currency balance to reach `outputAmountRaw`; unrelated dust below that amount cannot suppress submission or advance the phase. +3. **Pendulum→Moonbeam MUST avoid duplicate submission when recovery evidence exists** — A persisted hash or source balance below the planned transfer suppresses a fresh submission. The balance-depletion inference is an inherited liveness risk documented in `INHERITED-ISSUES.md`. +4. **Pendulum→Moonbeam MUST verify Avenia-wallet arrival with a bounded timeout** — The executor polls for the planned BRLA amount for two minutes and raises a recoverable error on timeout. +5. **Pendulum→AssetHub is a disabled-corridor recovery exception** — A newly submitted transfer waits for source finalization and its `xTokens.TransferredMultiAssets` event before persisting the finalized block hash. Re-entry trusts that internally persisted hash and does not prove AssetHub arrival. This exception MUST remain quote-disabled under RISK-009; enabling the corridor requires destination receipt/balance-delta evidence and durable ambiguous-broadcast recovery. +6. **Moonbeam XCM nonce consumption MUST be represented structurally** — The transaction intent declares `nonceSpan: 2`; cleanup receives Moonbeam nonce 2 without a dummy transaction. +7. **Catalog presence MUST NOT bypass BRL↔AssetHub eligibility** — Either flow may resolve for persisted recovery and flow/transaction tests, but public quote creation rejects both directions while disabled. +8. **AssetHub→Pendulum authority MUST remain with the user** — `assethubToPendulum` is a server-issued blueprint signed and broadcast by the user's AssetHub wallet. It is not accepted as an ephemeral presigned transaction; the reported hash, blueprint network, and signer must be present before platform funding. +9. **AssetHub off-ramp Pendulum nonces MUST remain contiguous** — Fee distribution, Nabla approve/swap, and Pendulum→Moonbeam use nonces 0–3; post-complete Pendulum cleanup uses nonce 4. Backup extrinsics derive from the primary blueprints and must pass Substrate call-equivalence validation. ## Threat Vectors & Mitigations | Threat | Mitigation | |---|---| -| **Double XCM submission** — Crash after XCM sent but before phase transition causes re-execution on retry | Multi-tier recovery in all handlers: check transaction hash, check source balance depletion, check destination balance arrival before re-submitting. | -| **RPC node failure during Moonbeam→Pendulum** — Single RPC failure blocks the transfer | RPC shuffling: each retry uses a different RPC node. After all RPCs exhausted, 30-minute cooldown allows infrastructure recovery. | -| **Moonbeam receiver contract called with wrong parameters** — Executor key misused to call `executeXCM` with attacker-controlled parameters | The handler reads parameters from the stored ramp state (set at creation time). The executor key is server-side only. An attacker would need server compromise to manipulate the call. | -| **Hydration chain reorganization after non-finalized XCM** — Transfer included but reverted due to chain reorg | **KNOWN RISK**: No mitigation. Finalization is explicitly skipped ("doesn't work on Hydration"). A reorg could result in the ramp transitioning to `complete` while the XCM transfer was actually reverted. Probability depends on Hydration's block finality characteristics. | -| **Moonbeam→Pendulum blind retry loop** — 5 attempts × 20s delay = 100s of repeated contract calls that may all fail | After 5 attempts, the error propagates to the phase processor, which has its own retry budget (8 retries). Total retry surface is 5 × 8 = 40 attempts across all phase processor cycles. | -| **Balance polling false positive** — Token balance on destination matches expected amount due to unrelated deposit | Ephemeral accounts are single-use, so unrelated deposits are unlikely. However, if the ephemeral receives tokens from another source during the same ramp, the balance check cannot distinguish them. | -| **Nonce desync across chains** — Nonce used for re-execution detection is read from a stale state | Nonces are read from on-chain state at execution time (`getTransactionCount` / API queries), not from cached values. | -| **`MOONBEAM_EXECUTOR_PRIVATE_KEY` compromise** — Attacker can call `executeXCM` on the receiver contract | Receiver contract should validate that the caller is the authorized executor. If it does, compromise of the key allows XCM execution with arbitrary parameters. Scope of damage depends on what the receiver contract permits. | +| **Duplicate XCM after a crash** | Executors check destination balance, persisted hash, or source depletion before submitting. The evidence gaps are tracked in `INHERITED-ISSUES.md`. | +| **Moonbeam RPC failure** | Retry uses a shuffled Moonbeam RPC; exhaustion becomes a recoverable error with a 30-minute wait. | +| **False-positive Moonbeam→Pendulum destination balance** | The exact phase-owned `outputAmountRaw` is required instead of a positive-balance test. Ephemeral single-use further reduces unrelated deposits, although balance still does not cryptographically prove provenance. | +| **Pendulum→AssetHub submission fails after hash persistence** | **KNOWN RISK:** current executor does not verify source success or AssetHub arrival before advancing. | +| **Nonce desynchronization** | Flow-level transaction intents allocate contiguous lanes; transaction tests pin the expected nonces. | ## Audit Checklist -- [x] Verify `moonbeam-to-pendulum-xcm-handler.ts` RPC shuffling: `submittedToRpcIndexes` is persisted in ramp state across retries and correctly excludes already-tried RPCs. **PASS** — RPC index array persisted in ramp state. -- [x] Verify `RecoverablePhaseError` with `minimumWaitSeconds: 1800` (30 min) is thrown when all RPCs are exhausted. **PASS** — 30-minute wait confirmed when all RPCs tried. -- [x] Verify `moonbeam-to-pendulum-handler.ts` waits for `getHashRegistered()` before calling `executeXCM`. **PASS** — hash registration check precedes XCM execution. -- [x] Verify `MOONBEAM_EXECUTOR_PRIVATE_KEY` is used correctly — not leaked in logs, not passed to clients. **PASS** — key used only for signing; no log leakage found. -- [PARTIAL] Verify the Moonbeam receiver contract's `executeXCM` function validates the caller is the authorized executor (on-chain check, not just client-side). **PARTIAL** — cannot verify on-chain contract logic from application code alone; requires separate on-chain audit. -- [x] Verify `pendulum-to-moonbeam-xcm-handler.ts` 3-tier recovery: (a) hash check → (b) token departure check → (c) fresh submit, in that order. **PASS** — 3-tier recovery logic confirmed in correct order. -- [x] Verify Moonbeam balance polling uses a 2-minute timeout and throws recoverable error on expiry. **PASS** — 2-minute timeout with recoverable error confirmed. -- [x] **FINDING**: `hydration-to-assethub-xcm-phase-handler.ts` explicitly passes `false` for finalization wait — verify this is an accepted risk and document the reorg window. **PASS (accepted risk)** — finalization skip is intentional due to Hydration limitations; documented as known risk. -- [FAIL] Verify Hydration nonce re-execution guard: `currentNonce > executeNonce` correctly identifies a previously-executed transfer. **FAIL F-028** — nonce mismatch is logged as warning only; execution is NOT blocked. A stale nonce could cause re-execution. -- [x] Verify `hydration-swap-handler.ts` uses the presigned extrinsic from state — not constructed at execution time. **PASS** — extrinsic decoded from stored presigned hex. -- [x] Verify `pendulum-to-assethub-phase-handler.ts` transitions to `complete` — confirm this is the correct terminal phase for its flow. **PASS** — transitions to `complete` as expected. -- [x] Verify `pendulum-to-hydration-xcm-phase-handler.ts` waits for balance arrival on Hydration before transitioning to `hydrationSwap`. **PASS** — balance polling confirmed before phase transition. -- [x] Verify no XCM handler logs private keys, seeds, or full transaction payloads that could expose sensitive data. **PASS** — no sensitive data in logs. -- [PARTIAL] Verify `moonbeam-to-pendulum-handler.ts` blind retry (5 attempts, 20s delay) does not consume the phase processor's retry budget — each handler invocation counts as one phase processor attempt. **PARTIAL F-028** — the 5-attempt internal retry uses stale gas prices from initial fetch; no gas price refresh between retries. +- [x] Moonbeam→Pendulum retries use `getApiWithShuffling` after a prior phase error. **PASS** — `phases/blocks/phases/moonbeam-to-pendulum-xcm/execution.ts`. +- [x] Exhausted Moonbeam RPC options raise a recoverable error with a 30-minute wait. **PASS**. +- [x] Moonbeam→Pendulum waits for source finalization, persists its finalized block hash, and requires the Pendulum balance to reach the exact phase-owned output amount. **PASS**. +- [x] Pendulum→Moonbeam checks persisted hash/source depletion before fresh submission and waits up to two minutes for Avenia-wallet arrival. **PASS** — `phases/blocks/phases/avenia-pendulum-offramp/execution.ts`. +- [ ] Pendulum→AssetHub does not prove AssetHub arrival on re-entry. **ACCEPTED ONLY FOR QUOTE-DISABLED RECOVERY (RISK-009)** — this is a release blocker before re-enabling the corridor. +- [x] Moonbeam preparation reserves two nonces structurally. **PASS** — flow transaction tests. +- [x] Disabled BRL↔AssetHub quote eligibility is separate from catalog resolution. **PASS**. +- [x] AssetHub→Pendulum remains a user-wallet blueprint and is rejected as an ephemeral presign. **PASS**. +- [x] No active Hydration XCM/swap executor is cataloged or registered. **PASS**. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 4b43861d3..4114c5112 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -28,6 +28,12 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - The API returns `X-Request-ID` so clients can include it in support/debug reports. - Partner-facing quote/ramp/auth outcomes are recorded as sanitized operational events; see `07-operations/client-observability.md`. +**Unified API credentials** (`api/services/apiCredential.service.ts`): +- One `api_credentials` row contains the public value and secret digest/prefix for one profile subject and optional partner. +- Public clients send `X-Public-Key`; secret clients send `X-API-Key`. If both are present, they must resolve to the same credential or the API returns `403 CREDENTIAL_MISMATCH`. +- Public capability is limited to quote/widget attribution and the sanitized `GET /v1/ramp-info` projection. Exact limits, ramp details/history/errors, provider-account operations, mutations, and webhooks require secret or session capability. +- Startup runs schema/index/constraint checks and refuses to listen while any active legacy `api_keys` row remains. There is no legacy authentication fallback. + **Maintenance-window enforcement** (`middlewares/maintenanceGuard.ts`): - Active maintenance windows are sourced from the `maintenance_schedules` table via `MaintenanceService`. - During an active window, mutable quote/ramp operations return HTTP `503 Service Unavailable` before controller/service work starts. @@ -49,10 +55,17 @@ 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. +13. **Provider-backed ramp endpoints MUST reject callers without an effective user** — Alfredpay and Avenia/BRL flows derive their provider customer/subaccount from the Supabase session profile or `CredentialContext.profileId`, then `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-credential capability 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. 14. **Active customer-entity selection MUST be authenticated, owner-scoped, and immutable** — `PUT /v1/onboarding/active-entity` accepts only `individual` or `business`, locks the authenticated profile while selecting, and may bind only an active `customer_entities` row owned by that profile. An identical retry returns the existing selection. A different later type, an ownership mismatch, or multiple active owned entities of the requested type is rejected with `409`; no arbitrary row is selected. 15. **Legacy active-entity backfill MUST be unambiguous** — Migration 048 selects only one active entity that already owns provider or recipient data. Empty automatically-created individual entities do not force the selection. Profiles with multiple meaningful entities or no meaningful entity remain null and `GET /v1/onboarding/status` returns `selectionRequired: true`. 16. **Authenticated all-wallet ramp history MUST be user-scoped** — `GET /v1/ramp/history` requires a principal with an effective user and filters directly on `RampState.userId`. The endpoint MUST NOT accept a client-supplied owner ID, include null-owned or foreign-user ramps, infer ownership from a destination wallet or pricing partner, or fall back to partner-wide history for an unlinked partner key. The legacy `/v1/ramp/history/:walletAddress` route remains available under its existing user-or-partner ownership rules. +17. **Unified limit reads MUST require an effective user and bounded corridor input** — `POST /v1/limits` accepts only a non-empty, duplicate-free list drawn from `AR`, `BR`, `CO`, `MX`, and `US`; unknown request fields are rejected. Supabase Bearer tokens and user-linked secret keys are accepted through `requirePartnerOrUserAuth()`, while an unlinked partner key receives `403`. Provider identity, customer type, tax ID, and subaccount are derived server-side. +18. **Credential capability MUST be route-enforced** — `X-Public-Key` identifies a credential but cannot authorize exact limits, ramp state/history/errors, provider-account operations, webhooks, or mutations. Those operations require `X-API-Key` or a Supabase session according to the API-key capability matrix. +19. **Multiple credential representations MUST be consistent** — a quote-body/query `apiKey` and `X-Public-Key` must be equal; a public and secret header must resolve to the same immutable credential ID. Mismatch returns `403 CREDENTIAL_MISMATCH` before business logic. +20. **Credential management MUST be owner-scoped and bounded** — profile-managed `POST/GET/DELETE /v1/api-credentials` requires a Supabase session, permits at most five active non-expired credentials per profile under a profile-row lock, and revokes one whole credential by immutable ID with no DELETE body. +21. **Credential startup MUST fail closed** — the process must not listen unless the complete `api_credentials` schema, nullability, indexes, and constraints exist and active legacy `api_keys` count is zero. Runtime auth must not fall back to legacy rows, hashes, prefixes, or pairing heuristics. +22. **`ramp-info` MUST expose only a sanitized subject-derived projection** — `GET /v1/ramp-info` may accept public, secret, or session capability, but must derive the profile from validated context, accept no caller-selected profile/user/PII identifier, and return only per-corridor `kycStatus`, `canBuy`, and `canSell`. +23. **Managed-profile provisioning MUST use immutable associations** — `POST /v1/admin/managed-profiles` requires admin auth, normalizes email, and binds a genuine Supabase/profile identity to unique `(partner_id, external_user_id)` and unique `profile_id` records. Existing Auth identities may be reconciled only when their immutable metadata matches. Technical subjects must not receive customer entities or register ramps. ## Threat Vectors & Mitigations @@ -70,27 +83,35 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api | **Observability side effects** — Event persistence failure breaks a partner-facing API call | Observability helpers must catch persistence/logging errors and run best-effort only. See `client-observability.md`. | | **Direct API bypass of UI maintenance mode** — Partner SDK or custom API clients ignore the frontend and continue creating quotes or mutating ramps during planned downtime | Mutable quote/ramp routes run the maintenance guard server-side and fail closed with `503 Service Unavailable`, `Retry-After`, and the active window's start/end timestamps. | | **Cross-user history disclosure** — A caller requests account-wide ramp history and receives ramps belonging to another profile or to a pricing partner | The controller requires `getEffectiveUserId(req)` and the service query adds `RampState.userId = effectiveUserId`; wallet addresses and partner pricing never grant ownership. | +| **Public credential escalates to private data** — A browser-held public key is sent to limits, ramp diagnostics, or provider-account endpoints | Route middleware enforces the public/secret capability matrix; only the separately sanitized `ramp-info` projection is public-key-readable. | +| **Mixed credentials create confused-deputy attribution** — A caller combines one public key with another credential's secret key | Both resolve to immutable credential IDs and mismatch is rejected with `403` before downstream authorization or pricing. | +| **Partial production migration reaches traffic** — Legacy, unpaired, or malformed credential data remains after deployment | Startup verifies unified schema invariants and zero active legacy rows, then fails before listening. The rollout uses explicit immutable-ID manifests, never display names. | ## Audit Checklist -- [FAIL] **⚠️ FINDING F-035**: `bodyParser.json({ limit: "20mb" })` — verify this limit is intentional. Recommend reducing to 1-10MB for a JSON API. **FAIL F-035** — 20MB limit remains high for a JSON API. -- [FAIL] **FINDING F-036**: `staging--pendulum-pay.netlify.app` is in the production CORS whitelist — verify this is intentional and assess the risk of staging-site compromise. **FAIL F-036** — staging origin always in CORS whitelist regardless of `NODE_ENV`. -- [PARTIAL] **FINDING**: All validators are hand-written (no Zod/Joi) — verify every mutable endpoint has a corresponding validator middleware. **PARTIAL F-037** — hand-written validators exist but multiple sensitive endpoints lack authentication/validation entirely. +- [ ] **⚠️ FINDING F-035**: `bodyParser.json({ limit: "20mb" })` — verify this limit is intentional. Recommend reducing to 1-10MB for a JSON API. **FAIL F-035** — 20MB limit remains high for a JSON API. +- [ ] **FINDING F-036**: `staging--pendulum-pay.netlify.app` is in the production CORS whitelist — verify this is intentional and assess the risk of staging-site compromise. **FAIL F-036** — staging origin always in CORS whitelist regardless of `NODE_ENV`. +- [ ] **FINDING**: All validators are hand-written (no Zod/Joi) — verify every mutable endpoint has a corresponding validator middleware. **PARTIAL F-037** — hand-written validators exist but multiple sensitive endpoints lack authentication/validation entirely. - [x] Verify CORS does not use wildcard (`*`) or dynamic origin reflection — check `express.ts` for `origin: true` or callback patterns. **PASS** — explicit origin whitelist used; no wildcard or dynamic reflection. - [x] Verify rate limiting cannot be bypassed by removing or spoofing `X-Forwarded-For` headers — check how `express-rate-limit` identifies clients. **PASS** — `express-rate-limit` uses IP-based identification. - [x] Verify `Helmet` is configured with secure defaults — check for any disabled protections. **PASS** — Helmet enabled with default security headers. -- [N/A] Verify `NODE_ENV` is set to `"production"` in production — stack traces are only stripped when not in development mode. **N/A** — requires deployment configuration inspection. +- [ ] Verify `NODE_ENV` is set to `"production"` in production — stack traces are only stripped when not in development mode. **N/A** — requires deployment configuration inspection. - [x] Verify error responses do not include internal error types, database error codes, or SQL fragments. **PASS** — error handler wraps errors in generic `APIError` format. - [x] Verify the `errors` array in `APIError` contains only user-facing messages, not internal field names or database column names. **PASS** — error messages are user-facing validation messages. -- [x] Map all 34 TypeScript route files and verify each has appropriate auth middleware (Supabase, API key, admin, metrics dashboard, or public). **PASS** — F-013 resolved (legacy `/pendulum/fundEphemeral`, `/moonbeam/execute-xcm`, `/subsidize/*` endpoints removed); `/v1/ramp/*` and `/v1/ramp/quotes(/best)` use `requirePartnerOrUserAuth()` with ownership guards; `/v1/brla/*` uses `requireAuth`; `/v1/mykobo/profiles` (GET + POST) use `requireAuth` (F-068 resolved); `/v1/maintenance/*`, `/v1/admin/partners/:partnerName/api-keys`, `/v1/admin/profile-partner-assignments`, `/v1/admin/partner-pricing-configs`, and `/v1/admin/profile-roles` use `adminAuth`; `/v1/admin/api-client-events` uses `metricsDashboardAuth`; `/v1/webhook/*` uses `apiKeyAuth`. +- [x] Map all TypeScript route files and verify each has appropriate auth middleware (Supabase, API credential, admin, metrics dashboard, or public). **PASS** — `/v1/ramp/*` and quote routes use credential/session middleware with ownership guards; `/v1/api-credentials` uses `requireAuth`; admin partner credential routes use `adminAuth`; `/v1/admin/api-client-events` uses `metricsDashboardAuth`; `/v1/webhook/*` requires secret capability. - [x] Active customer-entity selection is Supabase-authenticated, serialized on the profile row, owner-scoped, idempotent for an identical retry, and rejects mutation or ambiguity. - [x] Verify no route accidentally uses `publicKeyAuth` (public key only, no secret key) for operations that should require `apiKeyAuth` (secret key). **PASS** — auth middleware usage reviewed per route. -- [N/A] Verify controllers do not pass raw `req.body` to database operations — check for Sequelize `.create(req.body)` or `.update(req.body)` patterns. **N/A** — deferred; requires comprehensive Sequelize usage audit. +- [ ] Verify controllers do not pass raw `req.body` to database operations — check for Sequelize `.create(req.body)` or `.update(req.body)` patterns. **N/A** — deferred; requires comprehensive Sequelize usage audit. - [x] Verify no endpoint returns `process.env`, server config, or internal paths in responses. **PASS** — no endpoint exposes internal configuration. -- [PARTIAL] Check whether Supabase auth cookies use `SameSite=Strict` or `SameSite=Lax` — and whether CSRF tokens are required for state-changing operations. **PARTIAL** — cookie parser enabled but cookie attributes not explicitly configured for `SameSite`. +- [ ] Check whether Supabase auth cookies use `SameSite=Strict` or `SameSite=Lax` — and whether CSRF tokens are required for state-changing operations. **PARTIAL** — cookie parser enabled but cookie attributes not explicitly configured for `SameSite`. - [x] Verify the 404 handler does not reveal Express version or framework information. **PASS** — custom 404 handler returns generic JSON error. - [x] Check all 27 route files for endpoints that accept file uploads — verify file size limits and type validation if present. **PASS** — no file upload endpoints found. - [ ] Verify request ID middleware runs before routes and returns `X-Request-ID` without using request IDs for authorization. - [ ] Verify partner-facing API observability writes are best-effort and cannot alter response status, response body, or quote/ramp state. - [x] Verify active maintenance windows are enforced by the backend on quote creation and ramp register/update/start, not only by frontend UI state. - [x] `GET /v1/ramp/history` precedes the dynamic `/:id` route, requires an effective user, and returns only non-initial ramps whose `RampState.userId` matches that user. HTTP tests cover multiple destination wallets, cross-user isolation, user-scoped API keys, anonymous rejection, and the `403` for a partner-only secret key (no partner-wide fallback). +- [x] Unified credential creation enforces five active non-expired rows per profile under a profile lock, and revocation updates one whole credential by ID. +- [x] Public/header/body and public/secret mismatches return `403 CREDENTIAL_MISMATCH`. +- [x] Backend startup checks the full unified schema and rejects any active legacy `api_keys` row before listening. +- [ ] Verify `GET /v1/ramp-info` backend routing, public/secret/session auth, rate limiting, and PII-negative tests. The shared and SDK contract exists, but the backend route is not represented in the current implementation. +- [x] Managed-profile provisioning requires admin auth, enforces immutable association/profile uniqueness and idempotency, rejects conflicting email reuse, creates individual/business entities, and blocks technical subjects from customer/ramp operations. diff --git a/docs/security-spec/07-operations/client-observability.md b/docs/security-spec/07-operations/client-observability.md index a3afb9f61..2878a5608 100644 --- a/docs/security-spec/07-operations/client-observability.md +++ b/docs/security-spec/07-operations/client-observability.md @@ -6,7 +6,7 @@ Backend client observability records sanitized operational events for partner-fa The observed surface includes: -- API key, public key, dual-auth, and ownership failures. +- Public/secret credential validation, credential mismatch, dual-auth, and ownership failures. - Quote create, best-quote create, and quote retrieval. - Ramp register, update, start, status, and error-log retrieval. - Request correlation through `X-Request-ID` / `X-Correlation-ID` and response `X-Request-ID`. @@ -22,17 +22,20 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev 3. **Secrets MUST NOT be logged or persisted** — `X-API-Key`, bearer tokens, secret API keys, provider credentials, private keys, seeds, ephemeral private material, and signed transaction payloads must not appear in logs or observability events. 4. **Sensitive user/payment data MUST NOT be logged or persisted** — Tax IDs, PIX destinations, QR codes, KYC data, bank details, and raw payment credentials must be excluded from observability metadata. 5. **Request correlation MUST be non-secret** — `requestId`, `quoteId`, and `rampId` may be stored for debugging, but they must not be used as high-cardinality metric labels. They are correlation identifiers, not authentication material. -6. **Partner attribution MUST use safe identifiers** — Events may store `partnerId`, `partnerName`, and short API key prefixes capped at 16 characters. Full secret keys and raw auth headers are forbidden. `partnerName` is a display/audit label only; it must not be treated as an authorization credential or runtime pricing key. +6. **Credential and partner attribution MUST use safe identifiers** — Events may store immutable `credentialId`, credential strength, `partnerId`, `partnerName`, endpoint/operation, and short key prefixes capped at 16 characters. Full public or secret values and raw auth headers are forbidden. `partnerName` is a display/audit label only; it must not be treated as credential-pairing evidence, an authorization credential, or a runtime pricing key. 7. **Operational metrics MUST remain low-cardinality** — Future metric exporters must group by bounded labels such as operation, partner, status, HTTP status, and error type. They must not label by user ID, wallet address, request ID, quote ID, ramp ID, tax ID, PIX key, or free-form request values. 8. **Event persistence SHOULD have automated retention before production operational use** — Raw operational events are useful for investigation but must not be retained indefinitely without aggregation or cleanup. The backend retention worker keeps the current UTC calendar day plus the previous six full UTC calendar days and removes older `api_client_events` rows on startup and daily. 9. **Client observability access MUST go through metrics-dashboard-authenticated backend APIs** — Internal consumers must call protected backend endpoints and must not ship database credentials, Supabase service-role keys, Metabase embed secrets, or other server-only credentials to client-side code. +10. **Credential mismatch MUST be observable without exposing values** — `CREDENTIAL_MISMATCH` events may identify the request, endpoint, and safe credential IDs/prefixes, but must not persist either full key value or combine the mismatched contexts into one authoritative subject. +11. **Startup credential failures MUST be operationally visible but fail closed** — missing schema elements, constraints, indexes, or active legacy-row counts must be logged without key values; observability failure must not allow the server to listen. +12. **Public `ramp-info` telemetry MUST remain sanitized** — events may record operation, outcome, credential ID/strength, safe prefix, duration, and HTTP status. They must not include the response projection, KYC details, profile selectors, provider identifiers, or exact limits. ## Threat Vectors & Mitigations | Threat | Mitigation | |---|---| | **Observability database leak** — An attacker gains read access to `api_client_events` | Store only minimal sanitized event fields and allowlisted request summaries. Do not persist secrets, raw request bodies, tax IDs, PIX data, KYC data, or private key material. Treat the table as operationally sensitive even after redaction. | -| **API key/header capture** — Instrumentation accidentally records `X-API-Key`, bearer tokens, or raw headers | Use an allowlist-shaped event schema and denylist sensitive metadata keys before persistence. Store only short 16-character API key prefixes when explicitly safe. | +| **API key/header capture** — Instrumentation accidentally records `X-API-Key`, `X-Public-Key`, bearer tokens, or raw headers | Use an allowlist-shaped event schema and denylist sensitive metadata keys before persistence. Store only immutable credential IDs and short 16-character prefixes when explicitly safe. | | **PII leakage through metadata** — Client-provided `additionalData` or error messages include tax IDs, PIX keys, or bank details | Do not persist nested metadata objects. Keep metadata scalar-only and sanitized. Pass only allowlisted request-derived fields to observability helpers; use counts or presence flags for arrays/objects such as presigned transactions, signing accounts, and `additionalData`. Truncate error messages and prefer stable `errorType` categories. | | **Business flow disruption** — Database/logging outage causes quote/ramp requests to fail | Observability writes are fire-and-forget/best-effort and catch their own errors. The request path must proceed exactly as it would without observability. | | **Missing correlation during incidents** — Operators cannot connect a partner report to backend logs | Generate or propagate `requestId` for all requests and return it via `X-Request-ID`. Persist request IDs alongside quote/ramp IDs when available. | @@ -41,6 +44,8 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev | **Unbounded telemetry retention** — Raw event rows grow indefinitely | Use the backend retention worker to delete `api_client_events` older than the 7-day UTC calendar retention window. The cleanup runs on startup and daily, uses advisory locking, and deletes in bounded batches. | | **Internal metrics client exposure** — An internal metrics consumer is reachable by outsiders | Require the dedicated backend metrics dashboard bearer token for all event data. Do not rely on obscurity of client URLs. | | **BI embed secret leak** — A future Metabase embed is generated in client-side code | Generate signed embed URLs only from the backend. Do not place Metabase signing secrets in publicly exposed environment variables. | +| **Mismatch logs leak two credentials** — Error instrumentation records both full presented halves | Emit `CREDENTIAL_MISMATCH` with safe IDs/prefixes only and never attach raw headers or request bodies. | +| **Public eligibility telemetry becomes a shadow profile store** — `ramp-info` events persist KYC state or provider details | Record only request outcome metadata; keep the response and all identity/provider details out of events. | ## Audit Checklist @@ -55,3 +60,6 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev - [ ] Verify future metric exporters do not use request ID, quote ID, ramp ID, user ID, wallet address, tax ID, or PIX key as metric labels. - [ ] Verify `GET /v1/admin/api-client-events` uses `metricsDashboardAuth` and returns only sanitized event fields. - [ ] Verify the API client events retention worker runs on backend startup and daily, and deletes `api_client_events` older than the 7-day UTC calendar retention window in bounded batches. +- [ ] Verify credential events use immutable credential ID/strength and safe prefixes without full `X-Public-Key` or `X-API-Key` values. +- [ ] Verify `CREDENTIAL_MISMATCH` records no mixed authoritative subject and no presented key values. +- [ ] Verify future `ramp-info` events omit KYC projection data, exact limits, profile selectors, and provider identifiers. diff --git a/docs/security-spec/07-operations/rebalancer.md b/docs/security-spec/07-operations/rebalancer.md index 9706dc948..5d1570c8d 100644 --- a/docs/security-spec/07-operations/rebalancer.md +++ b/docs/security-spec/07-operations/rebalancer.md @@ -209,23 +209,23 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- ### Shared -- [x] **FINDING**: State stored as JSON file in Supabase Storage — no locking, no atomic updates. Verify whether concurrent rebalancer instances are possible in the deployment configuration. **PASS (confirmed limitation)** — rebalancer is a one-shot CLI process (`process.exit(0/1)`); concurrency depends entirely on deployment scheduling (cron). No in-code concurrency guard. -- [PARTIAL] **FINDING**: `brlaBusinessAccountAddress` has hardcoded default `0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2` — verify this is the correct BRLA business account and that it's set via environment variable in production. **PARTIAL** — address is overridable via env var but has hardcoded default; correctness of default requires external verification. +- [ ] **FINDING**: State stored as JSON file in Supabase Storage — no locking, no atomic updates. **ACCEPTED RISK RISK-012** — rebalancer is a one-shot CLI process (`process.exit(0/1)`); concurrency depends entirely on deployment scheduling (cron). No in-code concurrency guard. +- [ ] **FINDING**: `brlaBusinessAccountAddress` has hardcoded default `0xDF5Fb34B90e5FDF612372dA0c774A516bF5F08b2` — verify this is the correct BRLA business account and that it's set via environment variable in production. **PARTIAL** — address is overridable via env var but has hardcoded default; correctness of default requires external verification. - [x] Verify Supabase Storage write errors are handled — what happens if state cannot be persisted after a phase completes? **PASS** — errors propagate and cause process exit; no silent data loss. -- [PARTIAL] Verify the rebalancer has monitoring/alerting for: failed phases, insufficient balances, stuck state. **PARTIAL** — `process.exit(1)` on failure provides signal for external monitoring, but no built-in alerting. Slack notifications on completion provide some visibility. +- [ ] Verify the rebalancer has monitoring/alerting for: failed phases, insufficient balances, stuck state. **PARTIAL** — `process.exit(1)` on failure provides signal for external monitoring, but no built-in alerting. Slack notifications on completion provide some visibility. - [x] Verify no rebalancer secrets are logged (check all error handlers and debug logging). **PASS** — no secret logging found. - [x] Check whether the rebalancer runs on a schedule (cron) or is triggered manually — determines concurrency risk. **PASS** — one-shot CLI process; concurrency controlled by external scheduler. - [x] Verify the `StateManager` handles missing or corrupted state files gracefully (fresh start vs crash). **PASS** — missing state treated as fresh start; `upsert: true` for writes; invalid JSON treated as missing with console warning. ### Legacy flow (BRLA ↔ axlUSDC) -- [x] **FINDING**: 5% slippage tolerance hardcoded in Nabla swap — verify this is acceptable for expected rebalancing amounts. **PASS (confirmed limitation)** — 5% is generous but acceptable for the current rebalancing volumes; documented as known risk. -- [x] **FINDING**: `gasMultiplier * 5n` applied to `maxFeePerGas` — verify this doesn't cause excessive gas overpayment in production. **PASS (confirmed limitation)** — aggressive multiplier ensures inclusion; overpayment risk accepted for reliability. +- [ ] **FINDING**: 5% slippage tolerance hardcoded in Nabla swap. **ACCEPTED CURRENT POLICY** — generous but accepted for current rebalancing volumes; changing volume requires review. +- [ ] **FINDING**: `gasMultiplier * 5n` applied to `maxFeePerGas`. **ACCEPTED CURRENT POLICY** — aggressive inclusion policy with overpayment exposure. - [x] Verify legacy coverage trigger is appropriate for the expected token volumes. **PASS** — legacy flow still checks BRLA over-coverage while USDC.axl is not over-covered before starting. - [x] Verify the rebalancer private keys are distinct from all API service keys. **PASS** — separate env vars and accounts confirmed. -- [PARTIAL] Verify step idempotency: can each of the 8 steps be safely re-executed after a crash? Check for nonce guards, balance checks, or transaction hash verification. **PARTIAL F-033** — steps 2, 3, 5, 6, 7 are NOT idempotent; crash between step execution and `saveState()` causes double-spend risk. -- [PARTIAL] Verify the BRLA→USDC swap (step 3) validates the received USDC amount against expectations. **PARTIAL** — BRLA API response is trusted; no independent amount verification. -- [FAIL] Verify the SquidRouter swap (step 5) validates the received axlUSDC amount against expectations. **FAIL F-034** — no output amount validation AND Axelar status polling has no timeout; infinite loop risk if Axelar never reports success. +- [ ] Verify step idempotency: can each of the 8 steps be safely re-executed after a crash? Check for nonce guards, balance checks, or transaction hash verification. **PARTIAL F-033** — steps 2, 3, 5, 6, 7 are NOT idempotent; crash between step execution and `saveState()` causes double-spend risk. +- [ ] Verify the BRLA→USDC swap (step 3) validates the received USDC amount against expectations. **PARTIAL** — BRLA API response is trusted; no independent amount verification. +- [ ] Verify the SquidRouter swap (step 5) validates the received axlUSDC amount against expectations. **FAIL F-034** — no output amount validation AND Axelar status polling has no timeout; infinite loop risk if Axelar never reports success. ### Base flows @@ -233,7 +233,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - [x] **FINDING**: Daily bridge limit check — `REBALANCING_DAILY_BRIDGE_LIMIT_USD` (default 10,000) enforced against both Base-flow histories plus the current requested amount for paid runs. **PASS** — checked after quote/cost-policy evaluation and before fresh Base side effects for non-profitable quotes. Projected-profitable current runs bypass the cap and are still recorded in history after completion. - [x] **FINDING**: Opportunistic in-range trigger — Base coverage inside configured bounds can still run USDC→BRLA→USDC only when projected route cost is below `REBALANCING_OPPORTUNISTIC_USDC_TO_BRLA_MAX_COST_BPS` (default 10 bps). **PASS** — uses the same quote/cost-policy path with zero coverage deviation, then applies the configured opportunistic cap before balance checks and state-machine execution. Opportunistic Avenia fallback to SquidRouter is blocked unless the preflight SquidRouter quote independently passes the same policy and profitable-bypass requirements. - [x] **FINDING**: Avenia fallback to SquidRouter — if Avenia ticket creation fails, flow falls back to SquidRouter route. **PASS** — error caught, `winningRoute` updated, state saved atomically. -- [x] **FINDING**: `EVM_ACCOUNT_SECRET` single mnemonic for all EVM chains — broad EVM blast radius across Base, Polygon, and Moonbeam. **PASS (accepted)** — deliberate simplification; documented in invariants. +- [ ] **FINDING**: `EVM_ACCOUNT_SECRET` is one mnemonic for all EVM chains. **ACCEPTED CURRENT ARCHITECTURE** — deliberate broad Base/Polygon/Moonbeam blast radius. - [x] Verify route comparison handles partial failures — what happens if one provider's quote fails? **PASS** — if every enabled route fails, throws; otherwise uses the best available route. If `--route=` is specified, only fetches that quote. - [x] Verify NonceManager re-initialization on resume — does it fetch fresh nonce from chain? **PASS** — `NonceManager.create()` calls `getTransactionCount()` on each execution. - [x] Verify BRLA balance arrival tolerance is appropriate. **PASS** — Avenia BRLA uses a 95% threshold for provider-side deductions; on-chain Base/Polygon arrivals use 99.8% to account for rounding and minor route deductions while rejecting significant shortfalls. @@ -241,7 +241,7 @@ bun run start [amount] [--legacy] [--restart] [--route=squidrouter|avenia|nabla- - [x] Verify `waitForBrlaOnAvenia` has a timeout. **PASS** — 10-minute timeout with 5-second poll interval. - [x] Verify `waitUsdcOnBase` has a timeout. **PASS** — 30-minute timeout via `checkEvmBalancePeriodically`. - [x] Verify `waitBrlaOnPolygon` has a timeout. **PASS** — 10-minute timeout via `checkEvmBalancePeriodically`. -- [PARTIAL] Verify the Nabla swap validates output amount against expectations. **PARTIAL** — uses `AMM_MINIMUM_OUTPUT_HARD_MARGIN` (5%) for slippage protection via `quoteSwapExactTokensForTokens`, but post-swap balance is verified by comparing pre/post BRLA balance (not against the quote). A sandwich attack could extract up to 5%. +- [ ] Verify the Nabla swap validates output amount against expectations. **PARTIAL** — uses `AMM_MINIMUM_OUTPUT_HARD_MARGIN` (5%) for slippage protection via `quoteSwapExactTokensForTokens`, but post-swap balance is verified by comparing pre/post BRLA balance (not against the quote). A sandwich attack could extract up to 5%. - [x] Verify the `usdcBasePhaseOrder` overlap (`AveniaTransferToPolygon` and `AveniaSwapToUsdcBase` both at order 6; both wait phases at order 7) cannot cause incorrect phase transitions. **PASS** — routes are mutually exclusive, guarded by `if (state.winningRoute === "avenia")` / `if (state.winningRoute === "squidrouter")` checks. - [x] Verify Base flow arrival checks are delta-based. **PASS** — Avenia BRLA, Polygon BRLA, Avenia USDC-on-Base, and SquidRouter USDC-on-Base waits all use persisted pre-action baselines plus expected deltas. Avenia BRLA transfer recovery also uses the persisted Avenia baseline before resending. Base USDC waits use the default 99.8% tolerance and persist the actual received delta before final verification. - [x] Verify Nabla swap resume cannot lose the received BRLA amount. **PASS** — pre-swap BRLA baseline and swap hash are persisted; resume computes output from the persisted baseline or reuses already recorded output. diff --git a/docs/security-spec/07-operations/secret-management.md b/docs/security-spec/07-operations/secret-management.md index 41db8fa0d..e2957a404 100644 --- a/docs/security-spec/07-operations/secret-management.md +++ b/docs/security-spec/07-operations/secret-management.md @@ -12,7 +12,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a | Secret | Purpose | Blast Radius | |---|---|---| -| `FUNDING_SECRET` | Stellar funding account keypair | Drain of Stellar funding pool — affects all Stellar off-ramps | +| `FUNDING_SECRET` | **Removed/obsolete** — was the Stellar funding account keypair; Stellar/Spacewalk support was removed (migration 028). Unset in deployments; no code reads it. | None (obsolete) | | `PENDULUM_FUNDING_SEED` | Pendulum funding account seed | Drain of Pendulum funding pool — affects all subsidization | | `MOONBEAM_EXECUTOR_PRIVATE_KEY` | Calls `executeXCM` on Moonbeam receiver contract | Unauthorized XCM execution on Moonbeam — could route funds incorrectly | | `MOONBEAM_FUNDING_PRIVATE_KEY` | EVM subsidization transfers across all EVM chains in scope (Moonbeam, Base, Polygon, etc.); BRLA payouts on Base; EVM fee distribution on Base | Drain of EVM funding pool on every supported EVM chain — including BRLA payout path on Base | @@ -63,7 +63,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a | Threat | Mitigation | |---|---| -| **Server compromise — full secret exfiltration** — Attacker gains shell access to the API server | **All secrets are exposed.** There is no HSM, no secrets manager, no encryption at rest for env vars. Blast radius includes: all funding accounts (Stellar, Pendulum, Moonbeam), all database access, admin access, all third-party API keys. The only mitigation is infrastructure hardening (firewalls, SSH hardening, monitoring). | +| **Server compromise — full secret exfiltration** — Attacker gains shell access to the API server | **All secrets are exposed.** There is no HSM, no secrets manager, no encryption at rest for env vars. Blast radius includes: all funding accounts (Pendulum, Moonbeam, Base), all database access, admin access, all third-party API keys. The only mitigation is infrastructure hardening (firewalls, SSH hardening, monitoring). | | **Environment variable leak via error page or debug endpoint** — Misconfigured error handler dumps `process.env` | Express error handler strips stack traces in non-development mode. However, there is no explicit guard against dumping environment variables. A bug in error handling could expose secrets. | | **Ephemeral webhook keys after restart** — Without `WEBHOOK_PRIVATE_KEY`, webhook signatures change on every restart | Webhook consumers lose the ability to verify signatures from the previous instance. This is a reliability issue, not a direct security vulnerability, but it could cause consumers to reject legitimate webhooks or accept unverified ones (if they fall back to no-verification). | | **Credential rotation requires redeployment** — No runtime rotation mechanism | To rotate any secret, the environment variable must be updated and the service restarted. During the rotation window, the old secret may still be valid (e.g., API keys at third parties). There is no way to do zero-downtime rotation. | @@ -75,17 +75,17 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a ## Audit Checklist -- [x] **FINDING**: No secrets manager — all secrets are plain environment variables with no encryption at rest, no access logging, no rotation automation. **PASS (confirmed)** — this is the current architecture; documented as known limitation. +- [ ] **FINDING**: No integrated secrets manager — all secrets are environment variables with no application-level access logging or rotation automation. **ACCEPTED RISK RISK-011**. - [x] **FINDING**: `WEBHOOK_PRIVATE_KEY` generates ephemeral RSA key if missing — verify this env var is set in production. **PASS (confirmed)** — ephemeral key generation behavior verified in code; production configuration is an operational concern. -- [x] **FINDING**: No secret rotation mechanism — verify operational procedures exist for emergency rotation (which services to restart, which third-party dashboards to update). **PASS (confirmed)** — no rotation mechanism exists; documented as known gap. +- [ ] **FINDING**: No dual-secret or automated rotation mechanism. **ACCEPTED RISK RISK-011** — emergency rotation remains operational. - [x] Verify no secrets are hardcoded in source code — search for patterns like `private_key =`, `secret =`, `password =` in `.ts` files. **PASS** — no hardcoded secrets found in source code search. - [x] Verify no secrets appear in log output — check all `console.log`, `logger.info`, `logger.error`, `logger.debug` calls in handlers that use secrets. **PASS** — no secret values logged in handler code. - [x] Verify `SUPABASE_SERVICE_KEY` is never sent to the frontend or included in API responses. **PASS** — service key used server-side only. -- [N/A] Verify database credentials (`DB_*`) are not accessible from outside the VPC/private network. **N/A** — requires infrastructure audit, not code audit. +- [ ] Verify database credentials (`DB_*`) are not accessible from outside the VPC/private network. **N/A** — requires infrastructure audit, not code audit. - [x] Verify the `.env.example` file does not contain real secret values (only placeholder/dummy values). **PASS** — example files contain placeholder values only. - [x] Verify `.env` is in `.gitignore` — no secret files committed to the repository. **PASS** — `.env` in `.gitignore`. - [x] Verify the rebalancer's three chain keys are different from the API's funding keys — not the same private key reused. **PASS** — separate env var names and documented as separate accounts. -- [N/A] Verify `ADMIN_SECRET` entropy — is it a randomly generated string of sufficient length (>= 32 characters)? **N/A** — requires production configuration inspection. +- [ ] Verify `ADMIN_SECRET` entropy — is it a randomly generated string of sufficient length (>= 32 characters)? **N/A** — requires production configuration inspection. - [x] Verify no API endpoint returns environment variables or server configuration to clients. **PASS** — no endpoint exposes `process.env` or server config. - [x] Check whether `GOOGLE_PRIVATE_KEY` contains newlines that might be mis-parsed — a common issue with PEM keys in env vars. **PASS** — PEM key handling present; standard env var parsing. - [x] Map the full blast radius: if the API server is compromised, list every account, service, and database that becomes accessible. **PASS (comprehensive)** — full blast radius documented in the Secret Inventory table above. diff --git a/docs/security-spec/AUDIT-RESULTS.md b/docs/security-spec/AUDIT-RESULTS.md deleted file mode 100644 index 754d980b4..000000000 --- a/docs/security-spec/AUDIT-RESULTS.md +++ /dev/null @@ -1,1126 +0,0 @@ -# Security Audit Results — Code vs Spec - -> **Started:** 2026-04-02 | **Completed:** 2026-04-02 | **Auditor:** Automated + Manual Review -> -> Each section corresponds to a spec file. Checklist items are marked: -> - `[PASS]` — Code matches spec -> - `[FAIL]` — Code deviates from spec (new finding or confirmation of existing) -> - `[PARTIAL]` — Partially meets spec, needs attention -> - `[N/A]` — Not verifiable from code alone (requires runtime/infra check) -> -> For full finding descriptions, code snippets, and CTO decisions, see [FINDINGS.md](FINDINGS.md). - ---- - -## 00 — System Overview / Architecture - -**Spec:** `00-system-overview/architecture.md` - -#### 1. `[PASS]` Every route has appropriate auth middleware -Originally a critical gap (multiple ramp/quote/BRLA/maintenance/webhook routes were unauthenticated). Resolved: legacy `pendulum/fundEphemeral`, `moonbeam/execute-xcm`, and `subsidize/*` routes were removed; all `/v1/ramp/*` and `/v1/ramp/quotes(/best)` endpoints now use `requirePartnerOrUserAuth()` (sk_ partner key OR Supabase Bearer) with ownership guards; `requireAuth`/`adminAuth`/`apiKeyAuth` cover the remaining sensitive routes. → [F-013](FINDINGS.md) - -#### 2. `[FAIL]` No controller directly accesses `process.env` for secrets -`PENDULUM_FUNDING_SEED` accessed directly via `process.env` in `pendulum.service.ts`, bypassing centralized config. Other violations are low-severity (URL configs, non-critical API keys). → [F-016](FINDINGS.md) - -#### 3. `[PASS]` Ephemeral key secrets never appear in API request/response payloads or logs -Clients send `signingAccounts` (addresses only). No private keys in request/response schemas or logs. - -#### 4. `[PASS]` Phase processor always reads fresh state from DB before executing a phase -Fresh `RampState.findByPk(rampId)` on every `processRamp()` call. Lock mechanism prevents concurrent modification (though non-atomic — F-003). - -#### 5. `[FAIL]` All external API calls have timeout configuration -Most external `fetch()` calls (Mykobo, BRLA, CoinGecko, Moonpay, Transak, AlchemyPay, Slack, Subscan) lack `AbortController`/timeout. Only `webhook-delivery.service.ts` has a 30s timeout. → [F-014](FINDINGS.md) - -#### 6. `[PARTIAL]` Error responses never leak internal state, stack traces, or secret material -Stack traces stripped in production. However, raw `err.message` from internal errors passed to API responses in some paths. → [F-015](FINDINGS.md) - -#### 7. `[N/A]` Database connection uses TLS in production -No explicit SSL/TLS in Sequelize config. Depends on database hosting (e.g., Supabase enforces TLS at server level). → [F-017](FINDINGS.md) - -#### 8. `[PASS]` Rate limiting is applied at the network edge before auth middleware -Rate limiter applied before routes in middleware chain. - -#### 9. `[PASS]` CORS configuration restricts origins to known frontend domains -Static origin whitelist. No wildcard, no dynamic reflection. Staging origin always present (tracked as F-036). - -#### 10. `[PASS]` Rebalancer keys are distinct from API server keys -Different env var names and separate config files. - -### Architecture Audit Summary - -| # | Check | Result | -|---|---|---| -| 1 | All routes have auth middleware | ✅ PASS — F-013 resolved | -| 2 | No direct `process.env` in controllers | 🔴 FAIL — F-016 | -| 3 | Ephemeral keys not in payloads/logs | ✅ PASS | -| 4 | Phase processor reads fresh state | ✅ PASS | -| 5 | External API calls have timeouts | 🟠 FAIL — F-014 | -| 6 | Error responses don't leak internals | 🟡 PARTIAL — F-015 | -| 7 | Database uses TLS | ❓ N/A — F-017 | -| 8 | Rate limiting before auth | ✅ PASS | -| 9 | CORS restricts to known origins | ✅ PASS | -| 10 | Rebalancer keys distinct | ✅ PASS | - -### New Findings from Architecture Audit - -| ID | Severity | Summary | -|---|---|---| -| F-013 | ✅ RESOLVED | Multiple security-sensitive endpoints had no authentication middleware (now strict dual-track auth + ownership guards) | -| F-014 | 🟠 HIGH | Most external HTTP `fetch()` calls lack timeout — hanging services can stall ramp processing | -| F-015 | 🟡 MEDIUM | Raw `err.message` from internal errors passed to API responses | -| F-016 | 🟡 MEDIUM | `PENDULUM_FUNDING_SEED` accessed directly via `process.env` in service file | -| F-017 | 🔵 LOW | Database TLS not explicitly configured in Sequelize options | - ---- - -## 01 — Auth / Supabase OTP - -**Spec:** `01-auth/supabase-otp.md` - -#### 1. `[PASS]` `requireAuth` applied to all protected endpoints -Resolved alongside F-013. `/v1/ramp/*` endpoints now require either `X-API-Key: sk_*` (partner) or `Authorization: Bearer` (Supabase user) via `requirePartnerOrUserAuth()`; `/v1/brla/*` user-data endpoints use `requireAuth`; `adminAuth` and `apiKeyAuth` cover maintenance and webhook routes respectively. - -#### 2. `[PASS]` `optionalAuth` only where unauthenticated access is intentionally allowed -Used on ramp `/register`, quote creation, BRLA KYC — all reasonable uses. - -#### 3. `[FAIL]` `verifyToken()` uses service role key, not anon key -Uses anon-key Supabase client. Functionally correct (server-side verification happens regardless), but deviates from spec. → [F-018](FINDINGS.md) - -#### 4. `[PASS]` `Bearer ` prefix check includes trailing space -Correct `startsWith("Bearer ")` with `substring(7)` extraction. - -#### 5. `[PASS]` `req.userId` only set by auth middlewares -Only `requireAuth` and `optionalAuth` set `req.userId`. - -#### 6. `[PASS]` Error responses contain no token fragments or internal details -Generic error messages only: "Missing or invalid authorization header", "Invalid or expired token", "Authentication failed". - -#### 7. `[PASS]` `optionalAuth` truncates tokens in warning logs -First 15 chars + "..." + last 4 chars. - -#### 8. `[FAIL]` Supabase config validated at startup -`SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_KEY` default to `""` with no startup validation. Service starts but auth silently fails. → [F-019](FINDINGS.md) - -#### 9. `[PASS]` Token expiry enforced by verification call -Supabase server-side verification checks JWT `exp` claim. - -#### 10. `[PARTIAL]` No `optionalAuth` misuse -BRLA KYC endpoints use `optionalAuth` for user-specific resources — questionable but not a standalone finding. - -### Supabase OTP Audit Summary - -| # | Checklist Item | Result | -|---|---|---| -| 1 | `requireAuth` on all protected endpoints | ✅ PASS — F-013 resolved | -| 2 | `optionalAuth` only where intended | ✅ PASS | -| 3 | `verifyToken()` uses service role key | 🔵 FAIL — F-018 | -| 4 | `Bearer ` prefix check correct | ✅ PASS | -| 5 | `req.userId` only set by auth middleware | ✅ PASS | -| 6 | Error responses leak no data | ✅ PASS | -| 7 | Token truncation in logs | ✅ PASS | -| 8 | Supabase config validated at startup | 🟡 FAIL — F-019 | -| 9 | Token expiry enforced | ✅ PASS | -| 10 | No `optionalAuth` misuse | 🟡 PARTIAL | - -### New Findings from Supabase OTP Audit - -| ID | Severity | Summary | -|---|---|---| -| F-018 | 🔵 LOW | `verifyToken()` uses anon-key client instead of service-role client | -| F-019 | 🟡 MEDIUM | No startup validation for Supabase config — empty defaults, auth silently fails | - ---- - -## 01 — Auth / API Keys - -**Spec:** `01-auth/api-keys.md` - -#### 1. `[PARTIAL]` All endpoints requiring partner auth use `apiKeyAuth` or `enforcePartnerAuth` -`enforcePartnerAuth()` is commented out on quote routes. Anyone can pass a `partnerId` without the corresponding secret key. Known design decision. - -#### 2. `[PASS]` Secret key validation uses bcrypt -Only comparison path: `bcrypt.compare(apiKey, keyRecord.keyHash)`. - -#### 3. `[PASS]` Public key validation never returns auth credentials -Returns `partnerName` or `null` — never credentials. - -#### 4. `[PASS]` `getKeyType()` correct -`pk_` → public, `sk_` → secret, else → `null`. - -#### 5. `[PASS]` Regex patterns match documented format -`/^(pk|sk)_(live|test)_[a-zA-Z0-9]{32}$/` — anchored, exact match. - -#### 6. `[PASS]` `generateApiKey()` uses `crypto.randomBytes(32)` -Cryptographically secure key generation. - -#### 7. `[PASS]` `hashApiKey()` uses bcrypt with salt rounds ≥ 10 -`saltRounds = 10`. - -#### 8. `[PASS]` Expiration check handles null `expiresAt` -Null check before comparison — no expiration if unset. - -#### 9. `[PASS]` `enforcePartnerAuth` returns 403 -Correct 403 response. Code is currently unreachable (commented out on only route). - -#### 10. `[PASS]` Partner name comparison is case-sensitive -Strict equality (`!==`), no normalization. - -#### 11. `[PASS]` No secret keys in query parameters or request body -`apiKeyAuth` reads exclusively from `X-API-Key` header. - -#### 12. `[PARTIAL]` Error codes don't reveal validation step -`PARTNER_MISMATCH` error includes `authenticatedPartnerName` and `requestedPartnerName` — moderate information disclosure. - -### API Key Audit Summary - -| # | Checklist Item | Result | -|---|---|---| -| 1 | Partner-auth endpoints use apiKeyAuth/enforcePartnerAuth | 🟡 PARTIAL — `enforcePartnerAuth` commented out | -| 2 | Secret keys use bcrypt | ✅ PASS | -| 3 | Public keys don't grant auth | ✅ PASS | -| 4 | `getKeyType()` correct | ✅ PASS | -| 5 | Regex matches format | ✅ PASS | -| 6 | `generateApiKey()` uses crypto.randomBytes | ✅ PASS | -| 7 | bcrypt salt rounds ≥ 10 | ✅ PASS | -| 8 | Expiration handles null | ✅ PASS | -| 9 | `enforcePartnerAuth` returns 403 | ✅ PASS | -| 10 | Partner name case-sensitive | ✅ PASS | -| 11 | No sk\_ in query/body | ✅ PASS | -| 12 | Error codes don't reveal validation step | 🟡 PARTIAL | - -### New Findings from API Key Audit - -No new standalone findings. Commented-out `enforcePartnerAuth` and partner name leak in error response are design observations. - ---- - -## 01 — Auth / Admin Auth - -**Spec:** `01-auth/admin-auth.md` - -#### 1. `[PASS]` `adminAuth` on all admin endpoints -`router.use(adminAuth)` applied globally on admin route file. The maintenance toggle gap previously cross-referenced under F-013 has been closed. - -#### 2. `[PASS]` Only `safeCompare` used for comparison -No `===` or `==` comparison of token. - -#### 3. `[EXISTING FINDING]` `safeCompare()` leaks secret length -Early return on length mismatch. → [F-010](FINDINGS.md) - -#### 4. `[PARTIAL]` `config.adminSecret` validated at startup -Runtime check returns 500 when empty, but no startup validation. Service starts normally with empty `adminSecret`. Analogous to F-019. - -#### 5. `[PASS]` No admin endpoint accepts other auth as fallback -Only `adminAuth` is imported and applied. - -#### 6. `[PASS]` Admin endpoints not reachable from public frontend -CORS allows all origins for all routes, but auth middleware is the actual protection. Acceptable. - -#### 7. `[N/A]` `ADMIN_SECRET` ≥ 32 characters -Deployment config check. No minimum length enforced in code. - -#### 8. `[PASS]` No logging middleware captures full Authorization header -Morgan doesn't log auth headers. Auth middleware truncates tokens in logs. - -#### 9. `[PASS]` Error response reveals nothing about secret -Generic "Invalid admin token" message. - -#### 10. `[FAIL]` Admin auth errors logged with request metadata -Successful rejections (invalid token, missing header) produce **no server-side log**. Only exceptions are logged. → [F-020](FINDINGS.md) - -### Admin Auth Audit Summary - -| # | Checklist Item | Result | -|---|---|---| -| 1 | `adminAuth` on all admin endpoints | ✅ PASS | -| 2 | Only `safeCompare` used | ✅ PASS | -| 3 | `safeCompare` length leak | ⚠️ EXISTING F-010 | -| 4 | `adminSecret` validated at startup | 🟡 PARTIAL | -| 5 | No fallback auth | ✅ PASS | -| 6 | Admin not reachable from frontend | ✅ PASS | -| 7 | `ADMIN_SECRET` ≥ 32 chars | ❓ N/A | -| 8 | No full auth header logging | ✅ PASS | -| 9 | Error reveals nothing | ✅ PASS | -| 10 | Failed auth logged | 🟡 FAIL — F-020 | - -### New Findings from Admin Auth Audit - -| ID | Severity | Summary | -|---|---|---| -| F-020 | 🟡 MEDIUM | Failed admin auth attempts (401/403) produce no server-side logs | - ---- - -## 02 — Signing Keys - -### 02a — Ephemeral Accounts - -**Spec:** `02-signing-keys/ephemeral-accounts.md` - -#### 1. `[PASS]` Ephemeral key generation is SDK/frontend only -No production code in `apps/api` generates ephemeral keys. Only test files reference generation functions. - -#### 2. `[PASS]` Ramp registration only accepts addresses -`AccountMeta` type contains `{ address, type }` — no private key field. - -#### 3. `[N/A]` Stellar ephemeral multisig (2-of-2 thresholds) -Deferred to Module 05 (Stellar transaction construction). - -#### 4. `[PASS]` Stellar ephemeral starting balance is bounded -`2.5 XLM`, `0.1 PEN`, `1 GLMR`, `1.5 MATIC` — all reasonably bounded constants. - -#### 5. `[PASS]` `storeEphemeralKeys` writes to local filesystem only -Pure `fs/promises.writeFile` — no network calls. - -#### 6. `[FAIL]` Ephemeral addresses validated for format -`normalizeAndValidateSigningAccounts()` validates `account.type` but **never validates `account.address`** — no format, length, or chain-specific checks. → [F-021](FINDINGS.md) - -#### 7. `[PASS]` No API code logs/persists ephemeral private keys -API only handles addresses and presigned transactions. - -#### 8. `[PASS]` `generateEphemerals()` produces fresh keypairs -No caching, memoization, or static references. - -#### 9. `[PASS]` Unsigned transactions bound to specific ephemeral addresses -Transaction construction uses registered `signingAccounts` addresses. - -#### 10. `[PARTIAL]` API checks if EVM ephemeral address is an EOA -No `getCode()` check. Low practical risk (self-harm scenario). - -### Ephemeral Accounts Audit Summary - -| # | Checklist Item | Result | -|---|---|---| -| 1 | Ephemeral key gen is SDK-only | ✅ PASS | -| 2 | Registration accepts addresses only | ✅ PASS | -| 3 | Stellar 2-of-2 multisig | ↗️ Deferred to Module 05 | -| 4 | Starting balance bounded | ✅ PASS | -| 5 | `storeEphemeralKeys` local only | ✅ PASS | -| 6 | Ephemeral addresses validated | ❌ FAIL — F-021 | -| 7 | No private keys logged/persisted | ✅ PASS | -| 8 | Fresh keypairs each call | ✅ PASS | -| 9 | Transactions bound to addresses | ✅ PASS | -| 10 | EVM EOA check | 🟡 PARTIAL | - -### New Findings from Ephemeral Accounts Audit - -| ID | Severity | Summary | -|---|---|---| -| F-021 | 🟡 MEDIUM | No address format validation for ephemeral accounts | - ---- - -### 02b — Server-Side Signing Keys - -**Spec:** `02-signing-keys/server-side-signing.md` - -#### 1. `[PARTIAL]` `FUNDING_SECRET` purpose separation -Also aliased as `SEP10_MASTER_SECRET` — same key for funding and Stellar web authentication. → [F-022](FINDINGS.md) - -#### 2. `[PASS]` `PENDULUM_FUNDING_SEED` used only for funding ephemerals -Used in `subsidize.controller.ts` and `pendulum.service.ts` for funding/subsidization only. Dual access path noted (F-016). - -#### 3. `[PARTIAL]` `MOONBEAM_EXECUTOR_PRIVATE_KEY` purpose -Also aliased as `MOONBEAM_FUNDING_PRIVATE_KEY`. One key handles all platform EVM operations. Intentional design decision. - -#### 4. `[PASS]` `initializeKeys()` called exactly once at startup -Called once in `initializeApp()`. Singleton pattern ensures one instance. - -#### 5. `[PASS]` `getPrivateKey()` is `private` -Not accessible from outside `CryptoService`. - -#### 6. `[PASS]` `getPublicKey()` is the only key-exposure method -No method returns the private key. `signPayload()` returns a signature. - -#### 7. `[PASS]` Missing `WEBHOOK_PRIVATE_KEY` triggers warning log -Falls back to in-memory key generation with logged warning. - -#### 8. `[PASS]` RSA key generation uses 2048-bit modulus -Confirmed `modulusLength: 2048`. - -#### 9. `[PASS]` Signing uses RSA-PSS with SHA-256 and max salt -All three parameters confirmed. - -#### 10. `[PASS]` No server key in responses/logs/errors -Only derived public keys and addresses exposed. Error messages are generic. - -#### 11. `[PASS]` Missing mandatory keys → startup failure -`validateRequiredEnvVars()` checks `PENDULUM_FUNDING_SEED` and `MOONBEAM_EXECUTOR_PRIVATE_KEY`. Missing → `process.exit(1)`. - -#### 12. `[N/A]` Funding/executor accounts hold minimal balances -Operational check — cannot verify from code. - -#### 13. `[N/A]` Monitoring/alerts for balance changes -No monitoring infrastructure in codebase. - -### Server-Side Signing Audit Summary - -| # | Checklist Item | Result | -|---|---|---| -| 1 | `FUNDING_SECRET` single-purpose | 🟡 PARTIAL — F-022 (SEP10 alias) | -| 2 | `PENDULUM_FUNDING_SEED` funding only | ✅ PASS | -| 3 | `MOONBEAM_EXECUTOR_PRIVATE_KEY` single-purpose | 🟡 PARTIAL — aliased as funding key | -| 4 | `initializeKeys()` called once | ✅ PASS | -| 5 | `getPrivateKey()` is private | ✅ PASS | -| 6 | Only `getPublicKey()` exposes material | ✅ PASS | -| 7 | Missing webhook key logs warning | ✅ PASS | -| 8 | RSA 2048-bit | ✅ PASS | -| 9 | RSA-PSS + SHA-256 + max salt | ✅ PASS | -| 10 | No keys in responses/logs | ✅ PASS | -| 11 | Missing keys → exit | ✅ PASS | -| 12 | Minimal balances | ❓ N/A | -| 13 | Balance monitoring | ❓ N/A | - -### New Findings from Server-Side Signing Audit - -| ID | Severity | Summary | -|---|---|---| -| F-022 | 🟡 MEDIUM | `SEP10_MASTER_SECRET` aliased to `FUNDING_SECRET` — key separation violated | - ---- - -## 03 — Ramp Engine - -### 03a — State Machine (Phase Processor) - -**Spec:** `03-ramp-engine/state-machine.md` - -#### 1. `[EXISTING FINDING]` Lock acquisition is non-atomic -Check-then-set pattern with no `SELECT FOR UPDATE` or CAS. → [F-003](FINDINGS.md) - -#### 2. `[EXISTING FINDING]` Infinite soft loop after max retries -After max retries, counter is cleared → resets to 0 on next processing cycle → indefinite retries. → [F-004](FINDINGS.md) - -#### 3. `[PASS]` `state.update()` restricted to `currentPhase`/`phaseHistory` -`{ fields: ["currentPhase", "phaseHistory"] }` prevents accidental overwrite of other columns. - -#### 4. `[PASS]` Terminal states halt recursion and clean up retries -Both `complete` and `failed` call `retriesMap.delete()` with no recursive call. - -#### 5. `[PASS]` 10-minute timeout enforced via `Promise.race` -`RecoverablePhaseError` on timeout. `clearTimeout` in `finally`. - -#### 6. `[PASS]` `MAX_RETRIES` (8) not bypassed -No code path resets counter during retry loop. Caveat: resets across cycles (F-004). - -#### 7. `[PASS]` `minimumWaitSeconds` respected -Used if provided, otherwise 30-second fallback. - -#### 8. `[PASS]` `phaseHistory` append-only -Spread operator creates new array with existing entries plus new one. - -#### 9. `[PASS]` Error logs include all required fields -Stack trace, error message, phase, recoverability flag, timestamp all present. - -#### 10. `[PASS]` No handler mutates `currentPhase` directly -Handlers update operational state only. Phase transitions exclusively via processor. - -#### 11. `[PASS]` `lockedRamps` Set cleaned up in `finally` -`releaseLock()` called in `finally` block. - -#### 12. `[PASS]` Lock expiry handles edge cases -Missing timestamp, invalid date, and normal case all handled. - -#### 13. `[PASS]` Phase processor is singleton -Private static instance with `getInstance()`. Default export is singleton. - -### State Machine Audit Summary - -| # | Checklist Item | Result | -|---|---|---| -| 1 | Lock non-atomic | ⚠️ EXISTING F-003 | -| 2 | Infinite soft loop | ⚠️ EXISTING F-004 | -| 3 | Update restricted to phase fields | ✅ PASS | -| 4 | Terminal states halt + cleanup | ✅ PASS | -| 5 | 10-min timeout | ✅ PASS | -| 6 | MAX_RETRIES not bypassed | ✅ PASS | -| 7 | minimumWaitSeconds respected | ✅ PASS | -| 8 | phaseHistory append-only | ✅ PASS | -| 9 | Error logs complete | ✅ PASS | -| 10 | No handler mutates currentPhase | ✅ PASS | -| 11 | lockedRamps cleanup | ✅ PASS | -| 12 | Lock expiry edge cases | ✅ PASS | -| 13 | Singleton | ✅ PASS | - -No new findings. F-003 and F-004 confirmed as previously documented. - ---- - -### 03b — Quote Lifecycle - -**Spec:** `03-ramp-engine/quote-lifecycle.md` - -#### 1. `[PASS]` Fees calculated server-side, no client override -Quote pipeline calculates all fees in `BaseFeeEngine`. No fee parameters accepted from client. - -#### 2. `[PASS]` Quote expiry hardcoded to 10 minutes -Hardcoded literal `10 * 60 * 1000`. No client parameter or config overrides it. - -#### 3. `[PASS]` `discountStateTimeoutMinutes` ≠ quote expiry -Controls partner `difference` adjustment, not `QuoteTicket.expiresAt`. Separate mechanisms. - -#### 4. `[PASS]` Quote consumed atomically with ramp creation -Both operations share same DB transaction. `WHERE status = 'pending'` ensures single-use. - -#### 5. `[PASS]` `deltaDBasisPoints` step size reasonable -0.3 / 10000 = 0.003% per step. Would take 5+ hours of continuous quoting to accumulate 0.01%. - -#### 6. `[N/A]` Dynamic difference caps -Database values — requires DB review. - -#### 7. `[EXISTING FINDING]` Dynamic pricing state is in-memory only -Module-level `Map` — lost on restart. → [F-012](FINDINGS.md) - -#### 8–9. `[N/A]` Min/max dynamic difference DB constraints -Database schema check needed. - -#### 10. `[PASS]` Exchange rates from live on-chain sources -Core swap rate from Nabla DEX (on-chain). Oracle price from Nabla oracle. - -#### 11. `[PASS]` Quote response doesn't leak discount internals -`QuoteResponse` excludes `adjustedDifference`, `adjustedTargetDiscount`, subsidy internals. - -#### 12. `[PASS]` Quote amounts immutable after creation -Only `status` updated (consumed) or quote destroyed (expired). No amount modification. - -#### 13. `[PARTIAL]` Authentication on quote creation -`optionalAuth` + `validatePublicKey` + `apiKeyAuth({ required: false })`. Intentional — SDK creates quotes before login. - -#### 14. `[PARTIAL]` Quote ownership verified at registration -No strict ownership check, but mitigated by UUID unpredictability + 10min expiry + single-use. - -#### 15. `[PASS]` Subsidy only when `targetDiscount > 0` -Ternary returns `Big(0)` when discount is 0. - -#### 16. `[PASS]` `calculateSubsidyAmount` cap correct -`maxSubsidy × expectedOutput` correctly caps the shortfall. - -#### 17. `[PASS]` `resolveDiscountPartner` fallback to "vortex" -Falls back to `DEFAULT_PARTNER_NAME = "vortex"` when partner not found. - -#### 18. `[N/A]` Monitoring for high subsidization -No monitoring infrastructure. - -### Quote Lifecycle Audit Summary - -| # | Checklist Item | Result | -|---|---|---| -| 1 | Fees server-side | ✅ PASS | -| 2 | Expiry hardcoded 10 min | ✅ PASS | -| 3 | discountStateTimeout ≠ expiry | ✅ PASS | -| 4 | Atomic quote consumption | ✅ PASS | -| 5 | deltaD step size | ✅ PASS | -| 6 | Dynamic difference caps | ❓ N/A | -| 7 | In-memory pricing state | ⚠️ EXISTING F-012 | -| 8 | minDynamicDifference constraint | ❓ N/A | -| 9 | maxDynamicDifference constraint | ❓ N/A | -| 10 | On-chain exchange rates | ✅ PASS | -| 11 | No discount internals leaked | ✅ PASS | -| 12 | Amounts immutable | ✅ PASS | -| 13 | Auth on quote creation | 🟡 PARTIAL — optional by design | -| 14 | Quote ownership | 🟡 PARTIAL — UUID + expiry mitigation | -| 15 | Subsidy only when discount > 0 | ✅ PASS | -| 16 | Subsidy cap correct | ✅ PASS | -| 17 | Default partner fallback | ✅ PASS | -| 18 | Monitoring for high subsidy | ❓ N/A | - -No new findings. F-012 confirmed. - ---- - -### 03c — Fee Integrity - -**Spec:** `03-ramp-engine/fee-integrity.md` - -#### 1. `[EXISTING FINDING]` Dual fee system discrepancy -Database-based fees (displayed) vs token-config-based fees (deducted). Two paths calculate independently. → [F-002](FINDINGS.md) - -#### 2. `[PASS]` All fee calculations use `Big.js` -No native JS `number` arithmetic on monetary amounts. - -#### 3. `[PASS]` Negative output protection -`Big.toFixed()` with round-down mode. Fee engines store values, don't subtract. - -#### 4. `[PASS]` No fee parameter accepted from client -`QuoteRequest` type has no fee rate/amount/override fields. - -#### 5. `[N/A]` Fee config values match intentions -Business review needed. - -#### 6. `[PASS]` `distributeFees` uses pre-signed transactions -Fee distribution locked at quote time. Handler submits pre-signed tx as-is. - -#### 7. `[N/A]` Anchor fees pre-accounted in quoted amount -Deferred to Module 05 integration-specific review. - -#### 8. `[PASS]` Fee changes don't affect in-flight ramps -Fees stored in `metadata.fees` at creation. No re-fetch during execution. - -### Fee Integrity Audit Summary - -| # | Checklist Item | Result | -|---|---|---| -| 1 | Dual fee system | 🔴 EXISTING F-002 | -| 2 | Big.js for fees | ✅ PASS | -| 3 | Negative output protection | ✅ PASS | -| 4 | No client fee params | ✅ PASS | -| 5 | Fee config correctness | ❓ N/A | -| 6 | distributeFees presigned | ✅ PASS | -| 7 | Anchor fees pre-accounted | ↗️ Deferred to Module 05 | -| 8 | Fee changes don't affect in-flight | ✅ PASS | - -No new findings. F-002 confirmed. - ---- - -## Module 04 — Smart Contracts - -### Token Relayer (`04-smart-contracts/token-relayer.md`) - -**Contract:** `TokenRelayer.sol` (218 lines, pragma ^0.8.28). All 12 prior findings confirmed fixed. - -| # | Check | Result | -|---|---|---| -| C-1 | `nonReentrant` + CEI pattern | ✅ PASS | -| C-2 | OZ `ECDSA.recover()` | ✅ PASS | -| C-3 | Contract compiles | ✅ PASS | -| H-1 | Exact approval + revoke | ✅ PASS | -| H-2 | Hardcoded `destinationContract` in digest | ✅ PASS | -| M-1 | `receive()` + `withdrawETH()` | ✅ PASS | -| M-2 | Permit try-catch fallback | ✅ PASS | -| M-3 | Test ABI includes `payloadValue` | ✅ PASS | -| L-1 | `executedCalls` removed | ✅ PASS | -| L-2 | Withdrawal events added | ✅ PASS | -| I-1 | OZ `Ownable` | ✅ PASS | -| I-3 | OZ `EIP712` | ✅ PASS | -| G-1 | OZ dependency pinning | ⚠️ PARTIAL — caret range `^5.2.0`, not exact | -| G-2 | Constructor zero-address check | ✅ PASS | -| G-3 | Owner via Ownable constructor | ✅ PASS | -| G-4 | Nonce before external calls | ✅ PASS | -| G-5 | No selfdestruct/delegatecall | ✅ PASS | -| G-6 | Deployed bytecode verification | ❓ N/A — requires on-chain check | - -No new findings. All 12 prior findings verified fixed. OZ caret range is a minor best-practice observation. - ---- - -## Module 05 — Integrations - -### 5.1 BRLA Integration - -**Spec:** `05-integrations/brla.md` - -| # | Check | Result | -|---|---|---| -| 1 | Credentials from env vars | ✅ PASS | -| 2 | Payment confirmation before mint | ✅ PASS — on-chain balance (ground truth) | -| 3 | Correct gross payout amount | ✅ PASS — from stored quote metadata | -| 4 | CPF/tax ID validation | ✅ PASS — `isValidCnpj`/`isValidCpf` | -| 5 | Idempotent subaccount creation | ✅ PASS — tax ID as PK | -| 6 | API response validation | ⚠️ PARTIAL — shared package not audited | -| 7 | RecoverablePhaseError usage | ✅ PASS | -| 8 | HTTPS enforcement | ✅ PASS | -| 9 | No credentials/tax IDs in logs | ⚠️ PARTIAL — generic error handler may leak | -| 10 | Timeout on API calls | 🔴 FAIL — F-014 | -| 11 | Server-side PIX details | ✅ PASS | -| 12 | Reconciliation logging | ⚠️ PARTIAL — implicit only via DB state | - ---- - -### 5.2 Monerium Integration (DEPRECATED — replaced by Mykobo) - -**Spec:** `05-integrations/monerium.md` (deprecated; see `05-integrations/mykobo.md` for the current registration-gated EUR rail) - -> Monerium is no longer used. EUR on/off-ramp registration is currently gated before provider side effects; when re-enabled, the EUR flow goes through Mykobo on Base. The checks below describe the historical Monerium audit state and are retained for traceability of F-023 / F-024 lineage. - -| # | Check | Result | -|---|---|---| -| 1 | Credentials from env vars | ✅ PASS | -| 2 | SEPA confirmation via on-chain balance | ✅ PASS | -| 3 | Minted amount verified on-chain | ✅ PASS | -| 4 | Maximum SEPA wait time | ⚠️ PARTIAL — 30min may be too short for SEPA. → [F-023](FINDINGS.md) | -| 5 | Server-side SEPA details | ✅ PASS | -| 6 | Ephemeral balance verification | ✅ PASS | -| 7 | Idempotency keys | ❓ N/A — polling-based, inherently idempotent | -| 8 | RecoverablePhaseError usage | ✅ PASS | -| 9 | HTTPS enforcement | ✅ PASS | -| 10 | No credentials/IBAN in logs | ⚠️ PARTIAL — error responses could contain data | -| 11 | Timeout on API calls | 🔴 FAIL — F-014 | -| 12 | Concurrent SEPA ramp limit | 🔴 FAIL — no per-user throttle. → [F-024](FINDINGS.md) | - ---- - -### 5.2b Mykobo Integration (REGISTRATION-GATED EUR RAIL) - -**Spec:** `05-integrations/mykobo.md` - -Mykobo replaces Monerium for EUR on-ramp and Stellar/EURC for EUR off-ramp. EUR registration is currently gated before Mykobo side effects; when re-enabled, both directions flow on Base, mirroring the BRLA-on-Base architecture. - -| # | Check | Result | -|---|---|---| -| 1 | Mykobo access/secret keys + base URL from env vars | ✅ PASS — loaded via `packages/shared` config; `MykoboApiService` throws on missing config | -| 2 | `MYKOBO_BASE_URL` HTTPS and `/v` enforced | ✅ PASS — F-070 fixed: `assertSecureMykoboBaseUrl` enforces HTTPS at construction (localhost permitted in non-production) | -| 3 | On-ramp `mykoboOnrampDeposit` polls Base RPC for EURC arrival | ✅ PASS — `checkEvmBalancePeriodically` against `evmEphemeralAddress` until `mykoboMint.outputAmountRaw` arrives | -| 4 | 24h outer payment timeout; on expiry → `failed` | ✅ PASS — `PAYMENT_TIMEOUT_MS = 24h`, transition to `failed` enforced in handler | -| 5 | 5% recovery tolerance scoped to pre-funded shortcut only | ✅ PASS — `EPHEMERAL_FUNDED_TOLERANCE_FACTOR=0.95` applies only to `ephemeralAlreadyFunded` pre-check; live polling uses full `expectedAmountRaw` | -| 6 | On-ramp intent `wallet_address` = Base ephemeral (not user destination) | ✅ PASS — `prepareMykoboOnrampTransactions` passes `evmEphemeralEntry.address` | -| 7 | Off-ramp intent `wallet_address` = Base ephemeral | ✅ PASS — `prepareEvmToMykoboOfframpTransactions` passes `evmEphemeralEntry.address` | -| 8 | Off-ramp `receivables` address sourced server-side from intent response | ✅ PASS — `mykoboReceivablesAddress = intent.instructions.address` | -| 9 | Off-ramp EURC transfer amount equals `nablaSwapEvm.outputAmountRaw` | ✅ PASS — encoded into the `mykoboPayoutOnBase` presigned tx at registration time | -| 10 | `mykoboPayoutOnBase` advances to `complete` only after on-chain + Mykobo `COMPLETED` | ✅ PASS — `sendMykoboPayoutTransaction` waits for receipt; `pollMykoboUntilCompleted` blocks on `COMPLETED` | -| 11 | `FAILED` / `CANCELLED` / `EXPIRED` → unrecoverable error | ✅ PASS — `createUnrecoverableError` for all three terminal statuses | -| 12 | Recovery: `mykoboPayoutTxHash` short-circuits re-broadcast | ✅ PASS — waits for prior receipt; re-sends only if prior tx reverted | -| 13 | `MykoboApiError` mapped to recoverable/unrecoverable at handler boundary | ✅ PASS — payout handler wraps send failures in `createRecoverableError`; status terminal → unrecoverable | -| 14 | Bearer-token refresh debounced (no thundering-herd on 401) | ✅ PASS — F-071 fixed: `authFailurePromise` debounce added to `handleAuthFailure`, mirroring `tokenPromise` pattern | -| 15 | Token / access / secret keys absent from logs | ⚠️ PARTIAL — `MykoboApiError.body` may carry raw response bodies into logs; no explicit redaction | -| 16 | IBAN payment details surfaced only after presigned-tx validation | ✅ PASS — `ibanPaymentData` returned from `prepareRampTransactions` only after `validatePresignedTxs` succeeds upstream | -| 17 | `/v1/mykobo/profiles` endpoints require Supabase OTP auth | ✅ PASS — F-068 fixed: `requireAuth` added to both GET and POST routes | -| 18 | Mykobo KYC documents not persisted by Vortex | ✅ PASS — multipart form-data streamed through to Mykobo; no local persistence of files or PII beyond the email→profile linkage | -| 19 | HTTPS enforced for all Mykobo API calls | ✅ PASS — F-070 fixed: `assertSecureMykoboBaseUrl` rejects non-HTTPS schemes at construction (localhost permitted in non-production) | -| 20 | Timeout / AbortController on Mykobo HTTP client | 🔴 FAIL — F-014 (cross-cutting; Mykobo `fetch` calls lack explicit `AbortController`, same gap as BRLA/Monerium/CoinGecko/etc.) | -| 21 | Phase handlers never call Mykobo API without explicit recoverable/unrecoverable mapping | ✅ PASS — `mykobo-payout-handler.ts` catches `PhaseError` directly and wraps non-PhaseError exceptions | - ---- - -### 5.3 Alfredpay Integration - -**Spec:** `05-integrations/alfredpay.md` - -| # | Check | Result | -|---|---|---| -| 1 | Credentials from env vars | ✅ PASS | -| 2 | `validateResultCountry` applied | ✅ PASS — all 9 routes | -| 3 | Enum-based country validation | ✅ PASS | -| 4 | Payment confirmation before mint | ✅ PASS — `Promise.race` balance + status | -| 5 | Correct offramp amount | ✅ PASS — from presigned tx | -| 6 | Permit data validation | ✅ PASS — structure + length + signatures | -| 7 | RecoverablePhaseError usage | ✅ PASS | -| 8 | HTTPS enforcement | ✅ PASS | -| 9 | No credentials in logs | ✅ PASS | -| 10 | Timeout on API calls | 🔴 FAIL — F-014 | -| 11 | Subsidy before transfer | ✅ PASS | - ---- - -### 5.4 Stellar Anchors Integration - -**Spec:** `05-integrations/stellar-anchors.md` - -| # | Check | Result | -|---|---|---| -| 1 | `isStellarEphemeralFunded` checks existence + trustline | ✅ PASS | -| 2 | Sequence number validation | ✅ PASS | -| 3 | Nonce re-execution guard | ✅ PASS | -| 4 | `AmountExceedsUserBalance` → wait only, no re-submit | ✅ PASS | -| 5 | `verifyStellarPaymentSuccess` checks zero balance | ✅ PASS | -| 6 | `NETWORK_PASSPHRASE` derivation correct | ✅ PASS | -| 7 | `HORIZON_URL` consistency | ⚠️ PARTIAL — import inconsistency between modules. → [F-025](FINDINGS.md) | -| 8 | Presigned redeem extrinsic | ✅ PASS | -| 9 | Stellar XDR submitted as-is | ✅ PASS | -| 10 | `checkBalancePeriodically` 10min timeout | ✅ PASS | -| 11 | No secret keys in logs | ✅ PASS | -| 12 | `@ts-ignore` on nonce call | ⚠️ PARTIAL — suppressed type error. → [F-026](FINDINGS.md) | - ---- - -### 5.5 Squid Router Integration - -**Spec:** `05-integrations/squid-router.md` - -| # | Check | Result | -|---|---|---| -| 1 | Approve hash persisted before swap | ✅ PASS | -| 2 | `Promise.any` AggregateError handling | ✅ PASS | -| 3 | `calculateGasFeeInUnits` bounds | ✅ PASS — negative guard to "0" | -| 4 | `addNativeGas` correct address/chain | ✅ PASS | -| 5 | Funding vs executor keys distinct env vars | ✅ PASS | -| 6 | `getPublicClient` fallback risk | ⚠️ PARTIAL — silent default to Moonbeam on unknown currency | -| 7 | `isSignedTypedDataArray` validation | ✅ PASS | -| 8 | `RELAYER_ADDRESS` matches deployment | ✅ PASS | -| 9 | Balance check timeout 15min | ✅ PASS | -| 10 | Gas estimate 1.6M reasonable | ✅ PASS | -| 11 | `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` cap | 🔴 FAIL — F-001 (CRITICAL, `throw` missing) | -| 12 | `sendTransactionWithBlindRetry` nonce | ⚠️ PARTIAL — possible double-submit on lost response | -| 13 | `squidRouterPermitExecutionValue` validation | 🔴 FAIL — no null/range check on `msg.value`. → [F-027](FINDINGS.md) | - -### New Findings from Module 05 - -| ID | Severity | Finding | Module | -|---|---|---|---| -| F-023 | ⚪ Superseded | (Historical) Monerium 30-min SEPA timeout — Monerium removed; Mykobo uses 24h | Monerium → Mykobo | -| F-024 | 🟡 Medium | No concurrent SEPA ramp limit per user (now applies to Mykobo) | Mykobo | -| F-025 | 🔵 Low | `HORIZON_URL` import inconsistency between modules | Stellar | -| F-026 | 🔵 Low | `@ts-ignore` on `.nonce.toNumber()` hides potential API incompatibility | Stellar | -| F-027 | 🟡 Medium | `squidRouterPermitExecutionValue` used as `msg.value` without validation | Squid Router | -| F-068 | 🔴 Critical | Mykobo `/v1/mykobo/profiles` GET/POST have no `requireAuth` — anonymous KYC ingestion | Mykobo | -| F-069 | 🟠 High | EUR off-ramp `fundEphemeral.nextPhaseSelector` falls through to `moonbeamToPendulum` (latent stuck-phase bug) | Mykobo / Ramp Engine | -| F-070 | 🟡 Medium | `MYKOBO_BASE_URL` accepts any URL scheme — no HTTPS enforcement | Mykobo | -| F-071 | 🔵 Low | `MykoboApiService.handleAuthFailure` is not debounced — concurrent-401 thundering herd | Mykobo | - ---- - -## Module 06 — Cross-chain - -### 6.1 XCM Transfers - -**Spec:** `06-cross-chain/xcm-transfers.md` - -| # | Check | Result | -|---|---|---| -| 1 | RPC shuffling uses persisted state (UUID-keyed) | ✅ PASS | -| 2 | 30min RecoverablePhaseError on exhaustion | ✅ PASS | -| 3 | Hash registration wait before executeXCM | ✅ PASS | -| 4 | Executor key not logged | ✅ PASS | -| 5 | On-chain receiver contract caller validation | ⚠️ PARTIAL — cannot verify from app code | -| 6 | Pendulum→Moonbeam 3-tier recovery | ✅ PASS | -| 7 | 2-min Moonbeam balance timeout | ✅ PASS | -| 8 | Hydration→AssetHub finalization skip | ✅ PASS — accepted risk, documented | -| 9 | Hydration nonce guard | 🔴 FAIL — warning-only, no skip. → [F-028](FINDINGS.md) | -| 10 | Hydration swap uses presigned extrinsic | ✅ PASS | -| 11 | Pendulum→AssetHub terminal phase | ✅ PASS | -| 12 | Pendulum→Hydration balance wait | ✅ PASS | -| 13 | No private key logging | ✅ PASS | -| 14 | Retry budget isolation | ⚠️ PARTIAL — stale gas price across 5-attempt internal loop | - ---- - -### 6.2 Bridge Security — Spacewalk - -**Spec:** `06-cross-chain/bridge-security.md` - -| # | Check | Result | -|---|---|---| -| 1 | Vault filters by assetCode AND assetIssuer | ✅ PASS | -| 2 | Capacity check before vault selection | ✅ PASS | -| 3 | Presigned redeem extrinsic | ✅ PASS | -| 4 | Nonce guard skips re-submission | ✅ PASS | -| 5 | `AmountExceedsUserBalance` → wait only | ✅ PASS | -| 6 | Stellar funded check (existence + trustline) | ✅ PASS | -| 7 | 10-minute balance timeout | ✅ PASS | -| 8 | No fallback vault | ✅ PASS | -| 9 | Slash/cancel documented | ⚠️ PARTIAL — no operational runbook | -| 10 | `@ts-ignore` on nonce | 🟡 EXISTING — F-026 | -| 11 | Per-vault tx maximum | ⚠️ PARTIAL — not verified at protocol level | -| 12 | No claimable-balance recovery | ✅ PASS — confirmed absent, documented gap | - ---- - -### 6.3 Fund Routing — Subsidization & Settlement - -**Spec:** `06-cross-chain/fund-routing.md` - -| # | Check | Result | -|---|---|---| -| 1 | Missing `throw` on USD cap | 🔴 EXISTING — F-001 (CRITICAL) | -| 2 | Pre-swap subsidy: `expected - current` | ✅ PASS | -| 3 | Post-swap subsidy: same pattern | ✅ PASS | -| 4 | Skip when balance sufficient | ✅ PASS | -| 5 | `getFundingAccount()` from `PENDULUM_FUNDING_SEED` | ✅ PASS | -| 6 | `MOONBEAM_FUNDING_PRIVATE_KEY` isolation | 🔴 FAIL — aliased to executor key. → [F-029](FINDINGS.md) | -| 7 | Destination transfer balance check | ✅ PASS | -| 8 | Presigned transfer submitted as-is | ✅ PASS | -| 9 | Swap input bounded | ⚠️ PARTIAL — cap broken (F-001) | -| 10 | Retry on malicious route | 🔴 FAIL — no output validation, retries amplify loss. → [F-030](FINDINGS.md) | -| 11 | Post-swap routing completeness | ⚠️ PARTIAL — no default/error case. → [F-031](FINDINGS.md) | -| 12 | Funding balance pre-check | 🔴 FAIL — no check, opaque errors. → [F-032](FINDINGS.md) | -| 13 | Monitoring/alerting | 🔵 N/A | -| 14 | Cap value ($10 USD) reasonable | ✅ PASS | - -### New Findings from Module 06 - -| ID | Severity | Finding | Sub-module | -|---|---|---|---| -| F-028 | 🟡 Medium | Hydration nonce guard is warning-only + stale gas estimate in retry loop | XCM Transfers | -| F-029 | 🟠 High | `MOONBEAM_FUNDING_PRIVATE_KEY` aliased to `MOONBEAM_EXECUTOR_PRIVATE_KEY` — no blast radius separation | Fund Routing | -| F-030 | 🟡 Medium | SquidRouter swap has no output validation; retries amplify losses from bad routes | Fund Routing | -| F-031 | 🔵 Low | Post-swap routing has no default/error case for unrecognized flow combinations | Fund Routing | -| F-032 | 🟡 Medium | No pre-check of Pendulum funding account balance in subsidy handlers | Fund Routing | - ---- - -## Module 07 — Operations - -### 07a — Rebalancer - -**Spec:** `07-operations/rebalancer.md` - -#### 1. `[PASS]` State file locking -Confirmed limitation: Supabase Storage file overwrite, no locking. One-shot process — concurrency depends on deployment. - -#### 2. `[PARTIAL]` `brlaBusinessAccountAddress` hardcoded default -Configurable via env var, but falls back to hardcoded address. Not in `.env.example`. - -#### 3. `[PASS]` 5% slippage tolerance -Hardcoded `0.95` multiplier. Reasonable for default small amounts ($1 USD). - -#### 4. `[PASS]` Gas 5x multiplier -Aggressive but ensures inclusion on Polygon. Gas is typically cheap. - -#### 5. `[PASS]` Coverage ratio threshold -Default Base flow uses asymmetric bounds around 1.0: `1 - REBALANCING_THRESHOLD_BRLA_TO_USDC` for the low-coverage correction and `1 + REBALANCING_THRESHOLD_USDC_TO_BRLA` for the high-coverage flow. Both route-specific thresholds fall back to `REBALANCING_THRESHOLD` and default to `0.01`. - -#### 6. `[PASS]` Rebalancer keys distinct from API keys -Different env var names. Actual isolation is operational. - -#### 7. `[PARTIAL]` Step idempotency -Steps 2, 3, 5, 6, 7 have crash windows between execution and `saveState()` causing double-spend on re-execution. No tx hash guards or nonce guards. → [F-033](FINDINGS.md) - -#### 8. `[PARTIAL]` BRLA→USDC swap amount validation -Legacy BRLA→USDC trusts the BRLA API response. Base high-coverage routes use provider quotes and delta-based arrival checks; Base low-coverage is a Base-only two-swap loop with final balance verification. - -#### 9. `[FAIL]` SquidRouter swap amount validation -Legacy SquidRouter never validates received amount matches estimate and its Axelar polling has no timeout (infinite loop risk). The Base SquidRouter route has a 30-minute Axelar timeout and delta-based Base USDC arrival check. → [F-034](FINDINGS.md) - -#### 10. `[PASS]` Storage write errors handled -Errors thrown and propagated. Process exits with code 1. - -#### 11. `[PARTIAL]` Monitoring/alerting -Slack on success only. No notification on failure, stuck state, or insufficient balance. - -#### 12. `[PASS]` No secrets logged -Only env var names, never values. - -#### 13. `[PASS]` One-shot process -`process.exit(0/1)` after single run. Concurrency depends on external scheduling. - -#### 14. `[PASS]` Missing/corrupted state handled -Returns `undefined` → starts fresh rebalance. - -### Rebalancer Summary - -| # | Check | Result | -|---|---|---| -| 1 | State file locking | ✅ PASS (confirmed limitation) | -| 2 | Business account address | 🟡 PARTIAL | -| 3 | 5% slippage | ✅ PASS (confirmed limitation) | -| 4 | Gas 5x multiplier | ✅ PASS (confirmed limitation) | -| 5 | Coverage ratio threshold | ✅ PASS | -| 6 | Key isolation | ✅ PASS | -| 7 | Step idempotency | 🟡 PARTIAL — F-033 | -| 8 | BRLA→USDC amount validation | 🟡 PARTIAL | -| 9 | SquidRouter amount validation | 🔴 FAIL — F-034 | -| 10 | Storage write errors | ✅ PASS | -| 11 | Monitoring/alerting | 🟡 PARTIAL | -| 12 | No secrets logged | ✅ PASS | -| 13 | Schedule/trigger | ✅ PASS | -| 14 | Missing/corrupted state | ✅ PASS | - ---- - -### 07b — Secret Management - -**Spec:** `07-operations/secret-management.md` - -#### 1. `[PASS]` No secrets manager — plain env vars -Confirmed limitation. All secrets via `process.env`. - -#### 2. `[PASS]` Ephemeral webhook key if missing -`CryptoService` generates RSA keypair in-memory if env var absent. - -#### 3. `[PASS]` No secret rotation mechanism -All env vars loaded at startup. Rotation requires restart. - -#### 4. `[PASS]` No secrets hardcoded in source code -Only development defaults for DB credentials. - -#### 5. `[PASS]` No secrets in log output -Error messages log env var names, never values. - -#### 6. `[PASS]` `SUPABASE_SERVICE_KEY` not exposed to frontend -Frontend uses `SUPABASE_ANON_KEY` (Vite-prefixed). No endpoint returns service key. - -#### 7. `[N/A]` Database credentials network-restricted -Infrastructure check. - -#### 8. `[PASS]` `.env.example` safe -Only placeholder values. - -#### 9. `[PASS]` `.env` in `.gitignore` -Both root and rebalancer `.gitignore` exclude `.env`. - -#### 10. `[PASS]` Rebalancer keys isolated -Different env var names from API keys. - -#### 11. `[N/A]` `ADMIN_SECRET` entropy -Deployment config. No minimum length in code. - -#### 12. `[PASS]` No endpoint leaks env vars or config -Reviewed all 27 route files. No endpoint returns `process.env` or `config`. - -#### 13. `[PASS]` `GOOGLE_PRIVATE_KEY` newline handling -`.split(String.raw\`\\n\`).join("\\n")` correctly handles PEM in env vars. - -#### 14. `[PASS]` Blast radius mapping comprehensive -All secrets in code documented in spec. No undocumented secrets found. - -### Secret Management Summary - -| # | Check | Result | -|---|---|---| -| 1 | No secrets manager | ✅ PASS (confirmed) | -| 2 | Ephemeral webhook key | ✅ PASS | -| 3 | No rotation | ✅ PASS (confirmed) | -| 4 | No hardcoded secrets | ✅ PASS | -| 5 | No secrets in logs | ✅ PASS | -| 6 | Service key not exposed | ✅ PASS | -| 7 | DB creds restricted | 🔵 N/A | -| 8 | .env.example safe | ✅ PASS | -| 9 | .env in .gitignore | ✅ PASS | -| 10 | Rebalancer keys isolated | ✅ PASS | -| 11 | Admin secret entropy | 🔵 N/A | -| 12 | No config in responses | ✅ PASS | -| 13 | Google key newlines | ✅ PASS | -| 14 | Blast radius mapped | ✅ PASS | - ---- - -### 07c — API Surface - -**Spec:** `07-operations/api-surface.md` - -#### 1. `[FAIL]` 50MB body parser limit -`bodyParser.json({ limit: "50mb" })` — no endpoint justifies this. 100 req/min × 50MB = 5GB/min memory pressure per IP. → [F-035](FINDINGS.md) - -#### 2. `[FAIL]` Staging CORS origin in production -`staging--pendulum-pay.netlify.app` always in whitelist, not gated by `NODE_ENV`. → [F-036](FINDINGS.md) - -#### 3. `[PARTIAL]` Validator coverage -Multiple sensitive POST endpoints lack auth and input validation (`/ramp/update`, `/ramp/start`, `/pendulum/fundEphemeral`, `/moonbeam/execute-xcm`, `/maintenance/schedules/:id/active`, `/webhook`). Full route-by-route audit in FINDINGS.md. → [F-037](FINDINGS.md) - -#### 4. `[PASS]` No CORS wildcard or dynamic reflection -Static origin array. `credentials: true` requires specific origin. - -#### 5. `[PASS]` Rate limit bypass via `X-Forwarded-For` -`trust proxy` set to specific number (not `true`). Prevents arbitrary spoofing. - -#### 6. `[PASS]` Helmet configured with secure defaults -`helmet()` with default config — all protections enabled. - -#### 7. `[N/A]` `NODE_ENV` set to production -Default fallback is `"production"` (safe). Runtime check. - -#### 8. `[PASS]` Error responses — no internal types/SQL fragments -Stack stripped in production. Validation errors use user-facing field names. - -#### 9. `[PASS]` `errors` array contains only user-facing messages -Validator messages reference request field names, not DB internals. - -#### 10. `[PARTIAL]` Route auth mapping -Full audit in checklist item 3. Multiple gaps. → F-037 - -#### 11. `[PASS]` `publicKeyAuth` not used for operations requiring `apiKeyAuth` -`validatePublicKey()` used only for optional partner tracking on quotes. - -#### 12. `[N/A]` Controllers don't pass raw `req.body` to database -Controllers reviewed destructure specific fields. Full review deferred. - -#### 13. `[PASS]` No endpoint returns `process.env` or internal paths -Verified across all route files. - -#### 14. `[PARTIAL]` Cookie SameSite/CSRF -Server reads cookies but doesn't set them. No CSRF tokens, but primary auth uses `Authorization` headers (inherently CSRF-safe). Cookie auth limited to `/stellar/sep10`. - -#### 15. `[PASS]` 404 handler — no information leak -Generic "Not found" JSON through standard error handler. - -#### 16. `[PASS]` File upload validation -No file upload endpoints. BRLA KYC uses pre-signed URLs for client-side upload. - -### API Surface Summary - -| # | Check | Result | -|---|---|---| -| 1 | 50MB body limit | 🔴 FAIL — F-035 | -| 2 | Staging CORS origin | 🔴 FAIL — F-036 | -| 3 | Validator coverage | 🟡 PARTIAL — F-037 | -| 4 | No CORS wildcard | ✅ PASS | -| 5 | Rate limit X-Forwarded-For | ✅ PASS | -| 6 | Helmet defaults | ✅ PASS | -| 7 | NODE_ENV production | 🔵 N/A | -| 8 | Error response safety | ✅ PASS | -| 9 | User-facing error messages | ✅ PASS | -| 10 | Route auth mapping | 🟡 PARTIAL — F-037 | -| 11 | publicKeyAuth vs apiKeyAuth | ✅ PASS | -| 12 | Raw req.body to DB | 🔵 N/A (deferred) | -| 13 | No env/config in responses | ✅ PASS | -| 14 | Cookie SameSite/CSRF | 🟡 PARTIAL | -| 15 | 404 handler clean | ✅ PASS | -| 16 | File upload validation | ✅ PASS | - -### New Findings from Module 07 - -| ID | Severity | Finding | Sub-module | -|---|---|---|---| -| F-033 | 🟠 High | Rebalancer steps not idempotent — crash between execution and saveState causes double-spend | Rebalancer | -| F-034 | 🟡 Medium | Rebalancer SquidRouter swap has no output validation and Axelar polling has no timeout | Rebalancer | -| F-035 | 🟡 Medium | 50MB body parser limit enables memory exhaustion | API Surface | -| F-036 | 🟡 Medium | Staging CORS origin always in production whitelist | API Surface | -| F-037 | 🟠 High | Multiple sensitive POST endpoints lack auth and input validation | API Surface | - ---- - -## Final Audit Summary - -### Scope - -Full security audit covering all 8 modules (00–07) across 23 specification files. Each spec's Audit Checklist was verified item-by-item against actual source code. - -| Module | Sub-modules Audited | Checklist Items | -|---|---|---| -| 00 — System Overview | Architecture | 10 | -| 01 — Auth | Supabase OTP, API Keys, Admin Auth | 32 | -| 02 — Signing Keys | Ephemeral Accounts, Server-Side Signing | 23 | -| 03 — Ramp Engine | State Machine, Quote Lifecycle, Fee Integrity | 39 | -| 04 — Smart Contracts | Token Relayer | 18 | -| 05 — Integrations | BRLA, Mykobo (active EUR), Monerium (deprecated), Alfredpay, Stellar Anchors, Squid Router | 60 | -| 06 — Cross-chain | XCM Transfers, Bridge Security, Fund Routing | 40 | -| 07 — Operations | Rebalancer, Secret Management, API Surface | 44 | -| **Total** | **22 sub-modules** | **~266 checklist items** | - -### Findings Summary - -| Severity | Fixed | Accepted | Deferred | Open | Total | -|---|---|---|---|---|---| -| 🔴 Critical | 6 | 0 | 0 | 0 | 6 | -| 🟠 High | 12 | 3 | 3 | 0 | 18 | -| 🟡 Medium | 26 | 3 | 6 | 0 | 35 | -| 🔵 Low / ⚪ Info | 9 | 3 | 0 | 0 | 12 | -| **Total** | **53** | **9** | **9** | **0** | **71** | - -Findings F-068 through F-071 from the Mykobo integration audit (2026-05-22) were resolved in the same audit cycle; see `FINDINGS.md` Phase 5 section for full descriptions and resolutions. A companion fix wired `fundEphemeral` into the EUR (Mykobo) onramp flow — the EUR ephemeral on Base previously had no source of native ETH, which would have caused `nablaApprove`/`nablaSwap`/squid txs to fail with insufficient gas had any Mykobo onramp progressed past deposit. - -### Recommended Remediation Order - -**Week 1 — Stop the Bleeding:** -1. Fix F-001 (add `throw` — one word) -2. Add auth middleware to sensitive routes (F-013, F-037) -3. Reduce body parser limit to 1MB (F-035) -4. Gate staging CORS origin behind NODE_ENV (F-036) - -**Week 2 — Concurrency & State Safety:** -5. Implement atomic phase lock (F-003) -6. Add terminal state guard (F-004) -7. Make rebalancer steps idempotent (F-033) - -**Week 3 — Integration Hardening:** -8. Add output amount validation to SquidRouter swaps (F-027, F-030, F-034) -9. Add concurrent SEPA ramp limit per user (F-024, now applies to Mykobo flows) -10. Add pre-balance checks to subsidy handlers (F-032) - -**Month 2 — Architectural Improvements:** -11. Separate private keys per function (F-029) -12. Unify fee systems (F-002) -13. Add structured audit logging (F-015) -14. Implement proper admin auth (F-020) - -**Mykobo Integration Audit (2026-05-22) — Open:** -15. ✅ Done — Added `requireAuth` to `/v1/mykobo/profiles` GET/POST (F-068, Critical). The GET endpoint now identifies profiles by the authenticated user's email (`req.userEmail`) via `MykoboApiService.getProfileByEmail`, and rejects requests whose `email` query parameter does not match the authenticated user. POST profile creation continues to bind `wallet_address` to the user's ephemeral, so no separate wallet-ownership check is required there. -16. ✅ Done — Added explicit EURC SELL branch to `fund-ephemeral-handler.nextPhaseSelector` returning `distributeFees`; also added the missing EURC BUY branch returning `subsidizePreSwap` and wired `fundEphemeral` into the Mykobo onramp flow via `mykobo-onramp-deposit-handler` and `getRequiresBaseEphemeralAddress` (F-069, High) -17. ✅ Done — Enforced HTTPS scheme on `MYKOBO_BASE_URL` at `MykoboApiService` construction via `assertSecureMykoboBaseUrl` (F-070, Medium) -18. ✅ Done — Debounced `MykoboApiService.handleAuthFailure` with `authFailurePromise` mirroring `getToken`'s `tokenPromise` (F-071, Low) - -### Files Reference - -- **Specifications:** `docs/security-spec/` (23 spec files — see `README.md` for index) -- **Findings tracker:** `docs/security-spec/FINDINGS.md` (67 findings with full details) -- **Audit results:** This file (`docs/security-spec/AUDIT-RESULTS.md`) diff --git a/docs/security-spec/FINDINGS.md b/docs/security-spec/FINDINGS.md deleted file mode 100644 index 4f0780d87..000000000 --- a/docs/security-spec/FINDINGS.md +++ /dev/null @@ -1,1684 +0,0 @@ -# Audit Findings Tracker - -> **Generated:** 2026-04-02 | **Last Updated:** 2026-05-22 | **Status:** F-001 through F-067: 49 fixed, 9 accepted risk, 9 deferred, 0 open. F-068 through F-071 raised by the Mykobo integration audit: 4 open. F-072 raised by the ephemeral-account lifecycle review: 1 fixed. Additional discount-mechanism findings F-DISC-01 through F-DISC-05 remain open in `03-ramp-engine/discount-mechanism.md` and are not included in the counts below. - -This file consolidates all security findings from the Vortex platform audit. Findings were discovered across six phases: specification writing (F-001 through F-012), code-vs-spec audit across all 8 modules (F-013 through F-037), transaction validation / ephemeral account / phase flow audit (F-038 through F-058), fresh security audit pass (F-059 through F-067), Mykobo integration audit (F-068 through F-071), and ephemeral-account lifecycle review (F-072). - -## Summary - -| Severity | Fixed | Accepted | Deferred | Open | Total | -|---|---|---|---|---|---| -| 🔴 Critical | 5 | 0 | 0 | 1 | 6 | -| 🟠 High | 11 | 3 | 3 | 1 | 18 | -| 🟡 Medium | 25 | 3 | 6 | 1 | 35 | -| 🔵 Low / ⚪ Info | 8 | 3 | 0 | 1 | 12 | -| **Total** | **49** | **9** | **9** | **4** | **71** | - -> **Fixed** = code change implemented and verified. **Accepted** = CTO reviewed and accepted risk, no code change. **Deferred** = requires architectural work, separate app changes, or future investigation. **Open** = newly identified, awaiting fix or CTO decision. - ---- - -## 🔴 Critical - -### F-001: Final Settlement Subsidy USD Cap Not Enforced - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts`, lines 211-213 | -| **Spec** | `06-cross-chain/fund-routing.md` | -| **Status** | ✅ **FIXED** | -| **Impact** | A single ramp could drain the funding account's entire native token balance via an unbounded SquidRouter swap. | - -**Description:** `this.createUnrecoverableError(...)` is called **without the `throw` keyword**. The error object is created but never thrown, so execution continues past the cap check. The `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` constant provides zero protection. - -**Fix:** Add `throw` before `this.createUnrecoverableError(...)`. - ---- - -### F-002: Dual Fee System Discrepancy - -| Field | Value | -|---|---| -| **Location** | Token-config-based fees (used for deductions) vs. database-stored fees (displayed only) | -| **Spec** | `03-ramp-engine/fee-integrity.md` | -| **Status** | ✅ **FIXED** | -| **Impact** | Fees shown to the user may not match fees actually deducted. Silent divergence over time. | - -**Description:** Two parallel fee calculation paths exist. Token-config-based fees are what actually deduct from user amounts during swaps. Database-based fees are calculated, stored, and displayed — but are NOT used for actual deductions. These two systems can produce different numbers for the same ramp, meaning users may see one fee but pay another. - -**CTO Clarification (2026-04-02):** Unify into a single source of truth. One fee calculation path used for both display and deduction. - -**Resolution:** Removed the redundant `fee` column from `QuoteTicket`. This column stored `displayFiat` fees separately from `metadata.fees`, but was never read back by any code path — `buildQuoteResponse()` and `feeDistribution.ts` both read from `metadata.fees`. The column was dead weight creating the illusion of a second source of truth. `assignFeeSummary()` is now documented as the single source of truth for all fee representations. Migration `025-remove-quote-ticket-fee-column` drops the column while preserving historical data in `metadata.fees`. - ---- - -### F-013: Multiple Security-Sensitive Endpoints Have No Authentication - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/routes/v1/ramp.route.ts`, `pendulum.route.ts`, `subsidize.route.ts`, `moonbeam.route.ts`, `stellar.route.ts`, `webhook.route.ts`, `brla.route.ts`, `maintenance.route.ts` | -| **Spec** | `00-system-overview/architecture.md`, `01-auth/api-keys.md`, `01-auth/supabase-otp.md` | -| **Status** | ✅ **FIXED** (legacy endpoints removed; strict dual-track auth enforced on all remaining sensitive routes) | -| **Found** | Code audit, iteration 2 | -| **Impact** | Attacker can start ramps, trigger XCM execution, fund ephemeral accounts, and initiate subsidization — all spending platform funds — without any authentication. | - -**Description:** The following endpoints originally had **zero authentication middleware**: - -- `POST /v1/ramp/start` — starts ramp phase processing -- `POST /v1/ramp/update` — updates ramp with presigned transactions -- `GET /v1/ramp/:id` — reads full ramp state (including internal details) -- `POST /v1/pendulum/fundEphemeral` — triggers funding from platform wallet -- `POST /v1/subsidize/preswap`, `POST /v1/subsidize/postswap` — triggers subsidization -- `POST /v1/moonbeam/execute-xcm` — triggers cross-chain message execution -- `POST /v1/stellar/create` — requests Stellar transaction signatures -- `POST /v1/webhook/`, `DELETE /v1/webhook/:id` — register/delete webhooks -- `PATCH /v1/maintenance/schedules/:id/active` — toggle maintenance mode -- `GET /v1/brla/getUser`, `GET /v1/brla/getUserRemainingLimit`, etc. — user data without auth - -**Resolution:** - -1. **Legacy endpoints removed:** `/pendulum/fundEphemeral`, `/moonbeam/execute-xcm`, `/subsidize/preswap`, `/subsidize/postswap` were deleted; the server now drives ramp progression internally. -2. **Strict dual-track auth on all `/v1/ramp/*` endpoints** (`/register`, `/update`, `/start`, `/:id`, `/:id/errors`, `/history`, `/history/:walletAddress`) and on `POST /v1/ramp/quotes` and `POST /v1/ramp/quotes/best`. The `requirePartnerOrUserAuth()` middleware (`apps/api/src/api/middlewares/dualAuth.ts`) accepts **either**: - - `X-API-Key: sk_*` — partner API key (used by the SDK), or - - `Authorization: Bearer ` — Supabase access token (used by the first-party frontend). - - Anonymous access is rejected with HTTP 401. The previous backwards-compat carve-out (allowing `/ramp/start` and `/ramp/update` to remain unauthenticated until SDK consumers migrated) has been removed. - -3. **Ownership enforcement:** every authenticated principal can only access its own resources. - - **Partner principal:** ownership is the chain `RampState.quoteId → QuoteTicket.partnerId === authenticatedPartner.id`. `getRampHistory` joins through `QuoteTicket` to filter by `partnerId`. - - **Supabase user principal:** ownership is `RampState.userId === req.userId` (and the analogous check on `QuoteTicket.userId` for `/ramp/register`). Account-wide `GET /v1/ramp/history` requires this effective user identity and never falls back to partner-wide access. - - Cross-principal access is rejected with HTTP 403. - -4. **Other routes:** `requireAuth` was added to `/stellar/create` and the `/brla/*` user data endpoints; `adminAuth` was added to `/maintenance/*`; `apiKeyAuth` was added to `/webhook` POST/DELETE. -5. **Quotes:** `enforcePartnerAuth()` is now active on `POST /v1/ramp/quotes` and `POST /v1/ramp/quotes/best`. Passing a `partnerId` without a matching secret API key is rejected (closes a partner-spoofing vector). - -The API remains directly internet-exposed; defence-in-depth (rate limits, request validators, ownership guards) is the protection model. - ---- - -### F-038: EVM Typed Data Bypasses ALL Validation - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, lines 105-107 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit, 2026-04-07 | -| **Impact** | A malicious API client can submit EIP-712 typed data authorizing a transfer to an attacker's address. The server will execute it without any validation. | - -**Description:** When presigned transactions use `SignedTypedData` or `SignedTypedDataArray` format (EIP-712 permits used by `squidRouterPermitExecute` and similar flows), `validatePresignedTxs()` returns immediately without performing ANY validation: - -```typescript -if (isSignedTypedData(txData) || isSignedTypedDataArray(txData)) { - return; // ALL EVM validation skipped -} -``` - -This means no signer check, no chainId check, no `from` address check, and no content validation for EIP-712 typed data. A malicious client could submit a permit that authorizes an attacker's spender address for unlimited token allowance, or typed data that routes a SquidRouter execution to an attacker-controlled contract. - -**Fix:** Decode EIP-712 typed data and validate critical fields: `spender` must match the expected contract (SquidRouter, TokenRelayer), `value` must match expected amounts, `deadline` must be reasonable, and `verifyingContract` must match the expected chain's deployed contract address. - ---- - -### F-039: Stellar Payment Amount, Destination, and Asset Not Validated - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, lines 287-301 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit, 2026-04-07 | -| **Impact** | A malicious client can redirect Stellar payments to an attacker's address, send incorrect amounts, or send the wrong asset — all while passing server-side validation. | - -**Description:** The `stellarPayment` validation in `validateStellarTransaction()` checks that: (1) the operation type is "payment", and (2) the transaction source matches the expected signer. However, it does NOT validate: - -- **Payment amount** — not checked against the quote's expected amount -- **Payment destination** — not checked against the expected anchor deposit address; could redirect to an attacker's Stellar address -- **Payment asset** — not checked; could send a worthless token instead of the expected stablecoin - -A malicious client could sign a Stellar payment for 0.0001 XLM to their own address (instead of the quoted amount of USDC to the Stellar anchor) and the server would accept and execute it. - -**Fix:** Validate the Stellar payment operation's `destination`, `amount`, and `asset` (code + issuer) against the quote's expected values. These values are known at ramp registration time and should be passed through to the validator. - ---- - -## 🟠 High - -### F-003: Phase Processor Lock is Non-Atomic - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/phase-processor.ts` | -| **Spec** | `03-ramp-engine/state-machine.md` | -| **Status** | 🟠 **DEFERRED** — requires DB-level locking implementation | -| **Impact** | Two API instances could process the same ramp simultaneously, causing double-execution of phase handlers (double swaps, double XCM transfers). | - -**Description:** Lock acquisition reads `state.processingLock.locked` from a potentially stale DB read, then sets it in a separate UPDATE. No `SELECT FOR UPDATE`, advisory lock, or atomic compare-and-swap. The in-memory `Set` only protects within a single Node.js process. - -**CTO Clarification (2026-04-02):** Currently single instance, but multi-instance deployment is planned for the future. Should add proper DB-level locking now in preparation. - -**Fix:** Use `SELECT FOR UPDATE` or database advisory locks for cross-instance safety. Implement now even though it's currently single-instance, to prepare for future multi-instance deployment. - ---- - -### F-004: Infinite Soft Loop After Max Retries - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/phase-processor.ts` | -| **Spec** | `03-ramp-engine/state-machine.md` | -| **Status** | ✅ **FIXED** | -| **Impact** | Ramps that exhaust their retry budget stay in the current phase indefinitely. On each processing cycle, they are retried again — consuming resources and potentially repeating side effects. | - -**Description:** After `MAX_RETRIES` (8) is exhausted for a recoverable error, the ramp stays in its current phase. It is not transitioned to `failed`. The next processing cycle picks it up again and the retry counter restarts. - -**CTO Clarification (2026-04-02):** After max retries, transition the ramp to `failed` state. User gets notified, manual intervention possible. - -**Fix:** Transition to `failed` after max retries exhausted. The retry counter should not reset across processing cycles. - ---- - -### F-005: No Secrets Manager / No Rotation Mechanism - -| Field | Value | -|---|---| -| **Location** | All services — `apps/api/src/config/vars.ts`, `apps/rebalancer/src/utils/config.ts` | -| **Spec** | `07-operations/secret-management.md` | -| **Status** | ⚪ **ACCEPTED** — Render.com built-in secrets management is sufficient | -| **Impact** | Server compromise exposes every funding key, database credential, and third-party API key. No way to rotate without full redeployment. No access logging for secret usage. | - -**Description:** All secrets are plain environment variables loaded at startup. No HSM, no secrets manager (AWS Secrets Manager, Vault, etc.), no encrypted storage at rest, no audit trail. Blast radius of a server compromise is total: Stellar funding keys, Pendulum seeds, Moonbeam executor keys, all rebalancer chain keys, database credentials, admin tokens, and all third-party API keys. - -**CTO Clarification (2026-04-02):** Planned improvement. Migration to a secrets manager is on the roadmap but not in this audit cycle's scope. - -**Resolution (2026-04-07):** After evaluating Render.com's built-in secrets management (encrypted at rest, SOC 2 Type II, admin-only access in protected environments, audit logging), an external secrets manager (AWS SM, Vault) was deemed unnecessary for the current risk profile. The highest-value secrets (blockchain signing keys) cannot be auto-rotated by any secrets manager anyway. The centralized `config/vars.ts` refactoring (F-016) already provides a clean migration path if requirements change. Revisit if: multi-team ACL needed, regulatory mandate for CMK, or multi-instance deployment requires per-secret policies. - ---- - -### F-006: Rebalancer State File — No Locking - -| Field | Value | -|---|---| -| **Location** | `apps/rebalancer/src/services/stateManager.ts` | -| **Spec** | `07-operations/rebalancer.md` | -| **Status** | 🟠 **DEFERRED** — requires locking mechanism, separate app | -| **Impact** | Concurrent rebalancer executions could corrupt state and cause double-execution of swaps/XCMs. | - -**Description:** Rebalancer state is stored as a JSON file in Supabase Storage. Supabase Storage has no file locking, no conditional writes, no atomic compare-and-swap. If two instances run simultaneously, both read the same state and could execute the same steps. - -**CTO Clarification (2026-04-02):** Concurrent rebalancer runs can happen (e.g., cron overlap). Needs a locking mechanism. - -**Fix:** Add a locking mechanism (e.g., DB-based lock, advisory lock, or Supabase row-level lock) to prevent concurrent rebalancer execution. Check and acquire lock at startup, release on completion or crash. - ---- - -### F-014: Most External HTTP Calls Lack Timeout Configuration - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/monerium/index.ts`, `priceFeed.service.ts`, `moonpay/moonpay.service.ts`, `transak/transak.service.ts`, `alchemypay/alchemypay.service.ts`, `ramp/helpers.ts`, `distribute-fees-handler.ts`, `slack.service.ts` | -| **Spec** | `00-system-overview/architecture.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2 | -| **Impact** | A hanging external service can block the caller indefinitely. For phase handlers, this stalls ramp processing. For price feeds, this stalls quote generation. | - -**Description:** Of 16+ `fetch()` calls to external services, only `webhook-delivery.service.ts` uses `AbortController` with a timeout. All others (Mykobo, BRLA, CoinGecko, Moonpay, Transak, AlchemyPay, Subscan, Slack, ramp helpers) make HTTP requests without any timeout or `AbortSignal`. The historical Monerium `fetch` calls had the same gap and have been carried forward into the Mykobo client. - -**Fix:** Add `AbortController` with appropriate timeouts (e.g., 10-30s) to all external `fetch()` calls. Consider a shared utility function like `fetchWithTimeout(url, options, timeoutMs)`. - ---- - -### F-029: Executor and Funding Key Reuse — No Blast Radius Separation - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/constants/constants.ts`, line 45: `const MOONBEAM_FUNDING_PRIVATE_KEY = MOONBEAM_EXECUTOR_PRIVATE_KEY;` | -| **Spec** | `06-cross-chain/fund-routing.md`, Invariant 3; `07-operations/secret-management.md` | -| **Status** | ⚪ **ACCEPTED** — known gap, single EOA by design for now | -| **Found** | Code audit, iteration 2, Module 06 | -| **Impact** | Compromise of any single function (executor, funding, Monerium, SquidRouter) compromises ALL functions. No blast radius containment. | - -**Description:** `MOONBEAM_FUNDING_PRIVATE_KEY` is directly aliased to `MOONBEAM_EXECUTOR_PRIVATE_KEY` in `constants.ts`. This single key is used across at least 6 different handler files for 4 distinct security roles: -1. **Executor** — calling `executeXCM` on the Moonbeam receiver contract (`moonbeam-to-pendulum-handler.ts`) -2. **EVM Funding** — subsidizing ephemeral accounts on Moonbeam, Polygon, and destination EVM chains (`fund-ephemeral-handler.ts`, `final-settlement-subsidy.ts`) -3. **Monerium** — signing self-transfer transactions (`monerium-onramp-self-transfer-handler.ts`) -4. **SquidRouter** — executing permit operations (`squidrouter-permit-execution-handler.ts`) - -Each of these roles has different exposure surfaces and trust requirements. A single key compromise (e.g., from a SquidRouter API integration leak) would grant an attacker the ability to drain the funding account, execute arbitrary XCM transfers, and sign Monerium operations. - -**CTO Clarification (2026-04-02):** Known gap, to be addressed later. Currently only one EOA is managed on Moonbeam. Key separation requires deploying and funding additional accounts. - -**Fix:** Deferred. Document as accepted risk with a plan to separate keys when infra supports multiple funded EOAs. When addressed: one key for executor (XCM contract calls), one for EVM funding (subsidization), one for third-party integrations (Monerium, SquidRouter). - ---- - -### F-033: Rebalancer Steps Not Idempotent — Double-Spend on Crash Recovery - -| Field | Value | -|---|---| -| **Location** | `apps/rebalancer/src/rebalance/brla-to-axlusdc/index.ts` (orchestrator); `apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts` (step implementations) | -| **Spec** | `07-operations/rebalancer.md`, Invariant 3 | -| **Status** | 🟠 **DEFERRED** — requires rebalancer app changes | -| **Found** | Code audit, iteration 2, Module 07 | -| **Impact** | A crash between step execution and `saveState()` causes the step to re-execute on next run, leading to double swaps, double XCM transfers, or duplicate BRLA withdrawal tickets — all resulting in direct fund loss. | - -**Description:** The rebalancer is an 8-step state machine that persists progress to Supabase Storage (JSON file). Each step runs, then `saveState()` records completion. Steps 2, 3, 5, 6, and 7 are NOT idempotent: - -- **Step 2** (`transferBrlaToPendulum`): Creates a BRLA withdrawal ticket. Crash → duplicate ticket → double withdrawal. -- **Step 3** (`swapBrlaForUsdc`): Executes a Nabla DEX swap. Crash → swap executed but state not saved → re-swap on restart → double token consumption. -- **Step 5** (`transferUsdcToMoonbeamWithSquidrouter`): Executes a SquidRouter cross-chain swap. Crash → same issue → double swap. -- **Step 6** (`transferGlmrToMoonbeam`): XCM transfer. Crash → double XCM → double deduction from source chain. -- **Step 7** (`transferBrlaToMoonbeam`): XCM transfer. Same double-execution risk. - -None of these steps check for prior execution evidence (e.g., transaction hash from previous attempt, nonce guards, or balance pre-checks) before re-executing. - -**CTO Clarification (2026-04-02):** Crash recovery is a real concern. Steps should be made idempotent. - -**Fix:** Make each step idempotent. Recommended approach: -1. **Transaction hash guards**: Save the tx hash in state immediately after submission (before `saveState()` for the full step). On re-entry, check if the tx hash exists and verify its status before re-executing. -2. **Nonce guards**: Use explicit nonce management so re-submitted transactions are rejected as duplicates. -3. **Balance pre-checks**: Before executing a transfer, check if the expected balance change already occurred (e.g., tokens already on target chain). -4. **Atomic state + execution**: Write state before execution with an "in-progress" marker, then update to "completed" after. - ---- - -### F-037: Multiple Sensitive POST Endpoints Lack Authentication and Input Validation - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/routes/v1/ramp.route.ts` (`/ramp/update`, `/ramp/start`); `apps/api/src/api/routes/v1/pendulum.route.ts` (`/pendulum/fundEphemeral`); `apps/api/src/api/routes/v1/moonbeam.route.ts` (`/moonbeam/execute-xcm`); `apps/api/src/api/routes/v1/maintenance.route.ts` (`/maintenance/schedules/:id/active`); `apps/api/src/api/routes/v1/webhook.route.ts` (POST, DELETE) | -| **Spec** | `07-operations/api-surface.md`, Invariants 4 & 8 | -| **Status** | ✅ **FIXED** (legacy endpoints removed, auth added per CTO decisions) | -| **Found** | Code audit, iteration 2, Module 07 | -| **Impact** | Unauthenticated attackers can: (1) manipulate ramp state machine transitions, (2) trigger platform fund transfers to arbitrary ephemeral accounts, (3) execute arbitrary XCM transfers, (4) toggle maintenance mode on/off, (5) register/delete webhooks. Combined with F-001, an attacker could drain funding accounts. | - -**Description:** A systematic review of all 27 route files in `apps/api/src/api/routes/v1/` reveals that several sensitive endpoints have no authentication middleware and insufficient input validation: - -1. **`/ramp/update` (POST)** — No auth, no validation middleware. Accepts any body. Triggers ramp state machine processing via `rampController.update()`. An attacker could advance or manipulate any ramp's state. -2. **`/ramp/start` (POST)** — No auth, no validation middleware. Triggers `rampController.start()` which initiates ramp execution. Combined with knowledge of a ramp ID, an attacker could start processing. -3. **`/pendulum/fundEphemeral` (POST)** — No auth, no validation middleware. Triggers `pendulumController.fundEphemeral()` which transfers platform funds to an ephemeral account. An attacker could trigger funding of arbitrary addresses. -4. **`/moonbeam/execute-xcm` (POST)** — No auth. Only validates field existence (not types or ranges). Executes cross-chain XCM transfers via `moonbeamController.executeXcm()`. -5. **`/maintenance/schedules/:id/active` (PATCH)** — No auth. Toggles maintenance mode for schedules. An attacker could disable maintenance windows or enable them to cause service disruption. -6. **`/webhook` (POST, DELETE)** — No auth for webhook registration or deletion. Anyone can register callback URLs or delete existing webhooks. - -**CTO Clarification (2026-04-02):** -- Legacy endpoints (`/pendulum/fundEphemeral`, `/moonbeam/execute-xcm`, `/subsidize/*`) — **remove entirely** (see F-013 clarification). -- `/ramp/start`, `/ramp/update` — **unauthenticated for now** (backwards compat). Auth planned as future iteration. -- `/stellar/create` — **add requireAuth or apiKeyAuth**. -- `/maintenance/schedules/:id/active` — **add adminAuth**. -- `/webhook` POST/DELETE — **add apiKeyAuth** (partner-facing). -- `/brla/*` user data — **add requireAuth**. -- API is **directly exposed to the internet** with no network-level restrictions. - -**Fix:** -1. **Remove** legacy endpoints: `/pendulum/fundEphemeral`, `/moonbeam/execute-xcm`, `/subsidize/preswap`, `/subsidize/postswap` -2. **Add auth**: `adminAuth` on `/maintenance/*`, `apiKeyAuth` on `/webhook` POST/DELETE, `requireAuth` on `/stellar/create` and `/brla/*` user data -3. **Add input validation middleware** for all remaining endpoints -4. **Document** `/ramp/start` and `/ramp/update` as intentionally unauthenticated (temporary) with TODO for API key auth - ---- - -### F-040: Stellar CreateAccount Validation Incomplete — StartingBalance, Cosigner, and Asset Not Checked - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, lines 236-285 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit, 2026-04-07 | -| **Impact** | A malicious client can manipulate the Stellar account setup to: omit the server cosigner (making cleanup impossible and enabling fund theft), set a minimal startingBalance (causing downstream failures), or add trust for the wrong asset. | - -**Description:** The `stellarCreateAccount` path in `validateStellarTransaction()` validates that the correct operation types are present (createAccount, setOptions, changeTrust) and that the transaction source matches the expected signer. However, it does NOT validate: - -- **`startingBalance`** in the createAccount operation — client could set it to the minimum (1 XLM) instead of the required amount -- **`SetOptions` cosigner** — client could omit the server's cosigner public key, then drain the funded account unilaterally since the server would have no signing authority -- **`ChangeTrust` asset** — client could add a trustline for a worthless asset instead of the expected stablecoin - -The cosigner omission is the most dangerous: without the server cosigner, cleanup transactions cannot be authorized, and the client retains full unilateral control of the ephemeral account after it's been funded by the platform. - -**Fix:** Validate: (1) `startingBalance` meets the minimum required for the ramp, (2) `SetOptions` includes the server's cosigner public key with appropriate weight, (3) `ChangeTrust` asset code and issuer match the expected token for this ramp. - ---- - -### F-041: SELL Direction Bypasses SquidRouter Validation Entirely - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, line 94 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit, 2026-04-07 | -| **Impact** | Off-ramp (SELL) SquidRouter swap and approve transactions are not validated at all. A malicious client could submit a SquidRouter swap that routes funds to an attacker's EVM address. | - -**Description:** For SELL-direction ramps, the validation loop explicitly skips SquidRouter transactions: - -```typescript -if (direction === RampDirection.SELL && (tx.phase === "squidRouterSwap" || tx.phase === "squidRouterApprove")) continue; -``` - -This means the client's presigned SquidRouter swap and approval transactions are accepted without any content validation. The client could submit a swap routing output to a different recipient, or an approval granting allowance to an attacker contract. - -**Fix:** Remove the SELL-direction skip. Validate SquidRouter transactions for all directions, checking at minimum: the swap recipient address, the approval spender address, and the token/amount being swapped. - ---- - -### F-042: Substrate Transaction Content Never Validated — Only Signer Checked - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, lines 153-205 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit, 2026-04-07 | -| **Impact** | A malicious client could submit any Substrate extrinsic (e.g., `balances.transferAll` to an attacker address) in place of the expected swap, XCM, or bridge call. The server would execute it as long as the signer matches. | - -**Description:** `validateSubstrateTransaction()` only validates that the extrinsic signer matches the expected signer address. It does NOT decode or inspect the extrinsic content: method name, pallet, call parameters, amounts, and destination addresses are all unchecked. - -Substrate extrinsics encode the call data (pallet + method + parameters) in the payload. Without decoding and validating this, the server has no assurance that the signed extrinsic performs the intended action (e.g., a Nabla swap, an XCM transfer, a Spacewalk redeem). - -**Fix:** Decode each Substrate extrinsic using the chain's metadata and validate: (1) the pallet and method match the expected call for this phase, (2) key parameters (amounts, destination addresses) match expected values from the quote, (3) reject extrinsics with unexpected call data. - ---- - -### F-044: No Cleanup for Failed or Timed-Out Ramps — Funds Stuck on Ephemeral Accounts - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/workers/cleanup.worker.ts`, line 154 | -| **Spec** | `03-ramp-engine/ephemeral-accounts.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Ephemeral account audit, 2026-04-07 | -| **Impact** | Tokens funded to ephemeral accounts during failed ramps are permanently stuck. Platform funds used for subsidization are unrecoverable. | - -**Description:** The cleanup worker's query filter only processes ramps with `currentPhase: "complete"`: - -```typescript -currentPhase: "complete" -``` - -Ramps that fail mid-execution (e.g., after `fundEphemeral` or `subsidizePreSwap` but before the swap completes) remain in a `failed` state. Their ephemeral accounts may hold: -- Native tokens from `fundEphemeral` (platform funds) -- Subsidized tokens from `subsidizePreSwap` / `subsidizePostSwap` (platform funds) -- Swapped tokens that were never bridged or delivered - -These tokens sit indefinitely on ephemeral accounts with no recovery mechanism. Over time, this constitutes a slow drain of platform funds. - -**Fix:** Extend the cleanup worker to also query for ramps with `currentPhase: "failed"` (and optionally ramps that have been stuck in a non-terminal phase for longer than a configurable timeout, e.g., 24 hours). Add logic to detect which phases completed and which chains have residual balances, then invoke the appropriate post-process handlers. - ---- - -### F-045: No Cleanup Handler for Polygon, Hydration, or AssetHub Chains - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/post-process/index.ts` | -| **Spec** | `03-ramp-engine/ephemeral-accounts.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Ephemeral account audit, 2026-04-07 | -| **Impact** | Residual tokens on Polygon, Hydration, and AssetHub ephemeral accounts are never recovered. For Polygon (Monerium EURe) and Hydration (swap outputs), these can be significant amounts. | - -**Description:** Post-process handlers exist for three chains: Stellar (`StellarPostProcessHandler`), Pendulum (`PendulumPostProcessHandler`), and Moonbeam (`MoonbeamPostProcessHandler`). Three chains that ephemeral accounts may hold tokens on have NO cleanup handler: - -- **Polygon** — Monerium EURe on-ramp mints tokens to the Polygon ephemeral account. After the ramp completes, any dust or failed-transfer tokens remain. -- **Hydration** — Hydration swap operations may leave residual tokens on the Hydration ephemeral account. -- **AssetHub** — XCM transfers through AssetHub may leave residual tokens if the transfer fails partway. - -**Fix:** Implemented post-process handlers for all three chains: (1) **Polygon** — presigned `approve(fundingAddress, maxUint256)` created at registration time; handler broadcasts the approve, checks ERC-20 balance via `balanceOf`, and calls `transferFrom` using the server's `MOONBEAM_FUNDING_PRIVATE_KEY`. (2) **Hydration** — presigned `utility.batchAll([tokens.transferAll, balances.transferAll])` created at registration time; handler decodes and submits via `submitExtrinsic` (same pattern as Pendulum). (3) **AssetHub** — explicit no-op (no ephemeral on AssetHub). Route builders updated: `monerium-to-evm.ts`, `alfredpay-to-evm.ts`, `monerium-to-assethub.ts`, `avenia-to-assethub.ts`. Validation updated with `polygonCleanup` → EVM, `hydrationCleanup` → Substrate. - ---- - -### F-048: Stellar Payment Allows Extra Operations — No Operation Count Check - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, lines 287-301 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit (checklist walkthrough), 2026-04-07 | -| **Impact** | A malicious client can inject additional operations into the Stellar payment transaction that execute alongside the legitimate payment. | - -**Description:** The `stellarCreateAccount` validation enforces `transaction.operations.length !== 3` to ensure exactly 3 operations. However, the `stellarPayment` validation only checks `operations[0].type === "payment"` and `transaction.source === signer` — it does NOT check the operation count. A malicious client could craft a Stellar transaction with: - -- Operation 0: legitimate payment (passes validation) -- Operation 1: a second payment to an attacker's Stellar address -- Operation 2: an account merge sending the remaining XLM balance to the attacker - -All additional operations would execute atomically with the legitimate payment since they're in the same Stellar transaction envelope. - -**Fix:** Add `transaction.operations.length === 1` check for `stellarPayment` transactions, matching the pattern used for `stellarCreateAccount`. - ---- - -### F-053: Multiple Phase Handlers Lack Idempotency Guards — Double-Execution on Retry - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/stellar-payment-handler.ts`, `pendulum-to-assethub-phase-handler.ts`, `pendulum-to-hydration-xcm-phase-handler.ts`, `hydration-swap-handler.ts`, `nabla-swap-handler.ts` | -| **Spec** | `03-ramp-engine/ramp-phase-flows.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Phase flow audit (checklist walkthrough), 2026-04-07 | -| **Impact** | If the phase processor retries these handlers (due to 10-minute timeout or recoverable error), they will re-execute the on-chain transaction, causing double swaps, double XCM transfers, or double Stellar payments — all resulting in direct fund loss. | - -**Description:** Five phase handlers that submit on-chain transactions have NO explicit idempotency guard (no nonce check, no tx hash guard, no balance pre-check): - -1. **`stellar-payment-handler.ts`** — Submits the presigned Stellar payment XDR directly. No check for prior submission. Double submission sends the payment amount twice. -2. **`pendulum-to-assethub-phase-handler.ts`** — Submits presigned XCM extrinsic. Stores `pendulumToAssethubXcmHash` after submission but never checks it before submitting. If the phase times out after submission but before the hash is stored, retry causes double XCM. -3. **`pendulum-to-hydration-xcm-phase-handler.ts`** — Same pattern as above. Stores `pendulumToHydrationXcmHash` but doesn't check it before submission. -4. **`hydration-swap-handler.ts`** — Submits presigned Hydration DEX swap extrinsic. No hash guard, no nonce check. Double swap consumes tokens twice. -5. **`nabla-swap-handler.ts`** — Submits presigned Nabla DEX swap extrinsic. No hash guard. Double swap means the second swap operates on an empty balance (likely failing, but consuming gas and causing a failed ramp). - -By contrast, handlers like `spacewalk-redeem-handler` (nonce guard), `moonbeam-to-pendulum-handler` (hash guard), and `squid-router-phase-handler` (hash/nonce guard) demonstrate the correct pattern. - -**Fix:** Add idempotency guards to each handler: -1. **Hash guard pattern**: Before submitting, check if the tx hash already exists in state. If yes, skip to the waiting/verification path. Store the hash immediately after submission (before waiting for finalization). -2. **Nonce guard pattern**: Compare the ephemeral account's current nonce against the expected nonce. If the nonce has advanced, the transaction was already included — skip to verification. -3. For `stellar-payment-handler`, check the Stellar ephemeral account's sequence number or verify the payment operation on Horizon before re-submitting. - ---- - -### F-054: Backup Presigned Transactions Have No Registered Phase Handlers — Dead Code or Missing Implementation - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts`, `alfredpay-to-evm.ts`, `avenia-to-evm.ts`; `apps/api/src/api/services/phases/register-handlers.ts` | -| **Spec** | `03-ramp-engine/ramp-phase-flows.md` | -| **Status** | 🟠 **ACCEPTED** | -| **Found** | Transaction validation audit (agent investigation), 2026-04-07 | -| **Impact** | Three onramp routes build presigned transactions for phases `backupSquidRouterApprove`, `backupSquidRouterSwap`, and `backupApprove`, but NO phase handler is registered for any of these phases. If the ramp state machine ever transitions to these phases, the phase registry will have no handler to execute them — the ramp will be stuck indefinitely. If these phases are never reached, the user is signing transactions (including an unlimited ERC-20 approval) that serve no purpose and waste user interaction time. | - -**CTO Decision (2026-04-10):** Accepted — backup transactions are intentionally kept for manual execution when SquidRouter swaps fail. No automated handler needed. - -**Description:** All three onramp-to-EVM routes (`monerium-to-evm.ts`, `alfredpay-to-evm.ts`, `avenia-to-evm.ts`) build three "backup" presigned transactions per ramp: - -1. `backupSquidRouterApprove` — ERC-20 approval for the SquidRouter contract -2. `backupSquidRouterSwap` — SquidRouter swap call -3. `backupApprove` — **Unlimited** (`maxUint256`) ERC-20 approval to the platform's funding account - -These are pushed to `unsignedTxs` and the client signs them. However, `register-handlers.ts` only registers 27 handlers, and **none** of them have `getPhaseName()` returning `backupSquidRouterApprove`, `backupSquidRouterSwap`, or `backupApprove`. The `phaseRegistry.getHandler(phase)` call in the phase processor will return `undefined` for these phases. - -The backup nonce is set to `0` (or `polygonAccountNonce` for Polygon), meaning these transactions could theoretically be submitted by anyone with access to the raw signed tx data if the ephemeral account's nonce matches. - -**Fix:** Either: -- **Option A:** Implement dedicated backup handlers (or a generic backup execution handler) and register them in `register-handlers.ts`, with clear transition logic for when the primary path fails. -- **Option B:** If the backup mechanism is not yet implemented, remove the backup presigned transaction building from all three routes to avoid: (1) unnecessary user signatures, (2) a dangling unlimited approval signed by the user, (3) confusion about whether these phases can be reached. - ---- - ---- - -## 🟡 Medium - -### F-007: 50MB Body Parser Limit - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/config/express.ts` | -| **Spec** | `07-operations/api-surface.md` | -| **Status** | ✅ **FIXED** | -| **Impact** | Memory exhaustion via large request bodies. At 100 req/min rate limit, an attacker can push ~5GB/min of memory pressure per IP. | - -**Description:** `bodyParser.json({ limit: "50mb" })` is configured. Typical JSON APIs use 1-10MB. A 50MB limit combined with the global rate limit (100 req/min) allows significant memory pressure. - -**CTO Clarification (2026-04-02):** No endpoint needs more than ~1MB. The largest payload is the presigned transaction bundle from the user, which is well under 1MB. 50MB was not intentional. - -**Fix:** Reduce to `1mb` (or at most `10mb` as a safety margin). No per-route override needed. - ---- - -### F-008: Staging CORS Origin in Production - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/config/express.ts` | -| **Spec** | `07-operations/api-surface.md` | -| **Status** | ✅ **FIXED** | -| **Impact** | If the staging site is compromised or has XSS, it becomes a CORS-allowed origin for the production API. | - -**Description:** `staging--pendulum-pay.netlify.app` is in the CORS whitelist alongside production domains. This means the staging site can make authenticated cross-origin requests to production. - -**CTO Clarification (2026-04-02):** Oversight. The staging origin should NOT be in the production CORS whitelist. - -**Fix:** Remove staging origins from production CORS config. Gate behind `NODE_ENV` check. - ---- - -### F-009: Hydration XCM Skips Finalization - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/hydration-to-assethub-xcm-phase-handler.ts` | -| **Spec** | `06-cross-chain/xcm-transfers.md` | -| **Status** | 🟡 **DEFERRED** — requires investigation into Hydration finalization | -| **Impact** | A Hydration chain reorganization could revert the XCM transfer after the ramp has already transitioned to `complete`. | - -**Description:** `submitExtrinsic` is called with `waitForFinalization=false` because "it somehow doesn't work on Hydration." The handler proceeds after inclusion. If the chain reorganizes, the transfer is reverted but the ramp is already marked complete. - -**CTO Clarification (2026-04-02):** Investigate and fix. The root cause of finalization not working on Hydration should be identified and resolved rather than accepted. - -**Fix:** Investigate why `waitForFinalization=true` doesn't work on Hydration. Fix the root cause so the handler waits for finalization before proceeding. If the fix is non-trivial, add post-hoc verification (check finalization status before marking ramp complete). - ---- - -### F-010: `safeCompare` Leaks Admin Secret Length - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/middlewares/adminAuth.ts` | -| **Spec** | `01-auth/admin-auth.md` | -| **Status** | ✅ **FIXED** | -| **Impact** | Timing side-channel reveals the length of `ADMIN_SECRET`. Attacker can determine secret length before attempting brute force. | - -**Description:** `safeCompare()` returns early on `a.length !== b.length`. While the character-by-character comparison is constant-time, the length check is not. An attacker can probe with different-length tokens to determine the exact length of the admin secret. - -**Fix:** Pad or hash both inputs to equal length before comparison. Or use `crypto.timingSafeEqual` with equal-length buffers. - ---- - -### F-011: Ephemeral Webhook RSA Keys - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/config/crypto.ts` | -| **Spec** | `02-signing-keys/server-side-signing.md` | -| **Status** | ✅ **FIXED** | -| **Impact** | Webhook signatures change on every restart. Consumers lose ability to verify signatures from the previous instance. | - -**Description:** If `WEBHOOK_PRIVATE_KEY` is not set, `CryptoService` generates an ephemeral RSA keypair at startup. This key is non-persistent: webhook signatures generated before a restart cannot be verified after, and vice versa. - -**CTO Clarification (2026-04-02):** `WEBHOOK_PRIVATE_KEY` IS set in production. The ephemeral fallback is only for local development. - -**Fix:** Add a startup validation check: if `NODE_ENV === "production"` and `WEBHOOK_PRIVATE_KEY` is not set, terminate the process with a clear error. This prevents accidental deployment without the key. - ---- - -### F-012: Dynamic Pricing State In-Memory Only - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/quote/engines/discount/helpers.ts` | -| **Spec** | `03-ramp-engine/quote-lifecycle.md` | -| **Status** | ⚪ **ACCEPTED** — no code change needed | -| **Impact** | Server restart resets all partner discount states. Partners lose accumulated rate adjustments, causing abrupt rate changes. | - -**Description:** The `partnerDiscountState` Map is in-memory only. All dynamic pricing state (the `difference` value per partner) is lost on restart. - -**CTO Clarification (2026-04-02):** Acceptable. Losing dynamic pricing state on restart is fine — partners adapt quickly. No persistence needed. - ---- - -### F-015: Internal Error Messages Leaked in API Responses - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/middlewares/error.ts`, `apps/api/src/api/middlewares/auth.ts` | -| **Spec** | `00-system-overview/architecture.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2 | -| **Impact** | Internal error messages may reveal implementation details to attackers (library names, internal paths, database errors). | - -**Description:** While stack traces are correctly stripped in production, the `err.message` from arbitrary internal errors is passed through to API responses via the `converter` middleware. Additionally, `auth.ts:58` includes `details: err.message` in the response. Internal error messages can contain database connection errors, file paths, or other sensitive information. - -**Fix:** In production, replace internal error messages with generic messages (e.g., "Internal server error") unless the error is a known user-facing `APIError`. Only pass through messages from errors explicitly created for user consumption. - ---- - -### F-016: Funding Seed Accessed Directly via `process.env` - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/pendulum/pendulum.service.ts:9` | -| **Spec** | `00-system-overview/architecture.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2 | -| **Impact** | High-value signing key bypasses centralized config, making future secret rotation and access auditing harder. | - -**Description:** `const { PENDULUM_FUNDING_SEED } = process.env;` accesses the funding seed directly instead of through `config/vars.ts`. Other services (`slack.service.ts`, `priceFeed.service.ts`) also access `process.env` directly for API keys. - -**Fix:** Move all `process.env` access to `config/vars.ts`. Access all secrets through the centralized config object. - ---- - -### F-022: SEP-10 Master Secret Aliased to Stellar Funding Secret - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/constants/constants.ts:43` (`SEP10_MASTER_SECRET = FUNDING_SECRET`) | -| **Spec** | `02-signing-keys/server-side-signing.md` | -| **Status** | ⚪ **ACCEPTED** — intentional simplification, single Stellar keypair | -| **Found** | Code audit, iteration 2 | -| **Impact** | Key purpose separation violated. A vulnerability in the SEP-10 authentication flow that leaks key material would directly compromise the Stellar funding account. | - -**Description:** `SEP10_MASTER_SECRET` is set to `FUNDING_SECRET` at `constants.ts:43` rather than being loaded from its own environment variable. This means the Stellar key that holds and moves XLM funds is the same key used for SEP-10 web authentication challenges. The blast radius of a SEP-10 compromise is amplified from "authentication broken" to "funding account drained." - -**CTO Clarification (2026-04-02):** Intentional simplification — only one Stellar keypair is used. Accepted risk for now. - ---- - -### F-023: Monerium SEPA Timeout May Be Too Short (SUPERSEDED) - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/monerium-onramp-mint-handler.ts` (legacy) | -| **Spec** | `05-integrations/monerium.md` (deprecated) → see `05-integrations/mykobo.md` | -| **Status** | ⚪ **SUPERSEDED** — Monerium is removed; EUR on-ramp now uses Mykobo on Base with a 24h outer payment timeout | -| **Found** | Code audit, iteration 2, Module 05 | -| **Impact** | (Historical) Legitimate SEPA on-ramp payments could be marked as failed if Monerium took longer than 30 minutes to mint EURe after SEPA settlement. | - -**Description:** The legacy `monerium-onramp-mint-handler.ts` used `PAYMENT_TIMEOUT_MS` (30 minutes) to wait for EURe token arrival on Polygon. SEPA transfers take 1-3 business days to settle. - -**Resolution:** The EUR on-ramp has been migrated to Mykobo (`mykobo-onramp-deposit-handler.ts`) which uses a **24-hour `PAYMENT_TIMEOUT_MS`** with a 5-minute inner balance-check timeout that surfaces as a recoverable error. This matches SEPA business-day cutoffs and removes the original 30-minute tightness. The legacy 30-minute window is no longer reached by any active corridor. - ---- - -### F-024: No Concurrent SEPA Ramp Limit Per User (CARRIED FORWARD TO MYKOBO) - -| Field | Value | -|---|---| -| **Location** | Ramp creation flow (no per-user limit enforcement) | -| **Spec** | `05-integrations/mykobo.md` (formerly `monerium.md`) | -| **Status** | 🟡 **DEFERRED — STILL APPLIES** — per-user concurrent ramp limits are not enforced | -| **Found** | Code audit, iteration 2, Module 05 | -| **Impact** | Resource exhaustion — an attacker could create many SEPA-based ramps without paying, tying up system resources (polling, state tracking, phase processing). With Mykobo's 24h outer timeout the exposure window per pending ramp is **larger** than under the previous 30-minute Monerium window. | - -**Description:** No per-user concurrent ramp limit is enforced for Mykobo SEPA on-ramp flows (previously: Monerium). A user can create unlimited pending SEPA ramps. Each ramp consumes: (1) a database row with state tracking, (2) periodic phase processing cycles (polling for EURC arrival on Base), (3) a slot in the phase processor queue. With Mykobo's 24h timeout, each unpaid ramp now stays active for up to 24 hours rather than 30 minutes. - -**CTO Clarification (2026-04-02):** Yes, add a per-user limit on concurrent pending SEPA ramps. Suggested max: 3. - -**Current state:** Concurrent ramps are allowed for the same user. The proposed per-user limit for pending Mykobo SEPA ramps remains deferred. - ---- - -### F-027: `squidRouterPermitExecutionValue` Used as `msg.value` Without Validation - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts`, lines 123, 132 | -| **Spec** | `05-integrations/squid-router.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2, Module 05 | -| **Impact** | If ramp state is corrupted or manipulated, an unbounded `msg.value` could drain the executor account's native token (GLMR) balance. | - -**Description:** `state.state.squidRouterPermitExecutionValue` is read with a non-null assertion (`!`) and cast directly to `BigInt` without any validation: -- No null/undefined check (runtime `BigInt(null)` or `BigInt(undefined)` throws, potentially crashing the handler) -- No range validation (no maximum cap) -- No sanity check against expected values - -This value is used as `msg.value` in the `TokenRelayer.execute()` call, meaning it controls how much native GLMR is sent from `MOONBEAM_EXECUTOR_PRIVATE_KEY`. The value originates from presigned transaction data (server-constructed at ramp creation), so manipulation requires database access. However, defense-in-depth suggests validating this value. - -**Fix:** Add a maximum cap check (similar to `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`). Also add a null check with an unrecoverable error instead of relying on the non-null assertion. - ---- - -### F-028: Hydration→AssetHub Nonce Guard is Warning-Only; Stale Gas in Moonbeam Retry Loop - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/hydration-to-assethub-xcm-phase-handler.ts`, lines 28-32; `moonbeam-to-pendulum-handler.ts`, line 105 | -| **Spec** | `06-cross-chain/xcm-transfers.md`, Invariant 7 | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2, Module 06 | -| **Impact** | (1) Hydration handler: unnecessary error churn on retry after crash — nonce mismatch is logged as warning but submission proceeds, causing a chain-level rejection. (2) Moonbeam handler: gas price estimated once and reused across 5 retries (~100s window), potentially causing later attempts to underprice. | - -**Description:** Two related issues in XCM handlers: - -1. In `hydration-to-assethub-xcm-phase-handler.ts`, the nonce guard (lines 28-32) compares `currentEphemeralAccountNonce > nonce` but only logs a warning. Unlike the Spacewalk redeem handler (which correctly skips to the waiting path), this handler continues to submit the extrinsic, which will be rejected by the chain due to stale nonce. - -2. In `moonbeam-to-pendulum-handler.ts`, `estimateFeesPerGas()` is called once (line 105) before the 5-attempt retry loop (lines 109-126). Each retry waits 20 seconds — across 5 attempts, the gas estimate can become stale in volatile conditions. - -**Fix:** (1) Change the Hydration handler to skip re-submission when nonce indicates prior execution, similar to `spacewalk-redeem-handler.ts`. (2) Move `estimateFeesPerGas()` inside the retry loop so each attempt uses a fresh gas estimate. - ---- - -### F-030: No Output Validation on SquidRouter Swap in Final Settlement Subsidy - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts`, lines 216-264 (swap), lines 276-309 (transfer retry) | -| **Spec** | `06-cross-chain/fund-routing.md`, Threat Vector: "SquidRouter swap manipulation" | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2, Module 06 | -| **Impact** | If the SquidRouter API returns a malicious or severely unfavorable route, the swap executes without verifying the output amount. | - -**Description:** The `final-settlement-subsidy.ts` handler performs a SquidRouter swap (native → ERC-20) to top up the funding account when it has insufficient ERC-20 balance. The swap route is fetched from the SquidRouter API and executed. After the swap, the handler waits for the funding account's ERC-20 balance to meet the required subsidy amount. However, the handler does not compare the actual swap output against the expected output — if the route is manipulated, native tokens are lost. - -**Fix:** After fetching the swap route, validate that `swapRoute.estimate.toAmount` is within an acceptable range of `subsidyAmountRaw` (e.g., ≥80%). If it's dramatically lower, abort with an unrecoverable error. - ---- - -### F-032: No Pre-Check of Pendulum Funding Account Balance in Subsidy Handlers - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts`, lines 68-79; `subsidize-post-swap-handler.ts`, lines 100-110 | -| **Spec** | `06-cross-chain/fund-routing.md`, Invariant 8 | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2, Module 06 | -| **Impact** | If the Pendulum funding account runs out of tokens, subsidization transactions will fail on-chain, consuming transaction fees and triggering opaque recoverable errors without surfacing the root cause. | - -**Description:** Both subsidy handlers call `apiManager.executeApiCall()` to transfer tokens from the funding account to the ephemeral account, but neither checks the funding account's balance first. Insufficient balance creates a retry loop that won't resolve until the funding account is manually topped up, without clear diagnostics. - -**Fix:** Before executing the subsidization transfer, query the funding account's balance for the target token. If insufficient, throw a clear unrecoverable error (e.g., "Funding account balance too low for subsidy: has X, needs Y"). - ---- - -### F-034: Rebalancer SquidRouter Swap Has No Output Validation and Axelar Polling Has No Timeout - -| Field | Value | -|---|---| -| **Location** | `apps/rebalancer/src/rebalance/brla-to-axlusdc/steps.ts`, lines 202-278 | -| **Spec** | `07-operations/rebalancer.md`, Audit Checklist item 9 | -| **Status** | 🟡 **DEFERRED** — requires rebalancer app changes | -| **Found** | Code audit, iteration 2, Module 07 | -| **Impact** | (1) Received amount on Moonbeam could be significantly less than expected due to slippage, MEV extraction, or routing degradation — undetected. (2) If Axelar never reaches "executed" status, the rebalancer enters an infinite polling loop. | - -**Description:** In `transferUsdcToMoonbeamWithSquidrouter` (step 5): - -1. **No output validation**: After the SquidRouter swap completes on Moonbeam, the code never queries the actual received balance to verify it matches the SquidRouter estimate. -2. **Infinite polling loop** (lines 261-276): The Axelar status polling uses a `while(true)` loop that only exits when `status === "executed"`. No maximum poll count, no timeout, no handling for permanent failure states. - -**Fix:** -1. **Output validation**: After the swap, query the USDC balance on Moonbeam and compare to the expected amount. Log a warning if the difference exceeds a threshold (e.g., 2%), and abort if it exceeds a critical threshold (e.g., 10%). -2. **Polling timeout**: Add a maximum timeout (e.g., 30 minutes) or maximum poll count. On timeout, save state with an "axelar_timeout" marker and exit with a non-zero code. -3. **Failure states**: Handle Axelar status values other than "executed" — at minimum, log and exit on "failed" or "error" statuses. - ---- - -### F-035: 50MB JSON Body Parser Limit Enables Memory Exhaustion - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/config/express.ts`, lines 61-62 | -| **Spec** | `07-operations/api-surface.md`, Invariant 3 | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2, Module 07 | -| **Impact** | A single IP can send 100 requests/minute × 50MB = 5GB/minute of JSON that the server must parse and hold in memory. | - -**Description:** The Express configuration sets `bodyParser.json({ limit: "50mb" })`. For a payment API where the largest legitimate payload is a few KB, this limit is ~10,000x larger than necessary. - -**CTO Clarification (2026-04-02):** No endpoint needs more than ~1MB. The 50MB limit was not intentional. - -**Fix:** Reduce the body parser limit to `1mb`. - ---- - -### F-036: Staging CORS Origin Always Present in Production Whitelist - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/config/express.ts`, lines 31-37 | -| **Spec** | `07-operations/api-surface.md`, Threat Vectors table | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2, Module 07 | -| **Impact** | An XSS vulnerability on the staging frontend would grant the attacker cross-origin access to the production API with full cookie credentials. | - -**Description:** The CORS origin whitelist in `express.ts` includes `staging--pendulum-pay.netlify.app` unconditionally — it is not gated behind a `NODE_ENV !== 'production'` check. - -**CTO Clarification (2026-04-02):** Oversight. Staging should NOT be in the production CORS whitelist. - -**Fix:** Gate the staging origin behind the same `NODE_ENV` check as localhost. - ---- - -### F-043: `areAllTxsIncluded` Matches Metadata Only — Transaction Content Not Verified - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, lines 24-40 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit, 2026-04-07 | -| **Impact** | A malicious client can substitute completely different transaction data while preserving the metadata envelope, bypassing the inclusion check. | - -**Description:** `areAllTxsIncluded()` verifies that the client's presigned transactions cover all expected phases by matching on `phase`, `network`, `nonce`, and `signer` metadata. It does NOT compare the actual `txData` content. This means a client could: - -1. Receive the server's unsigned transactions (which define the expected txData) -2. Replace the txData with a malicious payload (e.g., redirecting a payment, changing a swap amount) -3. Keep the phase/network/nonce/signer metadata identical -4. Submit the modified transactions — `areAllTxsIncluded` passes because metadata matches - -While `validatePresignedTxs` provides a second layer of validation, it has its own gaps (F-038 through F-042). The inclusion check should be a strong first gate. - -**Fix:** Include a content comparison in `areAllTxsIncluded` — either compare txData directly (hash or deep equality) against the server-generated expected transactions, or include a server-side signature/HMAC over the expected txData that the client cannot forge. - ---- - -### F-046: SEPA Onramp Ramps Excluded from Cleanup - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/workers/cleanup.worker.ts`, line 156 | -| **Spec** | `03-ramp-engine/ephemeral-accounts.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Ephemeral account audit, 2026-04-07 | -| **Impact** | If a SEPA (Monerium) onramp fails after EURe is minted to the Polygon ephemeral account, the tokens are trapped with no cleanup mechanism. | - -**Description:** The cleanup worker explicitly excludes SEPA ramps: - -```typescript -from: { [Op.ne]: "sepa" } -``` - -This exclusion means that Monerium SEPA onramp ramps are never processed by the cleanup worker, regardless of their completion status. If a SEPA ramp completes normally, residual EURe dust on the Polygon ephemeral account is lost. If a SEPA ramp fails after Monerium mints EURe but before the tokens are bridged via SquidRouter, the full minted amount is trapped. - -The exclusion may have been added because SEPA ramps have a different lifecycle (polling for Monerium mint), but the cleanup concern remains: tokens on Polygon ephemeral accounts need to be swept. - -**Fix:** Evaluate whether SEPA ramps can leave residual tokens on ephemeral accounts (Polygon, Moonbeam, Pendulum). If yes, either: (1) remove the exclusion and handle SEPA ramps in the standard cleanup flow, or (2) add a SEPA-specific cleanup handler that accounts for the Monerium integration's lifecycle. - ---- - -### F-047: `getTransactionTypeForPhase` Default Silently Maps Unknown Phases to EVM - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, lines 42-70 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit (checklist walkthrough), 2026-04-07 | -| **Impact** | A new phase added to `RampPhase` that is actually Substrate-type would silently fall through to EVM validation, either throwing a confusing error or — if the txData happens to parse as valid EVM — passing without any meaningful check. | - -**Description:** The `getTransactionTypeForPhase()` switch statement maps known phases to their chain type (`Substrate`, `Stellar`, or `EVM`). The `default` case returns `EphemeralAccountType.EVM`. Approximately 15 `RampPhase` values are not in the switch: - -- `squidRouterPermitExecute`, `squidRouterPay`, `moneriumOnrampSelfTransfer`, `moneriumOnrampMint` -- `fundEphemeral`, `destinationTransfer`, `moonbeamToPendulum` -- `alfredpayOnrampMint`, `alfredpayOfframpTransfer` -- `brlaOnrampMint`, `brlaPayoutOnMoonbeam`, `finalSettlementSubsidy` -- `backupSquidRouterApprove`, `backupSquidRouterSwap`, `backupApprove` - -Most of these happen to be EVM transactions, so the default is accidentally correct. But this is fragile: if a developer adds a new Substrate-type phase without updating the switch, it silently gets EVM validation. Additionally, `squidRouterPermitExecute` falls to the default EVM path, where typed data is then skipped by the early return — creating a double bypass. - -**Fix:** Replace `default: return EphemeralAccountType.EVM` with a throw: `default: throw new Error(\`Unknown phase type: ${phase}\`)`. Explicitly add all missing phases to the appropriate case groups. - ---- - -### F-049: `stellarCleanup` Phase Gets No Content Validation - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, lines 207-302 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit (checklist walkthrough), 2026-04-07 | -| **Impact** | A malicious client could substitute a different cleanup XDR that merges the Stellar ephemeral account to an attacker address instead of the server funding account. | - -**Description:** The `stellarCleanup` phase is correctly mapped to `EphemeralAccountType.Stellar` in `getTransactionTypeForPhase`, so it enters `validateStellarTransaction`. However, that function only has phase-specific content checks for `stellarCreateAccount` (if block at line 236) and `stellarPayment` (if block at line 287). The `stellarCleanup` phase falls through both if-blocks and receives only: - -1. Signer matches expected signer -2. XDR parses successfully - -No validation of: merge destination, operation types, or operation count. The cleanup XDR typically contains an account merge operation that sends the ephemeral account's remaining balance to the server funding account. Without checking the merge destination, a malicious client could craft a cleanup XDR that merges to their own address. - -**Fix:** Add a `stellarCleanup` phase check that validates: (1) operation count, (2) operation type is `accountMerge`, (3) merge destination is the server's Stellar funding public key. - ---- - -### F-050: EVM Transaction `to` Address (Contract Target) Not Validated - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/validation.ts`, lines 101-151 | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit (checklist walkthrough), 2026-04-07 | -| **Impact** | A presigned EVM transaction could target any arbitrary contract address. For `squidRouterApprove`, the client could approve a malicious spender. For `squidRouterSwap`, the client could route through a malicious router contract that skims funds. | - -**Description:** `validateEvmTransaction` deserializes the transaction and checks: -- `from` matches expected signer ✅ -- `chainId` matches expected network ✅ - -But it does NOT check `to` (the contract target address). The `to` field determines which smart contract the transaction interacts with. For presigned transactions, the server generates unsigned transactions with specific `to` addresses (e.g., the SquidRouter contract, an ERC-20 token contract for approvals). The client could replace the `to` address with: -- A malicious router contract that executes the swap but sends output to an attacker -- A malicious token contract for the approval, granting allowance on the wrong token -- Any arbitrary contract - -**Fix:** Validate that `transactionMeta.to` matches the expected contract address for the phase. For `squidRouterApprove`, verify `to` is the expected ERC-20 token contract. For `squidRouterSwap`, verify `to` is the known SquidRouter contract address. - ---- - -### F-051: No Alerting or Monitoring for Cleanup Failures - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/workers/cleanup.worker.ts` | -| **Spec** | `03-ramp-engine/ephemeral-accounts.md` | -| **Status** | 🟡 **DEFERRED** | -| **Found** | Ephemeral account audit (checklist walkthrough), 2026-04-07 | -| **Impact** | Cleanup failures accumulate silently. Funds trapped on ephemeral accounts go unnoticed until someone manually inspects logs or the database. | - -**CTO Decision (2026-04-10):** Deferred — cleanup alerting is not crucial at this stage. - -**Description:** The cleanup worker logs errors via `logger.error()` and retries failed handlers on subsequent cycles, but never sends a Slack alert or triggers any monitoring notification. `SlackNotifier` exists and is used elsewhere in the codebase (e.g., balance alerts in `pendulum.controller.ts`) but is not wired into the cleanup worker. - -If a cleanup handler fails repeatedly (e.g., due to an RPC outage on a specific chain), the ramp's `postCompleteState.cleanup.errors` array grows but nobody is notified. The 5-minute cron cycle keeps retrying the same failed handlers indefinitely, but if the root cause requires manual intervention (e.g., an expired Stellar account, a chain upgrade that changed the extrinsic format), funds remain trapped. - -**Fix:** Add `SlackNotifier` integration to the cleanup worker. Send an alert when: (1) a cleanup handler fails for the same ramp more than N times (e.g., 3 consecutive cycles = 15 minutes), or (2) the total number of ramps with failed cleanup exceeds a threshold. Include the ramp ID, handler name, and error message in the alert. - ---- - -### F-052: No Manual Cleanup Trigger Endpoint - -| Field | Value | -|---|---| -| **Location** | No endpoint exists — gap in `apps/api/src/api/routes/v1/` | -| **Spec** | `03-ramp-engine/ephemeral-accounts.md` | -| **Status** | 🟡 **DEFERRED** | -| **Found** | Ephemeral account audit (checklist walkthrough), 2026-04-07 | -| **Impact** | If automated cleanup fails repeatedly for a specific ramp, there is no way to manually trigger a cleanup attempt without direct database modification or service restart. | - -**CTO Decision (2026-04-10):** Deferred — manual cleanup trigger is not crucial at this stage. - -**Description:** The cleanup worker runs on a 5-minute cron and processes ramps automatically. However, there is no admin API endpoint to manually trigger cleanup for a specific ramp ID. If a ramp's cleanup is stuck (e.g., the handler keeps failing due to a chain-specific issue that has since been resolved), an operator must either: -- Wait for the next automatic cycle (which will retry the same failed handler) -- Directly modify the database to reset the cleanup state -- Restart the service - -None of these are ideal for an operations team responding to a stuck-funds incident. - -**Fix:** Add an admin-authenticated endpoint (e.g., `POST /v1/admin/cleanup/:rampId`) that: (1) validates the ramp exists and has `currentPhase: "complete"` or `"failed"`, (2) resets the cleanup error state, (3) triggers post-process handlers immediately for that ramp, (4) returns the result. Protect with `adminAuth` middleware. - ---- - -### F-055: Unlimited ERC-20 Approval (maxUint256) in Backup Presigned Transactions - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts:183-203`, `alfredpay-to-evm.ts:190-209`, `avenia-to-evm.ts:235-254` | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | 🟡 **ACCEPTED** | -| **Found** | Transaction validation audit (agent investigation), 2026-04-07 | -| **Impact** | The ephemeral account signs an unlimited (`2^256 - 1`) ERC-20 token approval to the platform's funding account. If the signed `backupApprove` transaction is broadcast (by the platform or an attacker who obtains the raw tx data), the funding account gains unlimited transfer authority over ALL tokens of that type on the ephemeral account — not just the ramp's expected amount. | - -**CTO Decision (2026-04-10):** Accepted — backup mechanism with unlimited approval is intentional for manual recovery of failed SquidRouter swaps. Kept as-is. - -**Description:** All three onramp-to-EVM routes compute a `backupApprove` presigned transaction with: - -```typescript -const maxUint256 = 2n ** 256n - 1n; -const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); -const backupApproveTransaction = await addDestinationChainApprovalTransaction({ - amountRaw: maxUint256.toString(), - destinationNetwork: toNetwork as EvmNetworks, - spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenForFallback -}); -``` - -The spender is the platform's Moonbeam funding account (an EOA derived from `MOONBEAM_FUNDING_PRIVATE_KEY`). While this account is controlled by the platform, the approval amount is excessively permissive. If the funding account's private key is compromised, the attacker could drain ALL ephemeral accounts that have signed this approval — not just the ramp amount. - -Additionally, the `backupApprove` nonce is set to `0` (or `polygonAccountNonce` for Polygon), meaning on non-Polygon networks the tx is valid starting from the ephemeral account's first transaction. - -**Fix:** Replace `maxUint256` with the exact expected backup transfer amount (e.g., `quote.outputAmountRaw` plus a small buffer). This limits the blast radius if the funding key is compromised. - ---- - -### F-056: `sandboxEnabled` Bypasses ChainId Validation and Skips Entire Ramp Flow - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/initial-phase-handler.ts:32-35`; `apps/api/src/api/services/transactions/validation.ts:145` | -| **Spec** | `03-ramp-engine/transaction-validation.md`, `03-ramp-engine/state-machine.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit (code review), 2026-04-07 | -| **Impact** | If `SANDBOX_ENABLED=true` is accidentally set in production (or if an attacker can influence environment variables), ALL ramps skip every phase and immediately complete, and EVM chainId validation is disabled. Funds would not actually move, but ramps would appear successful. | - -**Description:** Two critical behaviors change when `config.sandboxEnabled` is `true`: - -1. **Initial phase handler** (line 32-35): Instead of routing to the correct first phase based on ramp type and currency, the handler waits 10 seconds and transitions directly to `"complete"`: - ```typescript - if (config.sandboxEnabled) { - await new Promise(resolve => setTimeout(resolve, 10000)); - return this.transitionToNextPhase(state, "complete"); - } - ``` - -2. **EVM transaction validation** (line 145): The chainId check is skipped: - ```typescript - if (Number(transactionMeta.chainId) !== getNetworkId(tx.network) && Boolean(config.sandboxEnabled) !== true) { - ``` - -There is no runtime guard to ensure `sandboxEnabled` cannot be `true` when `NODE_ENV=production`. The value is read directly from `process.env.SANDBOX_ENABLED === "true"` in `config/vars.ts`. - -**Fix:** Add an explicit guard in `config/vars.ts` or at app startup: if `NODE_ENV === "production"` and `SANDBOX_ENABLED === "true"`, throw an error and refuse to start. Additionally, log a warning at startup when sandbox mode is active. - ---- - -### F-057: `destinationTransfer` Handler Sends Presigned Transaction Without Validating Destination Address - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts:40,74-76` | -| **Spec** | `03-ramp-engine/transaction-validation.md`, `03-ramp-engine/ephemeral-accounts.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Transaction validation audit (agent investigation), 2026-04-07 | -| **Impact** | The `DestinationTransferHandler` retrieves the presigned `destinationTransfer` transaction and broadcasts it via `sendRawTransactionWithRetry()` without independently verifying that the transfer's `to` address matches the user's destination address from the quote. Combined with F-050 (EVM `to` address not validated during presigned tx submission), a malicious API client could craft a presigned `destinationTransfer` that sends tokens to an attacker's address instead of the user's address. | - -**Description:** The handler at line 40 retrieves the raw presigned tx: -```typescript -const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); -``` - -At line 74, it broadcasts it directly: -```typescript -const txHash = await evmClientManager.sendRawTransactionWithRetry( - quote.network as EvmNetworks, - destinationTransfer as `0x${string}` -); -``` - -The handler does check the expected amount via `checkEvmBalanceForToken` (ensuring the ephemeral account has the tokens), but never decodes the presigned transaction to verify that the `to` address matches `quote.toAddress` or any expected recipient. Since F-050 shows that `validatePresignedTxs` also doesn't check `to`, there is no validation of the destination address anywhere in the pipeline. - -**Fix:** Before broadcasting, decode the raw presigned `destinationTransfer` transaction and verify that the `to` address (the ERC-20 transfer recipient) matches the expected destination from the quote. Alternatively, fix F-050 to validate `to` during the presigned tx submission step, which would cover this case systemically. - ---- - -## 🔵 Low / ⚪ Info - -### F-017: Database TLS Not Explicitly Configured - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/config/database.ts` | -| **Spec** | `00-system-overview/architecture.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2 | -| **Impact** | If the database server does not enforce TLS, connections could be unencrypted, exposing credentials and data in transit. | - -**Description:** The Sequelize configuration does not include `dialectOptions.ssl`. Whether TLS is used depends entirely on the database server configuration. - -**Fix:** Add `dialectOptions: { ssl: { require: true, rejectUnauthorized: true } }` to the Sequelize configuration for production. - ---- - -### F-018: Token Verification Uses Anon-Key Supabase Client Instead of Service-Role Client - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/auth/supabase.service.ts:147` | -| **Spec** | `01-auth/supabase-otp.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2 | -| **Impact** | Functionally correct but deviates from spec and best practice. | - -**Description:** `SupabaseAuthService.verifyToken()` calls `supabase.auth.getUser(accessToken)` using the anon-key client, not `supabaseAdmin.auth.getUser(accessToken)` with the service-role key. The spec explicitly requires "MUST use `SUPABASE_SERVICE_KEY`." - -**Fix:** Change `supabase.auth.getUser(accessToken)` to `supabaseAdmin.auth.getUser(accessToken)`. - ---- - -### F-019: No Startup Validation for Supabase Configuration - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/config/vars.ts:115-118`, `apps/api/src/config/supabase.ts` | -| **Spec** | `01-auth/supabase-otp.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2 | -| **Impact** | Service starts normally with empty Supabase config — all authenticated endpoints silently return 401. | - -**Description:** `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_KEY` all default to empty string `""` in `vars.ts`. No startup validation checks these values. - -**Fix:** Add startup validation that terminates the process if any of the three Supabase config values are empty when `NODE_ENV === "production"`. - ---- - -### F-020: Failed Admin Auth Attempts Not Logged - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/middlewares/adminAuth.ts` | -| **Spec** | `01-auth/admin-auth.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2 | -| **Impact** | Brute-force attacks against admin endpoints are invisible in server logs. | - -**Description:** The `adminAuth` middleware only logs errors that occur during the authentication process (exceptions in the catch block). Intentional rejections — missing auth header (401) and invalid token (403) — produce no log output. - -**Fix:** Add `logger.warn()` for both rejection paths with IP, path, and reason. - ---- - -### F-021: No Address Format Validation for Ephemeral Accounts - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/ramp/ramp.service.ts:63-88` (`normalizeAndValidateSigningAccounts`) | -| **Spec** | `02-signing-keys/ephemeral-accounts.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2 | -| **Impact** | Malformed or empty addresses accepted for ramp registration. Transactions with invalid addresses fail unpredictably deep in the pipeline. | - -**Description:** `normalizeAndValidateSigningAccounts()` validates that `account.type` is a valid `EphemeralAccountType` but `account.address` is **never validated** — no format check for any chain type. - -**Fix:** Add chain-specific address validation: -- Stellar: `StrKey.isValidEd25519PublicKey(address)` -- Substrate: SS58 decode or prefix check -- EVM: `isAddress(address)` from viem/ethers - ---- - -### F-025: `HORIZON_URL` Import Inconsistency - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/helpers/stellar-payment-verifier.ts` line 4 vs `apps/api/src/api/services/phases/handlers/helpers.ts` line 5 | -| **Spec** | `05-integrations/stellar-anchors.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2, Module 05 | -| **Impact** | If local constants and shared package diverge in `HORIZON_URL` definition, the payment verifier could check a different Horizon server than the one used for payment submission. | - -**Description:** `stellar-payment-verifier.ts` imports `HORIZON_URL` from the local constants file, while other Stellar handlers import it from `@vortexfi/shared`. This creates a maintenance risk if the two sources diverge. - -**Fix:** Standardize all `HORIZON_URL` imports to use `@vortexfi/shared`. - ---- - -### F-026: `@ts-ignore` on Nonce Access in Spacewalk Redeem Handler - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/spacewalk-redeem-handler.ts`, lines 72-73 | -| **Spec** | `05-integrations/stellar-anchors.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2, Module 05 | -| **Impact** | If Polkadot API types change in a dependency update, `.nonce.toNumber()` may silently return incorrect values, breaking the nonce re-execution guard. | - -**Description:** `// @ts-ignore` is used before `api.query.system.account(pendulumEphemeralAddress)` to suppress a type error. The `.nonce.toNumber()` call relies on a specific shape of the returned account info that the TypeScript types no longer reflect. - -**Fix:** Replace `@ts-ignore` with proper type handling — cast through a known interface using `.toJSON()` with an appropriate type assertion. - ---- - -### F-031: Post-Swap Routing Has No Default Error Case - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts`, lines 128-148 | -| **Spec** | `06-cross-chain/fund-routing.md`, Invariant 7 | -| **Status** | ✅ **FIXED** | -| **Found** | Code audit, iteration 2, Module 06 | -| **Impact** | If a new ramp flow is added with an unrecognized routing combination, it would silently fall through to `spacewalkRedeem`, which may not be correct. | - -**Description:** The `nextPhaseSelector` method uses a series of `if` statements to determine the next phase, with `return "spacewalkRedeem"` as an implicit catch-all. Future SELL flows with different output currencies could be silently misrouted. - -**Fix:** Add an explicit `else` clause that throws an error for unrecognized combinations. - ---- - -### F-058: No Per-Presigned-Transaction TTL After Ramp Starts - -| Field | Value | -|---|---| -| **Location** | `apps/api/src/models/rampState.model.ts` (presignedTxs JSONB field); `apps/api/src/api/services/phases/base-phase-handler.ts` (`getPresignedTransaction`) | -| **Spec** | `03-ramp-engine/transaction-validation.md` | -| **Status** | 🔵 **ACCEPTED** | -| **Found** | Transaction validation audit (agent investigation), 2026-04-07 | -| **Impact** | Once a ramp starts, presigned transactions stored in `RampState.presignedTxs` have no expiry. If a ramp gets stuck in a non-terminal phase and the recovery worker retriggers it days later, the presigned transactions (which may reference stale nonces, changed on-chain state, or revoked approvals) will be used as-is. | - -**CTO Decision (2026-04-10):** Accepted — no-age-limit is intentional so stuck ramps can always be continued regardless of timing. - -**Description:** The `PresignedTx` model has no `createdAt` or `expiresAt` field. `getPresignedTransaction()` simply does `state.presignedTxs?.find(tx => tx.phase === phase)` with no age check. While the `RampRecoveryWorker` detects stale ramps (>10 min inactive) and retriggers processing, this recovery mechanism uses the same presigned transactions regardless of age. - -Time-related constraints that exist: -- `RAMP_START_EXPIRATION_TIME_SECONDS` (480s / 8 min) — enforced at `startRamp()` only, before processing begins -- `MAX_EXECUTION_TIME_MS` (10 min) — per-phase timeout in `PhaseProcessor` -- `RampRecoveryWorker` — retriggers stale ramps after 10 min of inactivity - -None of these invalidate the presigned transactions themselves. A ramp could theoretically be retried many hours after its presigned transactions were created, if repeated failures and recoveries occur. - -**Fix:** Add an optional `createdAt` timestamp to the `PresignedTx` structure and enforce a maximum age (e.g., 1 hour) in `getPresignedTransaction()`. If the presigned tx is older than the limit, throw an unrecoverable error and transition the ramp to `failed` instead of attempting to use stale transactions. - ---- - -## 🔴🟠🟡 Smart Contract Findings (All Verified Fixed) - -All 12 TokenRelayer findings from two prior security reviews have been **verified as fixed** in the current contract (`TokenRelayer.sol`, pragma ^0.8.28): - -| ID | Severity | Finding | Status | -|---|---|---|---| -| C-1 | 🔴 Critical | Reentrancy in `execute()` | ✅ Fixed — `ReentrancyGuard` + CEI pattern | -| C-2 | 🔴 Critical | Signature malleability | ✅ Fixed — OZ `ECDSA.recover()` | -| H-1 | 🟠 High | Unlimited token approval | ✅ Fixed — Exact approval + revoke after call | -| H-2 | 🟠 High | Destination mismatch | ✅ Fixed — Hardcoded `destinationContract` in digest | -| M-1 | 🟡 Medium | No ETH recovery | ✅ Fixed — `receive()` + `withdrawETH()` | -| M-2 | 🟡 Medium | Permit front-running | ✅ Fixed — try-catch with allowance fallback | -| M-3 | 🟡 Medium | Test ABI mismatch | ✅ Fixed — `payloadValue` in both test files | -| L-1 | 🔵 Low | Redundant `executedCalls` | ✅ Fixed — Removed | -| L-2 | 🔵 Low | No event for `withdrawToken` | ✅ Fixed — `TokenWithdrawn` + `ETHWithdrawn` events | -| I-1 | ⚪ Info | No access control library | ✅ Fixed — OZ `Ownable` | -| I-2 | ⚪ Info | Redundant return from `execute()` | ✅ Fixed — Returns void | -| I-3 | ⚪ Info | Manual EIP-712 construction | ✅ Fixed — OZ `EIP712` | - ---- - -## Phase 4: Fresh Security Audit Pass (F-059 — F-067) - -> Discovered during a comprehensive re-audit of webhooks, input validation, race conditions, amount handling, and SDK security. - -### F-059: Quote Double-Binding Race Condition - -| Field | Value | -|---|---| -| **Severity** | 🟠 **High** | -| **Location** | `apps/api/src/api/services/ramp/ramp.service.ts` (lines 132-171), `apps/api/src/api/services/ramp/base.service.ts` (lines 116-124), `apps/api/src/models/rampState.model.ts` (lines 228-231) | -| **Spec** | `03-ramp-engine/quote-lifecycle.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Fresh audit pass, race conditions investigation | -| **Impact** | Two concurrent `registerRamp` requests can bind the same quote to two separate ramps, enabling double-spend or duplicate ramp processing. | - -**Description:** `registerRamp` runs inside a database transaction, but has three compounding weaknesses: - -1. **No `SELECT FOR UPDATE`:** `QuoteTicket.findByPk(quoteId, { transaction })` on line 136 does not acquire a row-level lock. Two concurrent transactions can both read the same quote as `"pending"`. -2. **Unchecked `consumeQuote` return value:** `consumeQuote()` (line 171) returns `[affectedRowCount, updatedRows]`, but the return value is **discarded**. If the first transaction commits and changes status to `"consumed"`, the second transaction's UPDATE matches 0 rows — but the code doesn't notice and proceeds to create a second `RampState`. -3. **No unique constraint on `quoteId`:** The `idx_ramp_quote` index on `rampState.quoteId` is **non-unique**, so the database won't reject duplicate ramps referencing the same quote. - -**Exploitation scenario:** Attacker sends two simultaneous `POST /v1/ramp/register` requests with the same `quoteId`. Both transactions read the quote as "pending", both create RampStates, and only one actually flips the quote to "consumed". The second ramp is now bound to a consumed quote but proceeds normally. - -**Resolution (Option C):** Applied all three defenses: -1. Added `{ lock: Transaction.LOCK.UPDATE }` to `QuoteTicket.findByPk()` in `ramp.service.ts` to prevent concurrent reads. -2. Changed `consumeQuote()` call to check returned `affectedRows` — throws `CONFLICT` if 0 rows affected (quote already consumed). -3. Migration `026-add-unique-constraint-ramp-quote-id` replaces non-unique `idx_ramp_quote` index with unique constraint `uq_ramp_states_quote_id`. Model updated to reflect unique index. - ---- - -### F-060: Subsidy Amount Validation Missing Positive/NaN Guards - -| Field | Value | -|---|---| -| **Severity** | 🟡 **Medium** | -| **Location** | `apps/api/src/api/controllers/subsidize.controller.ts` (lines 28-32), `apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts`, `subsidize-post-swap-handler.ts` | -| **Spec** | `06-cross-chain/fund-routing.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Fresh audit pass, amount handling investigation | -| **Impact** | Negative, zero, NaN, or Infinity subsidy amounts could propagate to on-chain token transfers. | - -**Description:** `validateSubsidyAmount()` only checks that the amount doesn't exceed `maximumSubsidyAmountRaw`. It does **not** reject: -- Negative amounts (e.g., `"-1000"`) -- Zero amounts -- Non-numeric strings (e.g., `"NaN"`, `"Infinity"`) - -The REST endpoints (`/v1/subsidize/preswap`, `/v1/subsidize/postswap`) are **not mounted** in the v1 router (dead code), so the public attack surface is limited. However, the same `validateSubsidyAmount` function is used by the internal phase handlers (`SubsidizePreSwapPhaseHandler`, `SubsidizePostSwapPhaseHandler`), which call it with values derived from quote metadata. A corrupted or manipulated quote could propagate invalid amounts through the internal subsidy flow. - -**Resolution:** Added try/catch around `Big(amount)` construction to reject non-numeric strings, added `amountBig.lte(0)` guard to reject zero and negative values. Both checks now throw before the max-amount check. - ---- - -### F-061: No Maximum Amount Enforcement in Quote Finalization - -| Field | Value | -|---|---| -| **Severity** | 🟡 **Medium** | -| **Location** | `apps/api/src/api/services/quote/engines/finalize/onramp.ts` (line 83), `apps/api/src/api/services/quote/engines/finalize/offramp.ts` (line 48), `apps/api/src/api/services/quote/core/validation-helpers.ts` | -| **Spec** | `03-ramp-engine/quote-lifecycle.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Fresh audit pass, amount handling investigation | -| **Impact** | Users can create quotes with arbitrarily large amounts, potentially exceeding intended per-ramp limits. | - -**Description:** `validateAmountLimits()` is a generic helper that supports both `"min"` and `"max"` limit types, and token configs define `maxBuyAmountRaw` / `maxSellAmountRaw`. However, the finalize engines **only call it with `"min"`**: - -- `OnRampFinalizeEngine.validate()` → `validateAmountLimits(..., "min", ...)` -- `OffRampFinalizeEngine.validate()` → `validateAmountLimits(..., "min", ...)` - -The `"max"` path is **never invoked** anywhere in the codebase. This means `maxBuyAmountRaw` and `maxSellAmountRaw` in token configs are defined but unenforced. - -**Resolution:** Added `validateAmountLimits(..., "max", ...)` calls alongside the existing `"min"` calls in both `OnRampFinalizeEngine.validate()` and `OffRampFinalizeEngine.validate()`. - ---- - -### F-062: SDK Logs API Key to Console - -| Field | Value | -|---|---| -| **Severity** | 🟡 **Medium** | -| **Location** | `packages/sdk/src/services/ApiService.ts` (line 19) | -| **Spec** | `07-operations/secret-management.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Fresh audit pass, SDK security investigation | -| **Impact** | API keys are written to the console/log output of any application using the SDK. In Node.js server environments, this could expose the API key in log aggregators. | - -**Description:** Line 19 of `ApiService.ts`: -```typescript -console.log("Creating quote with request:", request); -``` -The `request` object passed to `createQuote` already has `apiKey` merged in (from `VortexSdk.createQuote()` line 55: `{ ...request, api: true, apiKey: this.apiKey }`). This logs the full request object, including the API key, on every quote creation. - -**Resolution:** Removed the `console.log` statement entirely. - ---- - -### F-063: SquidRouter High Slippage Rejection Disabled - -| Field | Value | -|---|---| -| **Severity** | 🟡 **Medium** | -| **Location** | `packages/shared/src/services/squidrouter/route.ts` (lines 193-198) | -| **Spec** | `05-integrations/squid-router.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Fresh audit pass, SDK/shared security investigation | -| **Impact** | Routes with aggregate slippage >2.5% are accepted without rejection. Users could receive significantly less value than quoted if SquidRouter returns a high-slippage route. | - -**Description:** The code detects high slippage and logs a warning, but the rejection is commented out: -```typescript -if (slippage > 2.5) { - logger.current.warn(`Received route with high slippage: ${slippage}%. Request ID: ${requestId}`); - // FIXME: temporarily disabled because we are facing issues with squidrouter routes failing the swap to USDT - // throw new Error(`The slippage of the route is too high: ${slippage}%. Please try again later.`); -} -``` -The `FIXME` comment indicates this was intentionally disabled as a workaround. However, leaving it disabled means there is no protection against high-slippage routes. - -**Resolution:** Re-enabled the `throw` statement for routes with slippage >2.5%. The 2.5% threshold remains as the existing hardcoded value. - ---- - -### F-064: BRLA KYC Callback Lacks Inbound Signature Verification - -| Field | Value | -|---|---| -| **Severity** | 🟡 **Medium** | -| **Location** | `apps/api/src/api/routes/v1/brla.route.ts` (line 31), `apps/api/src/api/controllers/brla.controller.ts` | -| **Spec** | `05-integrations/brla.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Fresh audit pass, webhook security investigation | -| **Impact** | Anyone can POST to `/v1/brla/kyc/record-attempt` to create or manipulate BRLA TaxId records. The endpoint uses `optionalAuth` (not mandatory), and there is no HMAC/signature verification to prove the request actually came from BRLA. | - -**Description:** The `POST /v1/brla/kyc/record-attempt` endpoint is designed to record KYC attempts from the BRLA integration. It uses `optionalAuth` middleware, meaning it can be called without any authentication. There is no HMAC, webhook signature, or IP allowlist to verify the request originates from BRLA. - -The endpoint can write to the `TaxId` model (create/update records with KYC status), which is used downstream in the BRL ramp flow to determine whether a user has sufficient KYC level. - -**Note:** The system already implements outbound webhook signing (RSA-PSS via `WebhookDeliveryService`), so the pattern for signature verification exists — it just isn't applied to inbound callbacks. - -**Resolution:** Changed `optionalAuth` to `requireAuth` on the `/kyc/record-attempt` endpoint in `brla.route.ts`, ensuring only authenticated sessions can record KYC attempts. - ---- - -### F-065: Ephemeral Keys Stored in Plaintext - -| Field | Value | -|---|---| -| **Severity** | 🔵 **Low** | -| **Location** | `packages/sdk/src/storage.ts` (lines 3-11) | -| **Spec** | `02-signing-keys/ephemeral-accounts.md` | -| **Status** | 🔵 **ACCEPTED** | -| **Found** | Fresh audit pass, SDK security investigation | -| **Impact** | Ephemeral private keys (Stellar secret, Substrate mnemonic, EVM private key) are stored as plaintext JSON on the filesystem or in `localStorage`. If the host is compromised, all ephemeral keys for active ramps are exposed. | - -**Description:** When `storeEphemeralKeys` is enabled (default: `true`), the SDK writes ephemeral secrets to: -- **Node.js:** A JSON file named `ephemerals_{rampId}.json` in the current working directory (no encryption, no restrictive file permissions). -- **Browser:** `localStorage.setItem(fileName, content)` — accessible to any JS running on the same origin. - -These files contain the full `{ address, rampId, secret, type }` for each ephemeral account (Stellar, Substrate, EVM). The secrets allow full control of the ephemeral accounts. - -**CTO Decision (2026-04-10):** Accepted — Low severity, SDK concern. Ephemeral accounts are temporary and drained during cleanup. Will address in future SDK hardening iteration. - -**Mitigating factor:** Ephemeral accounts are temporary and should be drained during cleanup. The exposure window is limited to the ramp's active duration. Also, the SDK is currently documented as Node.js-only. - ---- - -### F-066: No HTTPS Enforcement in SDK API Communication - -| Field | Value | -|---|---| -| **Severity** | 🔵 **Low** | -| **Location** | `packages/sdk/src/services/ApiService.ts` (constructor, line 16) | -| **Spec** | `07-operations/api-surface.md` | -| **Status** | 🔵 **ACCEPTED** | -| **Found** | Fresh audit pass, SDK security investigation | -| **Impact** | SDK consumers could configure `apiBaseUrl` with an HTTP URL, sending API keys, quote data, and ephemeral account metadata over an unencrypted connection. | - -**Description:** The `ApiService` constructor accepts `apiBaseUrl` as a string with no validation. There is no check that the URL uses HTTPS. All SDK API calls (quote creation, ramp registration, ramp start) use this URL directly via `fetch()`. - -**CTO Decision (2026-04-10):** Accepted — Low severity, SDK concern. Production API is served over HTTPS. Primarily affects developer misconfiguration. Will address in future SDK hardening iteration. - -**Mitigating factor:** In production, the Vortex API is served over HTTPS. This primarily affects developers misconfiguring the SDK during testing who then forget to switch to HTTPS. - ---- - -### F-067: Fee Calculation Allows Negative Fee Components - -| Field | Value | -|---|---| -| **Severity** | 🟡 **Medium** | -| **Location** | `apps/api/src/api/services/quote/core/quote-fees.ts` (lines 43-61, 96-139) | -| **Spec** | `03-ramp-engine/fee-integrity.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Fresh audit pass, amount handling investigation | -| **Impact** | A misconfigured partner fee entry with a negative `markupValue` or `vortexFeeValue` in the database would produce negative fee components, potentially increasing the user's output amount beyond the intended value. | - -**Description:** `calculateFeeComponent()` computes fees by either using an absolute value or multiplying a base amount by a relative value. There is no validation that the result is non-negative. `calculatePartnerAndVortexFees()` accumulates these components without a floor check. The `> 0` check on line 114/136 only sets a `hasApplicableFees` flag — it doesn't reject negative values. - -If a database partner record has `markupValue = -0.01` and `markupType = "relative"`, the computed markup would be negative, effectively giving the user a discount not intended by the platform. - -**Mitigating factor:** Partner records are managed by admins. This isn't directly exploitable by end users — it requires a misconfigured or compromised database entry. - -**Resolution:** Added a floor check at the end of `calculateFeeComponent()`: if the computed fee is negative, it is clamped to zero. - ---- - -## Phase 5: Mykobo Integration Audit (F-068 — F-071) - -Findings raised during the Mykobo-on-Base EUR rail audit. See `05-integrations/mykobo.md` and `03-ramp-engine/ramp-phase-flows.md`. - ---- - -### F-068: Mykobo KYC Profile Endpoints Have No Authentication - -| Field | Value | -|---|---| -| **Severity** | 🔴 **Critical** | -| **Location** | `apps/api/src/api/routes/v1/mykobo.route.ts` (lines 14-15); parent mount at `apps/api/src/api/routes/v1/index.ts` (line 150) | -| **Spec** | `05-integrations/mykobo.md` (Invariant 15), `01-auth/supabase-otp.md` | -| **Status** | ✅ **FIXED** (2026-05-22) | -| **Found** | Mykobo integration audit, 2026-05-22 | -| **Impact** | Anonymous callers can enumerate KYC profiles and submit arbitrary KYC documents (ID, source-of-funds, demographics) tied to any wallet address. This bypasses the spec's "Supabase OTP required" invariant, enables KYC-document submission floods against the Mykobo upstream, and allows attackers to associate attacker-controlled documents with arbitrary identities. | -| **Resolution** | `requireAuth` added to both `/v1/mykobo/profiles` GET and POST routes, mirroring the `alfredpay.route.ts` pattern. The GET endpoint now identifies profiles by the authenticated user's email (`req.userEmail`) via `MykoboApiService.getProfileByEmail`, and rejects requests whose `email` query parameter does not match the authenticated user's email. POST profile creation still ties `wallet_address` to the user's ephemeral, so no separate wallet-ownership check is needed there. Supabase OTP gating plus the email/`req.userEmail` match closes the anonymous-flood and oracle vectors. | - -**Description:** `mykobo.route.ts` mounts two endpoints with **no authentication middleware**: - -```typescript -router.route("/profiles").get(mykoboController.getProfileController); -router.route("/profiles").post(profileUpload, mykoboController.createProfileController); -``` - -The parent mount in `routes/v1/index.ts:150` (`router.use("/mykobo", mykoboRoute)`) does not wrap with `requireAuth` either. Compare against the sibling `alfredpay.route.ts`, which applies `requireAuth` on every user-facing endpoint. - -Spec `mykobo.md` invariant 15 requires: *"Mykobo KYC profile creation MUST be gated by Vortex auth — The `/v1/mykobo/profiles` endpoints require a Supabase OTP session; anonymous profile creation is rejected."* The code violates this invariant. - -The `GET /profiles?email=...&memo=...` endpoint is an email-keyed KYC profile-existence oracle. The `POST /profiles` endpoint accepts multipart form-data (ID document, utility bill, selfie) and forwards it to Mykobo — anonymous callers can submit forged KYC documents linked to arbitrary identities. - -**Fix:** Add `requireAuth` middleware to both routes (mirroring `alfredpay.route.ts`): - -```typescript -router.route("/profiles").get(requireAuth, mykoboController.getProfileController); -router.route("/profiles").post(requireAuth, profileUpload, mykoboController.createProfileController); -``` - -After adding auth, also verify that the `email` query parameter on GET matches the authenticated user's email (`req.userEmail`), to prevent an authenticated user from enumerating other users' KYC profile existence. - ---- - -### F-069: EUR Off-Ramp `fundEphemeral` Falls Through to Non-Existent Next Phase - -| Field | Value | -|---|---| -| **Severity** | 🟠 **High** | -| **Location** | `apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts` (`nextPhaseSelector`, lines 230-250) | -| **Spec** | `03-ramp-engine/ramp-phase-flows.md`, `05-integrations/mykobo.md` | -| **Status** | ✅ **FIXED** (2026-05-22) | -| **Found** | Mykobo integration audit, 2026-05-22 | -| **Impact** | If a EUR off-ramp ever transitions through `fundEphemeral`, `nextPhaseSelector` returns `"moonbeamToPendulum"` — a phase that has no role in the Base-only EUR off-ramp routing (`evm-to-mykobo.ts`). The phase processor would attempt to execute a handler with no presigned transaction registered for this corridor, putting the ramp into a stuck/failed state mid-flow. | -| **Resolution** | Explicit `SELL && outputCurrency === EURC → "distributeFees"` branch added to `nextPhaseSelector`. Additionally, while fixing the latent bug, also added the **active** missing branch `BUY && inputCurrency === EURC → "subsidizePreSwap"` to wire `fundEphemeral` into the Mykobo onramp flow (see the EUR onramp `fundEphemeral` companion fix below). | - -**Description:** `nextPhaseSelector` enumerates the SELL-direction branches: - -```typescript -if (state.type === RampDirection.SELL && state.from === Networks.AssetHub) { - return "distributeFees"; -} else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { - return "finalSettlementSubsidy"; -} else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL) { - return "distributeFees"; -} else { - return "moonbeamToPendulum"; // Via contract.subsidizePreSwap -} -``` - -There is no branch for `outputCurrency === FiatToken.EURC`. The BRL off-ramp uses `distributeFees` as the next phase after `fundEphemeral`; EUR off-ramps need the same routing (the Mykobo Base off-ramp presigned-tx order in `evm-to-mykobo.ts` is `distributeFees(0) → nablaApprove(1) → nablaSwap(2) → mykoboPayoutOnBase(3) → cleanup(4-6)`), but instead they fall through to the `else` branch and target `moonbeamToPendulum`. - -**Why this isn't currently surfaced:** The active EUR off-ramp dispatch path (`initial-phase-handler.ts`) does not currently route EUR through `fundEphemeral`. The bug is latent. However, any future routing change, recovery flow, or replay that reaches `fundEphemeral` with `outputCurrency === FiatToken.EURC` will hit the stuck-phase scenario. The integration tests don't catch it because they only exercise `registerRamp`, not `startRamp` through this path. - -**Fix:** Add an explicit EURC branch mirroring the BRL behavior: - -```typescript -} else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.EURC) { - return "distributeFees"; -} -``` - -Also consider replacing the default `else → "moonbeamToPendulum"` with an unrecoverable error for unrecognized SELL combinations, so future corridor additions fail loudly instead of silently falling through. - ---- - -### F-070: `MYKOBO_BASE_URL` Accepts Any Scheme — No HTTPS Enforcement - -| Field | Value | -|---|---| -| **Severity** | 🟡 **Medium** | -| **Location** | `packages/shared/src/services/mykobo/mykoboApiService.ts` (constructor, lines 47-53) | -| **Spec** | `05-integrations/mykobo.md` (Invariant: HTTPS enforced for all Mykobo API calls) | -| **Status** | ✅ **FIXED** (2026-05-22) | -| **Found** | Mykobo integration audit, 2026-05-22 | -| **Impact** | A misconfigured `MYKOBO_BASE_URL` (e.g., `http://...` instead of `https://...`) will silently transmit bearer tokens, KYC document references, and IBAN payment instructions over cleartext. There is no startup or runtime check that rejects non-HTTPS base URLs. | -| **Resolution** | `assertSecureMykoboBaseUrl` helper added; called from constructor. Throws on any non-HTTPS scheme. Exception: when `NODE_ENV !== "production"`, `http://localhost` and `http://127.0.0.1` are permitted for local development. | - -**Description:** The `MykoboApiService` constructor performs only path-shape normalization on the base URL: - -```typescript -if (!MYKOBO_BASE_URL) { - throw new Error("MYKOBO_BASE_URL not defined"); -} -const trimmedBase = MYKOBO_BASE_URL.replace(/\/$/, ""); -this.baseUrl = /\/v\d+$/.test(trimmedBase) ? trimmedBase : `${trimmedBase}/v1`; -``` - -No `new URL(...).protocol === "https:"` check. An operator with shell access to env vars (or a misconfigured deployment) could set `MYKOBO_BASE_URL=http://mykobo.example` and the service would silently use cleartext for all `/auth/token`, `/auth/refresh`, intent creation, and payout polling calls. This contradicts the audit-results PASS for "HTTPS enforced" (currently marked PASS for Mykobo on the strength of constructor normalization alone, which does not in fact enforce HTTPS). - -**Fix:** Add an explicit scheme check at construction time: - -```typescript -const parsed = new URL(trimmedBase); -if (parsed.protocol !== "https:" && process.env.NODE_ENV === "production") { - throw new Error("MYKOBO_BASE_URL must use HTTPS in production"); -} -``` - -For local development, allow `http://localhost` via an explicit allowlist. Also document the requirement in `.env.example`. - ---- - -### F-071: Concurrent-401 Race in `MykoboApiService.handleAuthFailure` - -| Field | Value | -|---|---| -| **Severity** | 🔵 **Low** | -| **Location** | `packages/shared/src/services/mykobo/mykoboApiService.ts` (`handleAuthFailure`, lines 109-122; `getToken`, lines 96-107) | -| **Spec** | `05-integrations/mykobo.md` (Invariant 14: Bearer-token refresh debounced) | -| **Status** | ✅ **FIXED** (2026-05-22) | -| **Found** | Mykobo integration audit, 2026-05-22 | -| **Impact** | Under concurrent load, multiple in-flight requests that each receive HTTP 401 will each independently call `handleAuthFailure()`, causing concurrent `refresh` / `acquireToken` calls to Mykobo. This is a "thundering herd" mini-race: it does not corrupt state, but it can produce redundant token rotations, brief windows where `this.cachedToken` is overwritten by a stale value, and unnecessary Mykobo `/auth/refresh` traffic that can itself trip Mykobo-side rate limiting. | -| **Resolution** | `authFailurePromise` debounce added, mirroring the existing `tokenPromise` pattern in `getToken`. Concurrent 401s now share a single in-flight refresh/re-acquire; the actual logic moved to `doHandleAuthFailure`. | - -**Description:** The happy-path token acquisition in `getToken()` is correctly debounced via `this.tokenPromise`: - -```typescript -if (!this.tokenPromise) { - this.tokenPromise = this.acquireToken().finally(() => { - this.tokenPromise = undefined; - }); -} -this.cachedToken = await this.tokenPromise; -``` - -But the 401-recovery path in `handleAuthFailure()` has no equivalent guard: - -```typescript -private async handleAuthFailure(): Promise { - if (this.cachedToken) { - try { - const refreshed = await this.refreshAccessToken(this.cachedToken.refreshToken); - this.cachedToken = refreshed; - return refreshed.token; - } catch (error) { - logger.current.warn("Mykobo refresh failed; re-acquiring token", error); - } - } - const reAcquired = await this.acquireToken(); - this.cachedToken = reAcquired; - return reAcquired.token; -} -``` - -If two requests race and both receive 401, they both enter `handleAuthFailure()` and both fire `refreshAccessToken` (or `acquireToken`) concurrently. Each then independently assigns to `this.cachedToken`, so the second write clobbers the first. The first caller may receive a token that is immediately replaced. - -**Mitigating factor:** The Mykobo handlers run inside the phase processor, which is single-threaded per ramp; the practical concurrency surface today is small (e.g., poll loops overlapping with `getProfile` calls from the HTTP layer). But the spec invariant ("Bearer-token refresh debounced — no thundering-herd on 401") is currently held only on the cold-start path. - -**Fix:** Apply the same `tokenPromise` debounce pattern to `handleAuthFailure`: - -```typescript -private authFailurePromise: Promise | undefined; - -private async handleAuthFailure(): Promise { - if (!this.authFailurePromise) { - this.authFailurePromise = this.doHandleAuthFailure().finally(() => { - this.authFailurePromise = undefined; - }); - } - return this.authFailurePromise; -} -``` - -Move the existing body of `handleAuthFailure` into `doHandleAuthFailure`. - ---- - -### F-072: Ephemeral Account Freshness Not Validated at Ramp Registration - -| Field | Value | -|---|---| -| **Severity** | 🟡 **Medium** | -| **Location** | `apps/api/src/api/services/ramp/ramp.service.ts` (`registerRamp` → `normalizeAndValidateSigningAccounts`, lines 141-216) | -| **Spec** | `02-signing-keys/ephemeral-accounts.md`, `03-ramp-engine/transaction-validation.md` | -| **Status** | ✅ **FIXED** | -| **Found** | Fresh audit pass, ephemeral lifecycle review | -| **Impact** | An API client could submit an ephemeral address that has already been used on one or more route-relevant chains (non-zero nonce, existing balance, or pre-existing Stellar account). The backend would build presigned transactions assuming nonce 0 / fresh account; mid-ramp execution would then halt with nonce mismatches, "account already exists" errors, or unexpected leftover balances, leaving subsidies/funding spent and ramps stuck. | - -**Description:** `normalizeAndValidateSigningAccounts` only validated the *format* of each provided ephemeral address (StrKey, SS58, EVM `isAddress`). It did not verify that the addresses were actually fresh on the chains the ramp would touch. Because the SDK generates ephemerals client-side and the API trusts whatever address is submitted, a buggy or malicious client could replay an old ephemeral address. Stellar is especially sensitive: the server's first action is to *create* the Stellar account on-chain with a 2-of-2 multisig and starting balance — if the account already exists, that creation operation fails and the ramp cannot proceed. - -**Fix:** Added `validateEphemeralAccountsFresh()` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`), invoked in `registerRamp` immediately after `normalizeAndValidateSigningAccounts`. The validator: - -1. **Checks every supported chain of each submitted ephemeral type unconditionally** — rather than deriving a route-relevant subset from the quote. Supported sets: Substrate = `[pendulum, hydration, assethub]`; EVM = all configured EVM networks including Moonbeam (`SUPPORTED_EVM_NETWORKS`); Stellar = single Horizon check. This is intentionally wider than strictly required so that a future phase-handler addition cannot silently reopen the freshness gap by routing through a chain not covered by a route-derived mapping. -2. **Substrate**: queries `system.account(address)` on each supported chain; requires `nonce === 0` AND `free === 0`. -3. **EVM**: queries `getTransactionCount(address)` on each supported chain; requires `nonce === 0`. -4. **Stellar**: calls `loadAccountWithRetry(address)` against Horizon; requires the account to **not exist** (the server creates and funds it during the ramp). -5. **Fail-closed**: any RPC error rejects the registration with `SERVICE_UNAVAILABLE` rather than allowing freshness to be presumed. -6. Scope: `registerRamp` only. `updateRamp` does not re-check, since the ephemeral identity is bound to ramp state at registration time. - -If the platform adds a new chain an ephemeral can ever sign on, `SUPPORTED_SUBSTRATE_NETWORKS` / `SUPPORTED_EVM_NETWORKS` MUST be updated, otherwise the freshness check leaves that chain unverified. - ---- - -## Additional Observations (Not Findings) - -These are design observations noted during spec writing that may warrant review but aren't direct vulnerabilities: - -| ID | Observation | Spec | -|---|---|---| -| O-1 | Rebalancer hardcoded `brlaBusinessAccountAddress` default (`0xDF5Fb...08b2`) | `07-operations/rebalancer.md` | -| O-2 | Rebalancer 5% slippage tolerance on Nabla swap | `07-operations/rebalancer.md` | -| O-3 | Rebalancer `gasMultiplier * 5n` on SquidRouter transactions | `07-operations/rebalancer.md` | -| O-4 | Hand-written validators (no Zod/Joi) across all 27 endpoints | `07-operations/api-surface.md` | -| O-5 | `SUPABASE_SERVICE_KEY` used for all DB operations (no least-privilege) | `07-operations/secret-management.md` | -| O-6 | No per-endpoint rate limiting — all endpoints share 100 req/min | `07-operations/api-surface.md` | -| O-7 | `minDynamicDifference` has no DB CHECK constraint — can go negative | `03-ramp-engine/quote-lifecycle.md` | -| O-8 | Quote expiry hardcoded to 10 min — not configurable via env var | `03-ramp-engine/quote-lifecycle.md` | -| O-9 | Subsidize REST endpoints (`/v1/subsidize/preswap`, `/v1/subsidize/postswap`) exist in `subsidize.route.ts` but are **not mounted** in the v1 router — dead code that should be removed | `07-operations/api-surface.md` | -| O-10 | Ephemeral keys are not zeroed from JS memory after signing — they remain until garbage collected | `02-signing-keys/ephemeral-accounts.md` | -| O-11 | AlfredPay KYC callback endpoints (`kycRedirectOpened`, `kycRedirectFinished`) have `requireAuth` but no dedicated AlfredPay signature verification — relies solely on user session auth | `05-integrations/alfredpay.md` | diff --git a/docs/security-spec/PUBLIC-RELEASE-READINESS.md b/docs/security-spec/PUBLIC-RELEASE-READINESS.md deleted file mode 100644 index 296c65ed1..000000000 --- a/docs/security-spec/PUBLIC-RELEASE-READINESS.md +++ /dev/null @@ -1,225 +0,0 @@ -# Public Release Readiness Report - -**Repository**: `pendulum-chain/vortex` (already public on GitHub) and `pendulum-chain/vortex-private` (private mirror). -**Scope**: Full secret/PII/configuration scan of tracked tree and complete git history across all branches and both remotes. -**Method**: Read-only grep + AST sweeps over working tree, `git log --all --full-history -p`, branch-containment checks, and remote-visibility verification via `gh`. - ---- - -## Executive Summary - -The `pendulum-chain/vortex` repository on GitHub is **already public**. Several secrets and operational artifacts that would normally be classified as pre-publication blockers are already exposed on public branches (including `origin/main`). This report therefore distinguishes between: - -- **Already-leaked** — the secret is in public history. Rotation is mandatory and urgent. Scrubbing history is optional and cosmetic; once a secret is on a public branch on GitHub, it must be assumed compromised regardless of subsequent rewrites. -- **Tree-only** — the issue exists in the current working tree and can still be prevented from reaching public history with a normal commit. - ---- - -## HIGH Severity - -### H1. Supabase service-role JWT hardcoded in tracked migration - -| | | -|---|---| -| **Location** | `supabase/migrations/20260304142601_remote_schema.sql:834` | -| **Secret** | `Authorization: Bearer eyJ...MlmXlQFvCGzFKEFROqgodLuPwTGeQtjificJjFJAjRA` | -| **Project** | `kglbssavflprkvsohcbg.supabase.co` | -| **Role** | `service_role` (bypasses Row Level Security) | -| **Expiry** | 2035 | -| **Embedded in** | `CREATE OR REPLACE TRIGGER "SlackNotifier"` body, called from `pg_net.http_post` | -| **Status on public origin** | Present on `origin/main` | -| **Classification** | Already-leaked | - -**Impact.** A service-role JWT grants full read/write access to every table in the Supabase project, ignoring RLS policies. With this token, an attacker can dump all data, mutate any row, and invoke any RPC as a superuser-equivalent. - -**Required actions, in order:** -1. Rotate the Supabase project's `service_role` JWT secret in the Supabase dashboard. This invalidates the leaked token immediately. -2. Audit Supabase access logs for unauthorized usage of the leaked token between commit `0e2074e85` (2026-03-23) and rotation time. -3. Refactor the trigger to read the JWT from a runtime source instead of inlining it. Recommended approaches: - - Use Supabase **Vault** (`vault.decrypted_secrets`) and reference the secret by name inside the trigger body. - - Or move the Slack notification out of the database trigger and into application code where the secret comes from `process.env`. -4. Regenerate the migration so the new version contains no token. Commit it normally — do not attempt to rewrite history (see "Scrubbing strategy" below). - ---- - -### H2. Stellar secret key committed in `signer-service-rust/.env` - -| | | -|---|---| -| **Location (history)** | `signer-service-rust/.env` at commit `76ce1c287` (2024-05-13) | -| **Deletion commit** | `f43b3cd04` (2024-06-05, "share env example") | -| **Secret** | `STELLAR_SECRET_KEY=SCVJD7BHU5LNFXNIDC7E226HISKUOZUEPJWLA2YU2GNBFMP5PYF2TQBH` | -| **Also present** | `POSTGRES_PASSWORD=1234` (low value — local-dev DB) | -| **Status on public origin** | Present on `origin/offramp-prototype` | -| **Classification** | Already-leaked | - -**Impact.** A Stellar secret key (`S...`) gives full control of the corresponding account: signing transactions, draining XLM and any held assets, and modifying account flags. The branch name `offramp-prototype` and timing (May 2024) suggest this was a development account; this must be confirmed. - -**Required actions:** -1. Determine whether the public key for this secret (derive offline) was ever funded on Stellar mainnet. Check at `https://horizon.stellar.org/accounts/`. -2. If mainnet: immediately submit a `MergeAccount` operation moving all balances to a safe account, **before** doing anything else. -3. Independent of mainnet status, treat the secret as compromised forever. Do not reuse it. -4. Optionally delete the `offramp-prototype` branch on `origin` (it is two years old and unlikely to be needed). - ---- - -## MEDIUM Severity - -### M1. Ramp-state JSON files with signed transactions and ephemeral signer addresses in history - -| | | -|---|---| -| **Files** | `api/src/api/services/phases/lastRampState.json`, `lastRampStateOnramp.json`, `signer-service/src/api/services/phases/failedRampStateRecovery.json` | -| **First committed** | `fe1f74777` (2025-04-08) | -| **Status on public origin** | Present on `origin/main`, `origin/main-backup-pre-widget` | -| **Classification** | Already-leaked | - -**Contents.** -- Pre-signed EVM transaction envelopes (with valid signatures from ephemeral keys). -- Pre-signed Stellar XDR envelopes (with valid signatures from ephemeral Stellar accounts). -- Concrete ephemeral signer addresses (e.g., `0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3`, `GBVXWRUJUSMEX75YN5KGYBNBJISFKOJBWHHX6ODQXNSXMCAIVD2BTDDD`). - -**Impact.** The signed transactions themselves do not leak the ephemeral private keys (signatures are not invertible). However: -- The signed transactions are valid envelopes that could be replayed if their account state matches (sequence number, nonce). For Stellar this is bounded by sequence numbers; for EVM this is bounded by nonces, target chain ID, and any account-touching tx already broadcast. -- The ephemeral addresses, transaction shapes, target contracts, swap routes, and fee values reveal operational patterns of the off/onramp engine. This is mainly a privacy concern, not a custody concern. - -**Required actions:** -1. Verify each of the listed ephemeral addresses on the target chains. If any account on Polygon, Stellar, Pendulum, Moonbeam, or AssetHub still holds funds, sweep them. -2. For every signed transaction in those files, check whether it is still replayable (account exists, nonce/sequence not yet consumed, fee still valid). If so, broadcast a no-op transaction at the same nonce/sequence to invalidate the envelope, or fund the account so it can be drained. -3. The files are no longer in the working tree and are now correctly ignored (`apps/api/...` paths use a different layout). Confirm `.gitignore` covers any future `lastRampState*.json` and `failedRampStateRecovery.json` artifacts in their new locations. - -### M2. `apps/api/.env.example` is incomplete - -Fifteen environment variables are read by `apps/api/src` but not documented in `apps/api/.env.example`: - -``` -EVM_FUNDING_PRIVATE_KEY -MYKOBO_ACCESS_KEY -MYKOBO_SECRET_KEY -MYKOBO_BASE_URL -MYKOBO_CLIENT_DOMAIN -ALCHEMY_API_KEY -SLACK_USER_ID -SLACK_WEB_HOOK_TOKEN -SUBSCAN_API_KEY -DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS -WEBHOOK_PUBLIC_KEY -RAMP_WIDGET_URL -LOG_LEVEL -BACKEND_TEST_STARTER_ACCOUNT -GOOGLE_CONTACT_SPREADSHEET_ID -TAX_ID -VORTEX_FEE_PEN_PERCENTAGE -``` - -**Impact.** External contributors cannot run the API without trial-and-error. None of these expose secrets in the example file (placeholders only), but their absence makes the project significantly harder to onboard. - -**Required action.** Add each variable to `apps/api/.env.example` with a placeholder and a one-line comment. - ---- - -## LOW Severity - -### L1. Sentry DSN inlined in `apps/frontend/src/main.tsx:32` - -```ts -dsn: "https://7eb35f175ccba5b5e2eb1ca00e64e053@o4508217222692864.ingest.de.sentry.io/4508217730269264" -``` - -Per Sentry's documentation, frontend DSNs are intentionally public and not authentication credentials. The remaining concern is that OSS forks running this code will silently report errors to your Sentry project, polluting your event quota. - -**Required action.** Move to `import.meta.env.VITE_SENTRY_DSN` and gate `Sentry.init()` on its presence. Document in `apps/frontend/.env.example`. - -### L2. Root `.gitignore` only matches the literal `apps/api/.env` - -The per-app `.gitignore` files cover `.env` correctly, but the root file does not enforce a project-wide pattern. Any future app added under `apps/` or `services/` will not have its `.env` ignored unless someone remembers to add an entry. - -**Required action.** Add to root `.gitignore`: - -``` -**/.env -**/.env.local -**/.env.*.local -!**/.env.example -``` - -### L3. Long-lived stale branches on public origin - -`origin/offramp-prototype`, `origin/main-backup-pre-widget`, and a large number of completed feature branches (numbered like `315-...`, `552-...`, `577-...`) remain on the public remote. They contain leaked secrets (H2, M1) and outdated code. - -**Required action.** Audit `origin` branches and delete completed feature branches, prototypes, and backups. Use `gh api repos/pendulum-chain/vortex/branches` for a full list. - ---- - -## Confirmed-Clean Categories - -The following classes of exposure were searched for and **not** found: - -- GitHub PATs (`gh[pousr]_...`, `github_pat_...`). -- npm tokens (`npm_...`), private npm scopes, registry-auth URLs in `package.json` files. -- AWS access keys (`AKIA...`, secret-access-key shapes). -- OpenAI / Anthropic / HuggingFace API keys (`sk-...`, `sk-ant-...`, `hf_...`). -- Telegram bot tokens. -- Slack webhook URLs (only placeholder `your_slack_webhook_token_here`). -- Mnemonic seed phrases (BIP-39 12/24-word patterns). -- Real 64-character hex private keys in any branch's history. All matches were either RLP-encoded transaction envelopes from contract artifacts or ABI-encoded `uint256` values. -- Internal IP addresses (RFC1918, loopback only in dev configs). -- Internal hostnames beyond the publicly documented `*.vortexfinance.co` and `*.pendulumchain.tech` infrastructure. -- Real customer/partner PII in test data (no real CPFs, BRLA accounts, or production payout addresses found). -- All tracked `.env*` files are `.env.example` placeholders. - ---- - -## Scrubbing Strategy - -The conventional advice — "rewrite history with `git filter-repo` or BFG Repo-Cleaner, then force-push" — does **not** materially reduce risk for this repository, because: - -1. The repository has been public on GitHub since at least 2024-05. -2. GitHub caches forks, pull requests, and unreachable commits indefinitely. Force-pushing a rewritten history does not delete those caches. -3. Any third party may have already cloned, mirrored, or scraped the repository. -4. The leaked Supabase JWT and Stellar secret must be rotated regardless of whether history is rewritten. - -**Recommended approach: rotate, do not rewrite.** - -1. Treat all already-leaked secrets as compromised forever. Rotate. -2. Patch the working tree so future commits are clean. -3. Add CI-level secret scanning (e.g., `gitleaks`, `trufflehog`, GitHub's native push protection) to catch the next leak before it reaches `origin`. -4. Optionally delete obsolete branches (`offramp-prototype`, `main-backup-pre-widget`, completed feature branches) to reduce the public surface, but understand that anything cached by GitHub or third parties remains accessible. - -If, after rotation, leadership still requires a history rewrite for compliance or appearance reasons: - -1. Coordinate with every active developer (force-push will require everyone to re-clone). -2. Use `git filter-repo --invert-paths --path signer-service-rust/.env --path signer-service/.env --path 'api/src/api/services/phases/lastRampState*.json' --path 'signer-service/src/api/services/phases/failedRampStateRecovery.json'`. -3. For the Supabase migration, use `git filter-repo --replace-text` to substitute the JWT with a placeholder, preserving the rest of the file. -4. Force-push to all branches on `origin`. -5. Open a GitHub support ticket to purge cached PRs and unreachable commits. - -This is significant operational disruption with marginal security benefit. It should not be the priority. - ---- - -## Action Checklist (Prioritized) - -| # | Action | Severity | Owner | -|---|---|---|---| -| 1 | Rotate Supabase service_role JWT in dashboard | HIGH | Backend on-call | -| 2 | Verify Stellar account `G...` (derived from `SCVJD...`) — sweep if funded on mainnet | HIGH | Backend on-call | -| 3 | Audit Supabase access logs since 2026-03-23 | HIGH | Backend on-call | -| 4 | Refactor `SlackNotifier` trigger to read JWT from Vault, regenerate migration | HIGH | Backend | -| 5 | Sweep any funded ephemeral accounts referenced in committed `lastRampState*.json` | MEDIUM | Backend | -| 6 | Move Sentry DSN to `VITE_SENTRY_DSN` env var | LOW | Frontend | -| 7 | Patch root `.gitignore` with `**/.env` patterns | LOW | Anyone | -| 8 | Complete `apps/api/.env.example` (15 missing vars) | MEDIUM | Backend | -| 9 | Enable GitHub Secret Scanning + Push Protection on `pendulum-chain/vortex` | MEDIUM | Repo admin | -| 10 | Add `gitleaks` pre-commit hook and CI step | LOW | Anyone | -| 11 | Delete obsolete branches on `origin` | LOW | Repo admin | - ---- - -## Appendix: Scan Methodology - -- Tracked-tree secret patterns: `git ls-files | xargs grep -nE ''`. -- History secret patterns: `git log --all --full-history --pretty=format: -p | grep -aE ''`. -- Branch containment: `git branch -a --contains `. -- Remote visibility: `gh repo view pendulum-chain/vortex --json visibility,isPrivate`. -- Patterns swept: JWT (`eyJhbGciOi...`), AWS (`AKIA[0-9A-Z]{16}`), GitHub PAT, npm token, OpenAI/Anthropic/HF keys, Telegram bot token, Slack webhook, BIP-39 mnemonic shape, 64-hex private keys, RFC1918 IPs, internal hostnames, email addresses outside `vortexfinance.co`/`pendulumchain.tech`. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 62c24b176..da78f4017 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -2,6 +2,26 @@ This directory contains the security specification for the Vortex cross-border payment platform. Each file defines the **intended behavior** of a system module — the invariants that must hold, the threats that must be mitigated, and the concrete checks an auditor should perform against the actual code. +## Document Authority + +Use documents in this order: + +1. **Module specifications** define normative current behavior. +2. **`RISK-REGISTER.md`** is the only authority for current accepted, deferred, or + deployment-dependent exceptions to those requirements. +3. **Retained review evidence** (`REVIEW-POST-1232-2026-07-30.md` and + `PUBLIC-RELEASE-READINESS.md`) is non-normative. It remains only while its review or + remediation context is still useful; status claims may be stale. +4. **Older audit results, findings trackers, and spec deltas** are kept in Git history, + not alongside the maintained specification. +5. **Implementation-side notes** such as + `apps/api/src/api/services/phases/blocks/INHERITED-ISSUES.md` provide engineering detail; every + still-active exception must also be indexed in the risk register. + +If code and a normative invariant disagree, treat the code as a finding. If a historical +document disagrees with a module specification or the risk register, the current normative +documents win. + ## Purpose 1. **Audit baseline** — During code review, each spec file acts as the source of truth for "how it should work." Any deviation between code and spec is a finding. @@ -18,6 +38,7 @@ This directory contains the security specification for the Vortex cross-border p | Module | Path | Scope | |---|---|---| +| Current Risk Register | `RISK-REGISTER.md` | Authoritative accepted, deferred, and rollout-dependent exceptions | | System Overview | `00-system-overview/architecture.md` | Trust boundaries, component map, data flows | | Supabase OTP Auth | `01-auth/supabase-otp.md` | Email OTP, session lifecycle, token handling | | API Key Auth | `01-auth/api-keys.md` | Dual-key system (pk\_/sk\_), validation, partner matching | @@ -26,14 +47,14 @@ This directory contains the security specification for the Vortex cross-border p | Server-Side Signing | `02-signing-keys/server-side-signing.md` | Funding keys, executor keys, webhook signing | | State Machine | `03-ramp-engine/state-machine.md` | Phase transitions, locking, idempotency, recovery | | Quote Lifecycle | `03-ramp-engine/quote-lifecycle.md` | Creation, expiry, binding to ramp | -| Fee Integrity | `03-ramp-engine/fee-integrity.md` | Fee calculation, dual-system discrepancy | +| Fee Integrity | `03-ramp-engine/fee-integrity.md` | Fee pipeline: quote-time snapshot, deduction, distribution, rounding | | Discount Mechanism | `03-ramp-engine/discount-mechanism.md` | Partner discounts, subsidies, dynamic adjustment | | Profile Partner Pricing | `03-ramp-engine/profile-partner-pricing.md` | Supabase profile assignments to ramp-specific partner pricing IDs | | Recipient Transfers | `03-ramp-engine/recipient-transfers.md` | Invite token hashing/retention/expiry, token-bound redemption, invitation/relationship archiving, sender↔recipient authorization, transfer eligibility gate | -| FastForex | `05-integrations/fastforex.md` | USD-fiat conversion provider hardening and fallback | | Transaction Validation | `03-ramp-engine/transaction-validation.md` | Presigned tx verification, content validation, signing model | | Ephemeral Account Lifecycle | `03-ramp-engine/ephemeral-accounts.md` | Funding, cleanup, stuck fund prevention | | Ramp Phase Flows | `03-ramp-engine/ramp-phase-flows.md` | Per-corridor token flow, phase handler map, subsidy bounds | +| Block-Flow Architecture | `03-ramp-engine/block-flow-architecture.md` | Persisted flow identity, version dispatch, topology, schemas, and executor wiring | | Token Relayer | `04-smart-contracts/token-relayer.md` | EIP-712, permit, known findings | | Integration Template | `05-integrations/_template.md` | Template for new provider specs | | BRLA | `05-integrations/brla.md` | BRLA anchor for BRL on/off-ramp | @@ -42,10 +63,8 @@ This directory contains the security specification for the Vortex cross-border p | Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | | Binance | `05-integrations/binance.md` | Binance USDT spot price used as the primary USD<>BRL rate source | | FastForex | `05-integrations/fastforex.md` | Fiat forex price provider used by quote/conversion math | -| Stellar Anchors | `05-integrations/stellar-anchors.md` | SEP-24, Spacewalk, Stellar payment (fully deprecated; EUR migrated to Mykobo, ARS removed) | | Squid Router | `05-integrations/squid-router.md` | Cross-chain EVM routing | | XCM Transfers | `06-cross-chain/xcm-transfers.md` | Pendulum↔Moonbeam↔AssetHub↔Hydration | -| Bridge Security | `06-cross-chain/bridge-security.md` | Spacewalk bridge trust model | | Fund Routing | `06-cross-chain/fund-routing.md` | Subsidization, fee distribution, amount integrity | | Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), cost/profit/opportunistic USDC→BRLA→USDC (Base), and cost/profit-aware BRLA→USDC correction (Base low-coverage) | | Secret Management | `07-operations/secret-management.md` | Env vars, rotation, blast radius | @@ -53,9 +72,24 @@ This directory contains the security specification for the Vortex cross-border p | Client Observability | `07-operations/client-observability.md` | Request IDs, sanitized API client events, operational monitoring | | Notifications | `07-operations/notifications.md` | In-app feed authorization, PII redaction rules, email dispatch status | -## Per-File Format +## Retained Evidence + +| Document | Why it remains | +|---|---| +| `REVIEW-POST-1232-2026-07-30.md` | Latest full spec-first review of the block-flow architecture and the evidence that drove its remediation | +| `PUBLIC-RELEASE-READINESS.md` | Repository-history secret exposure review with remediation actions that still require operational confirmation | + +## Checklist Semantics + +- `[x]` means the stated current-code conformance check was performed and passed. +- `[ ]` means open, partially conforming, not verified, deployment-dependent, or not applicable + to source-only review. The text must say which. +- Labels such as `[FAIL]`, `[PARTIAL]`, `[N/A]`, and `[EXISTING FINDING]` are not checkbox syntax + and must not be used in normative module checklists. + +## Usual Per-File Format -Every spec file uses exactly four sections: +Most module specifications use these sections: - **What This Does** — Brief overview, scope, why it matters for security. - **Security Invariants** — Numbered, testable MUST-hold properties. The core of the spec. @@ -68,9 +102,8 @@ Every spec file uses exactly four sections: |---|---| | **Ramp** | A conversion between fiat and crypto (on-ramp = fiat→crypto, off-ramp = crypto→fiat) | | **Ephemeral account** | A temporary blockchain account created per ramp, used for signing transactions, then discarded | -| **Phase** | A discrete step in the ramp state machine (e.g., `nablaSwap`, `spacewalkRedeem`) | +| **Phase** | A discrete step in the ramp state machine (e.g., `nablaSwap`, `distributeFees`) | | **Nabla** | DEX on Pendulum used for token swaps | -| **Spacewalk** | Bridge between Pendulum and Stellar | | **XCM** | Cross-Consensus Messaging — the cross-chain transfer protocol between Polkadot parachains | | **BRLA** | Brazilian Real stablecoin anchor (BRL on/off-ramp) | | **Mykobo** | EUR fiat anchor for SEPA on/off-ramp on Base (settles EURC on Base; currently registration-gated) | diff --git a/docs/security-spec/REVIEW-POST-1232-2026-07-30.md b/docs/security-spec/REVIEW-POST-1232-2026-07-30.md new file mode 100644 index 000000000..b594045ec --- /dev/null +++ b/docs/security-spec/REVIEW-POST-1232-2026-07-30.md @@ -0,0 +1,1429 @@ +# Security Specification Review After PR #1232 — 2026-07-30 + +> **Review artifact — non-normative.** This document explains the findings that drove the +> post-#1232 remediation. Current requirements live in the module specifications; current +> accepted/deferred exceptions live in [RISK-REGISTER.md](RISK-REGISTER.md). + +## Status + +- **Review type:** Full spec-first re-review after the block-flow refactor, with targeted implementation cross-checks +- **Repository baseline:** `84b991f64` (`staging`, merge commit for `pendulum-chain/vortex#1232`) +- **Review branch:** `codex/security-spec-review-post-1232-2026-07-30` +- **Scope:** `docs/security-spec/`, the new block-flow architecture, and implementation evidence needed to judge whether the specification itself is coherent +- **Purpose:** Standalone handoff for proof-reading by another AI agent or human reviewer + +This document supersedes the review performed on `2026-07-24` for purposes of the +post-#1232 codebase. It does not assume that old line references or old implementation +paths remain valid. + +Documentation consolidation on 2026-07-31 removed the older `AUDIT-RESULTS.md` and +`FINDINGS.md` snapshots after current exceptions moved to `RISK-REGISTER.md`. References +to those files below describe the review baseline; Git history preserves the evidence. + +## Executive Summary + +PR #1232 replaced the quote/phase pipeline with a block-flow catalog and updated many +security-spec references. The refactor fixed an important design problem: normal phase +advancement is now derived from a persisted `phaseFlow` rather than being selected +independently by every handler. However, the security specification is still not a +reliable source of truth. + +The highest-priority problems are: + +1. **The selected flow is not actually persisted or versioned.** A quote stores a + request and block metadata, then registration/start re-run the current ordered + catalog. A deployment can reinterpret an older quote. +2. **External financial side effects have no system-wide durable idempotency contract.** + The new architecture's own `INHERITED-ISSUES.md` documents duplicate mint, payout, + gas-payment, swap, and subsidy windows. +3. **The new architecture's security contract lives primarily in an implementation + README outside `docs/security-spec/`.** Several important checks are test conventions, + not startup/runtime invariants. +4. **The fee specification remains internally split between the new block system and a + deleted fee architecture.** +5. **The discount specification still explicitly permits a raw non-USD amount to be + treated as USD, immediately before requiring that this never happen.** +6. **Webhook ownership, SSRF defense, and replay semantics remain unspecified.** +7. **The documents continue to mix normative requirements, old audit results, accepted + risks, deleted code paths, and current conformance claims in the same authority + layer.** + +The new block architecture is a meaningful improvement, but it also raises new +requirements that the spec does not yet express: immutable flow identity, persisted +schema versions, exactly-one catalog resolution, phase/executor bijection, safe +compatibility projection, and upgrade/recovery compatibility. + +This review records **33 findings: 4 Critical, 21 High, and 8 Medium**. + +## Review Method + +The review used four passes: + +1. Read every file under `docs/security-spec/` as a normative corpus. +2. Read the new block architecture documents: + - `apps/api/src/api/services/phases/blocks/README.md` + - `apps/api/src/api/services/phases/blocks/TYPE-SYSTEM.md` + - `apps/api/src/api/services/phases/blocks/INHERITED-ISSUES.md` +3. Trace the production flow through: + - `blocks/core/flow.ts` + - `blocks/core/quote.ts` + - `blocks/core/register.ts` + - `blocks/flows/catalog.ts` + - `blocks/register-handlers.ts` + - `phase-processor.ts` + - `ramp.service.ts` +4. Re-test the conclusions from the 2026-07-24 review against the refactored code and + classify them as reconfirmed, evolved, or retired. + +This is not a full implementation audit. Implementation references are used where they +prove that a requirement is unsafe, contradictory, incomplete, or materially stale. + +## Severity Guide + +| Severity | Meaning | +|---|---| +| Critical | Following the spec can directly enable unauthorized access, duplicate financial operations, fund loss, or a materially false audit conclusion. | +| High | The spec permits a significant security/financial failure or cannot describe one coherent implementation. | +| Medium | The spec weakens defense in depth, creates material operational risk, or is misleading enough to cause incorrect engineering/audit work. | +| Low | Primarily clarity or maintainability risk with limited direct security impact. | + +## Finding Summary + +| ID | Severity | Summary | +|---|---|---| +| POST-001 | High | “The same persisted flow” is re-resolved from an unversioned request | +| POST-002 | High | Handler short-circuit transitions bypass the persisted phase sequence | +| POST-003 | Medium | Catalog resolution silently accepts the first matching flow | +| POST-004 | High | Phase/executor bijection is a test convention, not a construction invariant | +| POST-005 | High | Persisted block metadata/state are unversioned and shallowly cast | +| POST-006 | Medium | Generic compatibility flattening defeats namespaced ownership | +| POST-007 | Critical | External financial side effects lack a durable idempotency protocol | +| POST-008 | High | Cancellation is claimed complete while active waits ignore abort | +| POST-009 | High | Active risks are split into an unindexed implementation-side document | +| POST-010 | Critical | `fee-integrity.md` simultaneously describes current and deleted fee systems | +| POST-011 | Critical | Discount fallback repeats the documented cross-currency over-subsidy bug | +| POST-012 | High | `maxSubsidy = 0` means uncapped | +| POST-013 | High | Subsidy limits do not bound aggregate exposure | +| POST-014 | High | A 90% balance delta is called actual bridge delivery | +| POST-015 | High | Cross-chain finality requirements conflict with accepted heuristics | +| POST-016 | High | Fee-ordering invariants contradict the cataloged flows | +| POST-017 | High | Required transaction guards are allowed to fail open | +| POST-018 | Medium | “Guaranteed minimum” is incompatible with indefinite operator intervention | +| POST-019 | Medium | Anonymous quote claiming is both allowed and rejected | +| POST-020 | Medium | Ephemeral freshness is inconsistent and availability-hostile | +| POST-021 | High | Plaintext localStorage requirements are impossible and over-retentive | +| POST-022 | Critical | Webhook ownership, SSRF, and replay boundaries are unspecified | +| POST-023 | High | Authentication downgrade and outage semantics are unsafe/inconsistent | +| POST-024 | High | Supabase verification is incorrectly tied to the service-role key | +| POST-025 | High | Global authentication rules contradict anonymous quote creation | +| POST-026 | High | API-key authentication canonizes an O(n) bcrypt denial-of-service path | +| POST-027 | Medium | Admin authentication has no individual principal | +| POST-028 | High | Invitation token retention, visibility, revocation, and discount bounds conflict | +| POST-029 | High | Recipient transfer authorization remains explicitly undefined | +| POST-030 | Medium | TokenRelayer ownership terminology is stale | +| POST-031 | High | TokenRelayer amount-consumption guarantees are overstated | +| POST-032 | High | Checklist status and historical material cannot support source-of-truth use | +| POST-033 | Medium | The block architecture has no indexed security-spec module | + +## Findings + +### POST-001 — “The same persisted flow” is re-resolved from an unversioned request + +- **Severity:** High +- **Type:** False persistence/upgrade guarantee + +Quote-lifecycle invariant 18 says quote and ramp preparation must resolve “the same +persisted flow”: + +- `docs/security-spec/03-ramp-engine/quote-lifecycle.md:78` + +The flow is not persisted. Quote creation stores `{ globals, blocks }`, including +`globals.request`, but no immutable flow ID, flow version, topology hash, or block-schema +version: + +- `apps/api/src/api/services/phases/blocks/core/quote.ts:128-159` +- `apps/api/src/api/services/phases/blocks/core/metadata.ts:23-37` + +Registration, preparation, and start call the **current** `resolveBlockFlow(request)`: + +- `apps/api/src/api/services/phases/blocks/core/register.ts:16-35` +- `apps/api/src/api/services/ramp/ramp.service.ts:882-902` +- `apps/api/src/api/services/ramp/ramp.service.ts:1019-1036` + +The catalog uses current code and current ordering. Changing a predicate, reordering +definitions, changing a flow factory, or changing block metadata semantics can cause an +older quote to be prepared or started under a different program than the one that +simulated it. `flowVariant` distinguishes deployment/business variants (`monerium` versus +`mykobo`); it is not a software-flow version. + +**Recommended normative replacement** + +Persist and bind: + +1. stable flow ID; +2. immutable flow/schema version; +3. ordered phase/topology hash; +4. per-block metadata schema versions; and +5. the catalog version that selected the flow. + +Registration/start must dispatch by the persisted ID/version, verify that the stored +request and metadata match that version, and reject or explicitly migrate unsupported +versions. Recovery support windows and deployment rollback behavior must be specified. + +### POST-002 — Handler short-circuit transitions bypass the persisted phase sequence + +- **Severity:** High +- **Type:** Incomplete state-machine boundary + +The state-machine spec still says a handler returns the next phase and is responsible for +its validity: + +- `docs/security-spec/03-ramp-engine/state-machine.md:27` +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:198` + +PR #1232 improved the normal path: when a handler leaves `currentPhase` unchanged, the +processor advances through `state.phaseFlow`. But if a handler returns any different +phase, the processor accepts it before consulting the flow: + +- `apps/api/src/api/services/phases/phase-processor.ts:180-211` + +The spec does not define which short-circuit edges are permitted, nor require the +processor to validate an override against a flow-specific transition graph. A handler +bug can therefore jump to any `RampPhase`, including a phase outside the selected flow. + +There is also no invariant requiring `phaseFlow` phase names to be unique. Advancement +uses `phaseFlow.indexOf(originalPhase)`, so a repeated phase name always resolves from its +first occurrence. + +**Recommended normative replacement** + +- Persist a versioned transition graph or ordered positions, not only phase names. +- Require normal and exceptional transitions to be enumerated per flow version. +- Reject handler-returned overrides that are not explicit edges. +- Either prohibit duplicate phase names at flow construction or persist a phase-instance + identifier/cursor so repeated blocks are unambiguous. + +### POST-003 — Catalog resolution silently accepts the first matching flow + +- **Severity:** Medium +- **Type:** Ambiguous routing boundary + +`resolveBlockFlow` calls `flowDefinitions.find(...)`: + +- `apps/api/src/api/services/phases/blocks/flows/catalog.ts:317-325` + +If two predicates overlap, definition order silently selects one. The spec calls the +catalog authoritative but never requires exactly one match or complete validation of all +route-defining fields: + +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:12-14` + +This makes ordering a hidden financial/security rule. A future corridor can shadow an +existing one without a startup failure. + +**Recommended normative replacement** + +Catalog resolution must evaluate all definitions, require exactly one match, and reject +zero or multiple matches. Tests should enumerate the supported request matrix, but +runtime resolution must still fail closed on ambiguity. + +### POST-004 — Phase/executor bijection is a test convention, not a construction invariant + +- **Severity:** High +- **Type:** Test-only safety property + +The block README says each execution phase has exactly one executor and describes tests +that compare executor names with phase names: + +- `apps/api/src/api/services/phases/blocks/README.md:257-272` +- `apps/api/src/api/services/phases/blocks/README.md:442-471` + +`FlowBuilder.build()` only rejects duplicate context keys. It separately flattens phase +names and executors without checking cardinality, order, or name equality: + +- `apps/api/src/api/services/phases/blocks/core/flow.ts:57-69` + +Handler registration rejects only different executor **classes** sharing a phase name: + +- `apps/api/src/api/services/phases/blocks/register-handlers.ts:7-22` + +A newly added flow can therefore construct successfully with a missing, extra, or +misordered executor. Tests are valuable, but a missing handler can strand funds after an +earlier phase has moved them. + +**Recommended normative replacement** + +Flow construction/startup must assert: + +1. `flow.phases.length === flow.executors.length`; +2. every executor name equals its corresponding phase name; +3. every nonterminal phase in every enabled/recovery-supported flow is registered; +4. no phase has incompatible executor configuration across flows; and +5. the registry itself rejects duplicate registration rather than overwriting. + +### POST-005 — Persisted block metadata/state are unversioned and shallowly cast + +- **Severity:** High +- **Type:** Missing persisted-data contract + +Simulation is compile-time typed, but persisted data crosses JSONB and recovery +boundaries. The runtime accessors only check that a few top-level objects exist, then cast +block metadata and state: + +- `apps/api/src/api/services/phases/blocks/core/metadata.ts:39-63` +- `apps/api/src/api/services/phases/blocks/core/flow.ts:90-107` +- `apps/api/src/api/services/phases/blocks/core/flow.ts:180-203` + +The architecture README explicitly says preparation and execution are type-erased and +verified by tests: + +- `apps/api/src/api/services/phases/blocks/README.md:473-490` + +Compile-time adjacency does not validate old JSONB after code changes, manual recovery, +partial writes, or schema evolution. A cast is not a security check before constructing +or broadcasting transactions. + +**Recommended normative replacement** + +Every persisted `globals`, block-metadata, registration-facts, block-state, and +transaction-plan shape needs a versioned runtime schema. Validate at quote load, +registration, start, and recovery before side effects. Migration must be explicit and +must not silently reinterpret missing fields. + +### POST-006 — Generic compatibility flattening defeats namespaced ownership + +- **Severity:** Medium +- **Type:** Ownership/collision ambiguity + +The block design claims phase-owned, namespaced metadata and state. Ramp preparation then +flattens all registration facts and all block state with generic `Object.assign`, where +later properties silently overwrite earlier ones: + +- `apps/api/src/api/services/ramp/ramp.service.ts:903-917` +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:14` + +This collision surface is outside the duplicate context-key check. The implementation +README also admits compatibility cross-phase reads: + +- `apps/api/src/api/services/phases/blocks/README.md:571-574` + +The spec neither enumerates allowed compatibility fields nor defines collision +semantics. + +**Recommended normative replacement** + +Eliminate generic flattening. If legacy fields are temporarily required, project them +through an explicit, typed, versioned mapping that: + +- names the owning block; +- rejects duplicate destinations; +- validates equality where two sources intentionally project the same fact; and +- has a removal/versioning plan. + +Executors should read namespaced state wherever possible. + +### POST-007 — External financial side effects lack a durable idempotency protocol + +- **Severity:** Critical +- **Type:** Missing financial-operation invariant + +The state-machine spec requires handlers to be idempotent or guarded: + +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:204` + +It does not define a durable protocol for external side effects. The block +implementation's own active-risk document identifies: + +- Avenia mint and PIX payout IDs persisted after provider calls; +- Nabla and Squid broadcast-to-persist windows; +- repeated Axelar gas payments; +- subsidy and final-settlement sends before durable claims; +- failed fixed-nonce payout rebroadcasts. + +See: + +- `apps/api/src/api/services/phases/blocks/INHERITED-ISSUES.md:8-38` + +Running provider calls inside a database transaction does not make the provider and +PostgreSQL atomic. A crash after provider acceptance and before commit can duplicate a +mint or payout. Balance and nonce heuristics are not a general idempotency protocol. + +**Recommended normative replacement** + +Every external financial operation must have: + +1. a stable operation ID derived from ramp ID, flow version, phase instance, and attempt + class; +2. a durable local intent/claim written before the call; +3. an upstream idempotency key when supported; +4. broadcast/provider identifiers persisted as soon as they are known; +5. reconciliation for ambiguous timeouts before any retry; +6. a state model distinguishing `not_started`, `submitted`, `confirmed`, `failed`, and + `unknown`; +7. unique constraints preventing duplicate claims; and +8. explicit recovery behavior for providers without idempotency support. + +“Retryable” must not mean “safe to repeat.” + +### POST-008 — Cancellation is claimed complete while active waits ignore abort + +- **Severity:** High +- **Type:** False conformance claim + +The state-machine spec says the processor aborts timed-out executions and shared polling +helpers stop, presenting the prior overlap issue as fixed: + +- `docs/security-spec/03-ramp-engine/state-machine.md:33` +- `docs/security-spec/03-ramp-engine/state-machine.md:46` +- `docs/security-spec/03-ramp-engine/state-machine.md:58` + +The current architecture's active-risk document says representative Avenia, Mykobo, +XCM, destination, and final-settlement waits do not consistently accept the abort signal: + +- `apps/api/src/api/services/phases/blocks/INHERITED-ISSUES.md:86-92` + +The two statements cannot both be used as an audit conclusion. An abandoned execution +can overlap its retry and amplify POST-007. + +**Recommended normative replacement** + +Require the abort signal to propagate through every wait, sleep, provider call, RPC +receipt wait, and retry loop. Specify that after timeout no abandoned attempt may perform +a new side effect. Track incomplete coverage as an open conformance defect, not a fixed +invariant. + +### POST-009 — Active risks are split into an unindexed implementation-side document + +- **Severity:** High +- **Type:** Security-source fragmentation + +`INHERITED-ISSUES.md` contains active, release-relevant financial and completion risks, +but it lives under the implementation tree and is not indexed by +`docs/security-spec/README.md`. Some security modules link to it, others still claim the +corresponding property passes. + +Examples include the cancellation conflict in POST-008, the destination-validation +limitations, and cross-chain completion heuristics: + +- `apps/api/src/api/services/phases/blocks/INHERITED-ISSUES.md:40-92` +- `docs/security-spec/README.md:17-54` + +An auditor following the stated security-spec workflow can miss material active risks. + +**Recommended normative replacement** + +Move active accepted risks and conformance gaps into one versioned security-risk +register linked from the relevant normative invariant. Each entry should state owner, +status, affected flow versions, acceptance authority, expiry/review date, and compensating +controls. Implementation READMEs may explain design but must not be the only record. + +### POST-010 — `fee-integrity.md` simultaneously describes current and deleted fee systems + +- **Severity:** Critical +- **Type:** Stale source-of-truth module + +The file starts by saying block flows and `globals.fees` are the sole quote pipeline: + +- `docs/security-spec/03-ramp-engine/fee-integrity.md:7-9` + +It then says two disagreeing fee systems exist and names deleted +`calculateTotalReceiveOnramp()` / `calculateTotalReceive()` paths: + +- `docs/security-spec/03-ramp-engine/fee-integrity.md:11-19` +- `docs/security-spec/03-ramp-engine/fee-integrity.md:71-85` + +It simultaneously requires the immutable block fee snapshot to drive both display and +deduction: + +- `docs/security-spec/03-ramp-engine/fee-integrity.md:44-57` + +The README index still labels the module “Fee calculation, dual-system discrepancy”: + +- `docs/security-spec/README.md:29` + +The rounding statements are also incorrect: Big.js rounding mode `0` is round-down and +mode `1` is round-half-up, not the half-up/half-down descriptions in the file. + +**Recommended normative replacement** + +Rewrite the file instead of appending another “current” paragraph. Define per flow: + +- canonical configuration source; +- fee currency; +- immutable quote snapshot; +- exact deduction/distribution point; +- provider-charged components; +- raw/decimal rounding direction; +- reconciliation equality; and +- behavior for old quote/flow versions. + +Move the deleted dual-system history to an archive. + +### POST-011 — Discount fallback repeats the documented cross-currency over-subsidy bug + +- **Severity:** Critical +- **Type:** Direct contradiction and unsafe requirement + +The discount overview says a fiat-peg rate-feed failure may use bridged USDC, then raw +input as a last resort: + +- `docs/security-spec/03-ramp-engine/discount-mechanism.md:14` + +Invariant 13 says off-ramp input must always be USD-denominated and documents the +approximately 5× BRLA over-subsidy caused by treating BRLA units as USD: + +- `docs/security-spec/03-ramp-engine/discount-mechanism.md:42` + +Current code implements the unsafe last resort: + +- `apps/api/src/api/services/phases/blocks/core/discount.ts:92-133` + +A monetary amount cannot cross currency units as an availability fallback. + +**Recommended normative replacement** + +For every non-USD-like input, require a valid, fresh, bounded USD valuation or an +independently derived USD-denominated route amount. If neither exists, fail the quote. +Raw BRLA, EURC, ETH, or other input units must never be labeled USD. + +### POST-012 — `maxSubsidy = 0` means uncapped + +- **Severity:** High +- **Type:** Dangerous configuration semantics + +The spec calls `maxSubsidy` a fractional cap but applies it only when greater than zero: + +- `docs/security-spec/03-ramp-engine/discount-mechanism.md:12` +- `docs/security-spec/03-ramp-engine/discount-mechanism.md:30` + +It explicitly says non-positive values are interpreted as uncapped: + +- `docs/security-spec/03-ramp-engine/discount-mechanism.md:50` + +The implementation returns the full shortfall for zero: + +- `apps/api/src/api/services/phases/blocks/core/discount.ts:223-237` + +For a field named “maximum subsidy,” zero naturally means no subsidy. Making it unlimited +is a high-risk configuration trap. + +**Recommended normative replacement** + +- `0` means disabled. +- `(0, 1]` means a fractional cap. +- Uncapped behavior, if allowed at all, needs an explicit enum/nullable state, privileged + approval, and a separate aggregate circuit breaker. + +### POST-013 — Subsidy limits do not bound aggregate exposure + +- **Severity:** High +- **Type:** Incomplete financial invariant + +The system permits independently capped quote discount, swap discrepancy, pre-swap, +post-swap, and final-settlement subsidies. Some EVM components have a $1 floor: + +- `docs/security-spec/03-ramp-engine/discount-mechanism.md:38` +- `docs/security-spec/06-cross-chain/fund-routing.md:17` +- `docs/security-spec/06-cross-chain/fund-routing.md:41-49` + +The phase-flow threat model acknowledges no aggregate cross-ramp cap: + +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:217-218` +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:245` + +There is no normative maximum for total platform outflow per ramp, principal, partner, +corridor, funding account, or time window. Concurrent ramps can each observe the same +available pool. + +**Recommended normative replacement** + +Define atomically reserved limits at component, ramp, principal/API-key, partner, +corridor, funding-account, and global rolling-window levels. Use one canonical USD +valuation and require a circuit breaker independent of the pooled account's balance. + +### POST-014 — A 90% balance delta is called actual bridge delivery + +- **Severity:** High +- **Type:** Unsafe settlement evidence + +Fund-routing invariant 10 says final settlement subsidizes against “actual bridge +delivery,” then permits a balance delta of 90% of expected: + +- `docs/security-spec/06-cross-chain/fund-routing.md:47` +- `docs/security-spec/06-cross-chain/fund-routing.md:63` + +The baseline prevents old dust from being mistaken for new delivery, which is an +improvement. It does not prove that the bridge is terminal. The remaining output may +arrive later, overfunding the ephemeral after the platform tops it up. + +**Recommended normative replacement** + +Require authoritative terminal bridge status plus finalized destination receipt. If the +provider cannot supply that evidence, define an explicit settlement delay, accepted +maximum exposure, late-arrival reconciliation, and recovery destination. Do not call a +percentage threshold “actual delivery.” + +### POST-015 — Cross-chain finality requirements conflict with accepted heuristics + +- **Severity:** High +- **Type:** Direct cross-module contradiction + +The architecture requires verification on both source and destination: + +- `docs/security-spec/00-system-overview/architecture.md:69` + +Ramp flows require source finalization before destination advancement: + +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:202` + +The XCM spec accepts: + +- any positive Pendulum balance for Moonbeam→Pendulum; +- source balance depletion as evidence of Pendulum→Moonbeam submission; and +- a persisted hash without source success or AssetHub arrival for Pendulum→AssetHub. + +See: + +- `docs/security-spec/06-cross-chain/xcm-transfers.md:24-31` +- `docs/security-spec/06-cross-chain/xcm-transfers.md:38-41` + +These may be known implementation gaps, but they cannot simultaneously satisfy the +global MUST requirements. + +**Recommended normative replacement** + +Define evidence per transfer leg: source inclusion/finality, expected call/amount/sender, +destination asset/beneficiary/amount, confirmation depth, timeout, and reorg recovery. +Mark heuristic-only legs as explicit temporary exceptions with bounded exposure and +acceptance metadata. + +### POST-016 — Fee-ordering invariants contradict the cataloged flows + +- **Severity:** High +- **Type:** Direct contradiction + +The ramp-flow invariant says fee distribution must occur after all user-facing phases: + +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:203` + +The same file and fee module document Base off-ramp distribution before Nabla and Base +on-ramp distribution after Nabla: + +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:18-42` +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:248-249` +- `docs/security-spec/03-ramp-engine/fee-integrity.md:39-42` + +The corridor-specific order can be correct because fees are denominated in USDC. The +global invariant is not. + +**Recommended normative replacement** + +Specify per-flow fee order and require it to match the fee currency and refund/recovery +model. Define what happens if the ramp fails after distribution but before delivery. + +### POST-017 — Required transaction guards are allowed to fail open + +- **Severity:** High +- **Type:** Unsafe exception language + +The phase-flow spec requires destination nonce-gap detection but permits RPC or parse +failure to warn and proceed: + +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:207` + +It requires payout funding validation but permits unparseable or non-transfer payloads +to fall through to broadcast: + +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:209` +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:242` + +These exceptions negate the security checks exactly when the system cannot establish +what it is about to broadcast. + +**Recommended normative replacement** + +- Parse failure of a server-generated transaction is unrecoverable corruption. +- RPC inability to perform required preflight is a recoverable service failure. +- Neither case may fall through to broadcast. +- Only checks explicitly classified as observability-only may be best effort. + +### POST-018 — “Guaranteed minimum” is incompatible with indefinite operator intervention + +- **Severity:** Medium +- **Type:** Misleading financial promise + +Quote-lifecycle invariant 4 calls output a guaranteed minimum but says cap breaches may +leave the ramp waiting for operator intervention: + +- `docs/security-spec/03-ramp-engine/quote-lifecycle.md:64` + +That may preserve safety by refusing under-delivery, but it is not a delivery guarantee. +The spec does not define an intervention SLA, refund/cancellation path, or terminal +outcome. + +**Recommended normative replacement** + +Separate quoted target, hard minimum, maximum platform subsidy, pause conditions, +operator SLA, cancellation/refund rights, and reconciliation outcome. Reserve +“guaranteed” for a property covered by every terminal path. + +### POST-019 — Anonymous quote claiming is both allowed and rejected + +- **Severity:** Medium +- **Type:** Direct document contradiction + +Invariant 17 permits an authenticated user to claim an anonymous quote: + +- `docs/security-spec/03-ramp-engine/quote-lifecycle.md:77` + +The checklist says the service rejects that action: + +- `docs/security-spec/03-ramp-engine/quote-lifecycle.md:121` + +Current code allows it: + +- `apps/api/src/api/services/ramp/ramp.service.ts:187-203` + +**Recommended normative replacement** + +Keep one rule. If claiming remains allowed, state that quote IDs are short-lived bearer +references, define atomic claimant binding/consumption, and explain what public quote +data is exposed. Correct the checklist rather than treating both outcomes as PASS. + +### POST-020 — Ephemeral freshness is inconsistent and availability-hostile + +- **Severity:** Medium +- **Type:** Contradictory and overbroad requirement + +The signing-key spec requires zero nonce/zero balance/nonexistence on every supported +same-type chain and lists Hydration: + +- `docs/security-spec/02-signing-keys/ephemeral-accounts.md:29` +- `docs/security-spec/02-signing-keys/ephemeral-accounts.md:47` +- `docs/security-spec/02-signing-keys/ephemeral-accounts.md:63` + +Current code intentionally excludes disabled Hydration routes and checks EVM nonce but +not EVM balance: + +- `apps/api/src/api/services/ramp/ephemeral-freshness.ts:13-29` +- `apps/api/src/api/services/ramp/ephemeral-freshness.ts:80-97` + +The transaction-validation spec has the same zero-balance versus EVM-nonce-only +inconsistency: + +- `docs/security-spec/03-ramp-engine/transaction-validation.md:52` +- `docs/security-spec/03-ramp-engine/transaction-validation.md:68` + +Checking every unrelated RPC also makes all registrations depend on the health of chains +the selected flow cannot use. + +**Recommended normative replacement** + +Define freshness per chain/signing model. Derive the required set from the persisted flow +version, including fallback/recovery legs. Fail closed on those chains and prohibit flow +activation until its checker is registered. Do not make disabled/unrelated RPCs universal +dependencies. + +### POST-021 — Plaintext localStorage requirements are impossible and over-retentive + +- **Severity:** High +- **Type:** Impossible guarantee and unsafe lifecycle + +The spec requires plaintext localStorage keys to be inaccessible to third-party scripts: + +- `docs/security-spec/02-signing-keys/ephemeral-accounts.md:30` + +Any same-origin JavaScript can read localStorage. The same module requires retaining +entries across completion, failure, replacement ramps, and logout: + +- `docs/security-spec/02-signing-keys/ephemeral-accounts.md:32` + +Its threat table admits indefinite accumulation and XSS exposure: + +- `docs/security-spec/02-signing-keys/ephemeral-accounts.md:48` + +**Recommended normative replacement** + +State the same-origin/XSS dependency honestly. Define a bounded recovery window, +terminal-state cleanup, shared-device logout policy, explicit export/recovery workflow, +and CSP/supply-chain requirements. SDK filesystem storage should default off or use +restrictive permissions and a deletion policy. + +### POST-022 — Webhook ownership, SSRF, and replay boundaries are unspecified + +- **Severity:** Critical +- **Type:** Missing trust boundary + +The security spec documents only signing-key mechanics: + +- `docs/security-spec/02-signing-keys/server-side-signing.md:18-20` +- `docs/security-spec/00-system-overview/architecture.md:58` + +It does not define webhook owner principals, quote/session authorization, delete scope, +destination restrictions, redirects/DNS resolution, signed-message framing, event IDs, +or replay windows. + +Current implementation evidence: + +- registration checks quote existence, not authenticated ownership: + `apps/api/src/api/services/webhook/webhook.service.ts:57-76`; +- webhook rows have no owner field: + `apps/api/src/models/webhook.model.ts:5-33`; +- deletion is by UUID without owner predicate: + `apps/api/src/api/services/webhook/webhook.service.ts:104-121`; +- any syntactically HTTPS URL is accepted: + `apps/api/src/api/services/webhook/webhook.service.ts:14-27`; +- the timestamp header is not included in signed bytes: + `apps/api/src/api/services/webhook/webhook-delivery.service.ts:23-42`. + +An unrelated authenticated API client can attempt to subscribe to another quote, the +delivery mechanism is a blind server-side request surface, and a valid signed body can be +replayed with a replaced current timestamp. + +**Recommended normative replacement** + +Require owner-scoped rows and CRUD; resource ownership at registration; HTTPS canonical +parsing; public-address DNS validation per attempt; redirect disablement or per-hop +revalidation; a signed envelope such as +`version.timestamp.eventId.bodyHash`; bounded timestamp checks; event-ID deduplication; +and stable event IDs across retries. + +### POST-023 — Authentication downgrade and outage semantics are unsafe/inconsistent + +- **Severity:** High +- **Type:** Unsafe semantics and internal contradiction + +The Supabase spec requires `optionalAuth` to ignore a present invalid credential: + +- `docs/security-spec/01-auth/supabase-otp.md:24` + +It also requires all access-token verification failures, including provider/network +failures, to return `401`: + +- `docs/security-spec/01-auth/supabase-otp.md:25` +- `docs/security-spec/01-auth/supabase-otp.md:37` + +Refresh-token handling correctly reserves `503` for indeterminate upstream failures: + +- `docs/security-spec/01-auth/supabase-otp.md:13` +- `docs/security-spec/01-auth/supabase-otp.md:29` + +Silently converting an attempted authenticated request into an anonymous one can create +ownership different from the caller's understanding. Treating a provider outage as +invalid credentials causes unnecessary logout/recovery behavior. + +**Recommended normative replacement** + +- No credential on anonymous-eligible route: continue anonymously. +- Present invalid/expired/revoked credential: `401`. +- Valid identity lacking authority: `403`. +- Indeterminate verification/provider outage: `503`, no anonymous fallback. +- Log request IDs and error categories, not bearer-token fragments. + +### POST-024 — Supabase verification is incorrectly tied to the service-role key + +- **Severity:** High +- **Type:** Incorrect security property + +The spec says JWT verification must use `SUPABASE_SERVICE_KEY` and treats anon-key +verification as inherently insufficient: + +- `docs/security-spec/01-auth/supabase-otp.md:21` +- `docs/security-spec/01-auth/supabase-otp.md:46` + +Token authenticity depends on trusted issuer/project, signature or authoritative +introspection, audience, algorithm, time claims, subject, and revocation semantics—not on +giving the verifier broad service-role database privileges. + +**Recommended normative replacement** + +Allow authoritative Supabase Auth validation over a server-controlled channel or local +verification against pinned project JWKS/issuer/audience. Require the service-role key +only for operations that need service-role privileges. + +### POST-025 — Global authentication rules contradict anonymous quote creation + +- **Severity:** High +- **Type:** Cross-module contradiction + +The architecture says all client-facing endpoints enforce authentication and no quote +mutation is unauthenticated: + +- `docs/security-spec/00-system-overview/architecture.md:62` + +Quote lifecycle and API-key specs intentionally allow anonymous quote creation: + +- `docs/security-spec/03-ramp-engine/quote-lifecycle.md:75-77` +- `docs/security-spec/01-auth/api-keys.md:47` + +The current quote routes also declare themselves public and use optional authentication: + +- `apps/api/src/api/routes/v1/quote.route.ts:11-55` +- `apps/api/src/api/routes/v1/quote.route.ts:57-120` + +The architecture also says trust boundaries are enforced only in middleware: + +- `docs/security-spec/00-system-overview/architecture.md:63` + +Resource authorization often depends on loaded ownership state and needs service-layer +defense in depth. + +**Recommended normative replacement** + +Add an endpoint/principal matrix covering anonymous eligibility, accepted credentials, +effective principal, ownership result, claim rules, and service-layer authorization. +Distinguish boundary authentication from resource authorization. + +### POST-026 — API-key authentication canonizes an O(n) bcrypt denial-of-service path + +- **Severity:** High +- **Type:** Unsafe architecture requirement + +Every secret key shares the stored first-eight-character prefix, so authentication +bcrypt-compares all active keys in the environment: + +- `docs/security-spec/01-auth/api-keys.md:36` +- `docs/security-spec/01-auth/api-keys.md:41` + +The spec treats a per-user cap of ten as the mitigation: + +- `docs/security-spec/01-auth/api-keys.md:51` +- `docs/security-spec/01-auth/api-keys.md:64` + +That does not bound total users, admin-created partner keys, work per invalid request, or +aggregate attacker traffic. + +**Recommended normative replacement** + +Embed a random non-secret key ID, look up one record, and compare a keyed digest or +cryptographic hash in constant time. Uniform high-entropy API secrets do not need +password-style bcrypt work. Rate limiting remains defense in depth. Regex validation +does not “prevent injection”; parameterized queries do. + +### POST-027 — Admin authentication has no individual principal + +- **Severity:** Medium +- **Type:** Incomplete privileged-access model + +The admin spec intentionally describes one shared bearer secret and requires that no +identity be attached: + +- `docs/security-spec/01-auth/admin-auth.md:5-23` + +That makes current behavior explicit, but it cannot provide per-operator attribution, +selective revocation, MFA, role separation, approval workflows, or non-repudiation. +Admin-controlled pricing, roles, partner credentials, and assignments can materially +affect authorization and platform-funded transfers. + +**Recommended normative replacement** + +Require individual admin identities, MFA, least-privilege roles, immutable audit events, +and selective revocation. Treat `ADMIN_SECRET` only as a high-blast-radius, +break-glass/bootstrap credential if it must remain. + +### POST-028 — Invitation token retention, visibility, revocation, and discount bounds conflict + +- **Severity:** High +- **Type:** Contradictory bearer-token lifecycle + +Recipient-transfer invariant 1 retains raw bearer tokens and says expired rows remain +visible after lazy clearing: + +- `docs/security-spec/03-ramp-engine/recipient-transfers.md:28-39` + +Invariant 3 says sender listing excludes expired rows: + +- `docs/security-spec/03-ramp-engine/recipient-transfers.md:43-51` + +Expiry cleanup occurs only on acceptance/listing, so an untouched expired row can retain +its raw token. Archiving is explicitly not revocation, and no endpoint writes revoked +status: + +- `docs/security-spec/03-ramp-engine/recipient-transfers.md:19` +- `docs/security-spec/03-ramp-engine/recipient-transfers.md:52-61` +- `docs/security-spec/03-ramp-engine/recipient-transfers.md:246-254` + +Discount bounds also conflict: invariant 11 says `0..300` bps, while the threat section +says `0..1000`: + +- `docs/security-spec/03-ramp-engine/recipient-transfers.md:112-127` +- `docs/security-spec/03-ramp-engine/recipient-transfers.md:207-212` + +**Recommended normative replacement** + +Store only a hash and issue a replacement token for re-copy, or encrypt retained raw +tokens under a dedicated key. Use a scheduled expiry sweep independent of reads. Add +sender-scoped revocation that immediately deletes/decryptably destroys the bearer +secret. Choose one listing rule and one discount bound. + +### POST-029 — Recipient transfer authorization remains explicitly undefined + +- **Severity:** High +- **Type:** Draft feature inside an authoritative module + +The module labels ramp registration versus the recipient model “PRESSING, TO BE +DEFINED” and says eligibility is only UX: + +- `docs/security-spec/03-ramp-engine/recipient-transfers.md:155-186` + +It does not yet define a registration-time security boundary binding sender principal, +relationship, recipient entity, provider identity, payout instrument, corridor, and +settlement. + +**Recommended normative replacement** + +Mark recipient payout as non-authoritative/unsupported until the registration contract +defines and atomically verifies all of those elements, plus post-registration +immutability and block/revocation behavior for pending ramps. + +### POST-030 — TokenRelayer ownership terminology is stale + +- **Severity:** Medium +- **Type:** Incorrect invariant + +The spec says withdrawals are deployer-only: + +- `docs/security-spec/04-smart-contracts/token-relayer.md:16` +- `docs/security-spec/04-smart-contracts/token-relayer.md:33` + +The contract uses transferable OpenZeppelin `Ownable` and authorizes the current owner: + +- `contracts/relayer/contracts/TokenRelayer.sol:25` +- `contracts/relayer/contracts/TokenRelayer.sol:65-71` +- `contracts/relayer/contracts/TokenRelayer.sol:191-211` + +**Recommended normative replacement** + +Say “current owner,” define whether ownership transfer/renunciation is allowed, require +the expected owner type (preferably multisig/timelock), and specify monitoring and +emergency withdrawal policy. + +### POST-031 — TokenRelayer amount-consumption guarantees are overstated + +- **Severity:** High +- **Type:** False token-semantics guarantee + +The spec says `transferFrom` pulls exactly the signed value and the same value is +available to the forwarded call: + +- `docs/security-spec/04-smart-contracts/token-relayer.md:35` + +That is not generally true for fee-on-transfer, rebasing, callback-enabled, or other +non-standard tokens. Exact approval also does not prove arbitrary destination calldata +consumes that amount for the intended action. + +**Recommended normative replacement** + +Either maintain an explicit supported-token allowlist with required transfer semantics, +or measure balance deltas. Define permitted destination methods/calldata and how the +signed token amount binds to them, or explicitly state that the user authorizes arbitrary +calldata to the immutable destination. + +### POST-032 — Checklist status and historical material cannot support source-of-truth use + +- **Severity:** High +- **Type:** Audit-process defect + +The README says every unchecked box is a gap and every spec is the intended source of +truth: + +- `docs/security-spec/README.md:3-14` + +In practice: + +- checked boxes contain `FAIL` or `PARTIAL`; +- `[FAIL]` entries coexist with later checked “fixed” entries in the same file; +- removed handler names remain checked; +- stale global audit documents report old code as current; and +- normative MUSTs, implementation descriptions, accepted risks, and historical findings + are interleaved. + +Examples at the review baseline: + +- `docs/security-spec/01-auth/supabase-otp.md:46-55` +- `docs/security-spec/06-cross-chain/fund-routing.md:68-87` +- `docs/security-spec/03-ramp-engine/ramp-phase-flows.md:237-244` +- the since-removed `AUDIT-RESULTS.md` baseline, lines 1–11 +- the since-removed `FINDINGS.md` baseline, lines 1–17 + +`ramp-phase-flows.md` still checks deleted `spacewalk-redeem-handler` and +`post-swap-handler` behavior, while `fund-routing.md` refers to old +`final-settlement-subsidy.ts` and contradicts later fixed entries. + +**Recommended normative replacement** + +Split the corpus: + +1. `spec/` — current normative requirements only; +2. `risk-register/` — current accepted/open risks; +3. `conformance/` — dated results tied to an exact commit; +4. `history/` — closed findings and deleted architectures. + +Use one machine-readable status enum (`open`, `pass`, `partial`, `accepted`, `not_applicable`) +instead of combining checkboxes and prose. Add CI for links/paths, duplicate IDs, +contradictory statuses, required metadata, and review expiry. + +### POST-033 — The block architecture has no indexed security-spec module + +- **Severity:** Medium +- **Type:** Missing normative architecture module + +The security README says new features should have a corresponding spec before +implementation: + +- `docs/security-spec/README.md:7-15` + +The production source of truth is now the block model, but its complete design contract +lives in: + +- `apps/api/src/api/services/phases/blocks/README.md` +- `apps/api/src/api/services/phases/blocks/TYPE-SYSTEM.md` + +There is no indexed security module defining the trust boundaries of flow selection, +compile-time versus runtime guarantees, persisted metadata, lifecycle hooks, nonce +lanes, executor registration, compatibility projection, or upgrades. + +**Recommended normative replacement** + +Add `docs/security-spec/03-ramp-engine/block-flow-architecture.md` and index it. It should +normatively cover POST-001 through POST-006 and explicitly distinguish: + +- compile-time adjacency guarantees; +- startup construction/registry checks; +- runtime validation; +- persisted-data/version guarantees; and +- test-only evidence. + +## Prior Review Disposition + +| 2026-07-24 finding | Post-#1232 disposition | +|---|---| +| SPEC-001 Webhooks | Reconfirmed as POST-022 | +| SPEC-002 Raw input as USD | Reconfirmed in the new block code as POST-011 | +| SPEC-003 Obsolete fee architecture | Reconfirmed as POST-010; only the introduction was updated | +| SPEC-004 Optional-auth downgrade | Reconfirmed and combined into POST-023 | +| SPEC-005 Supabase service-role verification | Reconfirmed as POST-024 | +| SPEC-006 Auth outage semantics | Reconfirmed and combined into POST-023 | +| SPEC-007 Global auth vs public quote | Reconfirmed as POST-025 | +| SPEC-008 Aggregate subsidy limits | Reconfirmed as POST-013 | +| SPEC-009 `maxSubsidy=0` | Reconfirmed as POST-012 | +| SPEC-010 90% arrival threshold | Reconfirmed as POST-014 | +| SPEC-011 Hydration finality conflict | Hydration flow retired; evolved into the active XCM evidence conflict in POST-015 | +| SPEC-012 Fee ordering | Reconfirmed as POST-016 | +| SPEC-013 Fail-open transaction guards | Reconfirmed as POST-017 | +| SPEC-014 localStorage retention | Reconfirmed as POST-021 | +| SPEC-015 Freshness inconsistency | Reconfirmed and updated for disabled Hydration in POST-020 | +| SPEC-016 Stellar cleanup wording | Retired as a primary finding; Stellar routes remain deprecated | +| SPEC-017 Identity-less admin | Reconfirmed as POST-027 | +| SPEC-018 O(n) bcrypt API-key scan | Reconfirmed as POST-026 | +| SPEC-019 Invitation-token lifecycle | Reconfirmed and expanded as POST-028 | +| SPEC-020 Recipient authorization undefined | Reconfirmed as POST-029 | +| SPEC-021 TokenRelayer owner/deployer | Reconfirmed as POST-030 | +| SPEC-022 TokenRelayer amount binding | Reconfirmed as POST-031 | +| SPEC-023 Guaranteed minimum | Reconfirmed as POST-018 | +| SPEC-024 Anonymous quote checklist | Reconfirmed at new line numbers as POST-019 | +| SPEC-025 Handler-owned transition validity | Partially improved by #1232; evolved into POST-002 | +| SPEC-026 Checklist status syntax | Reconfirmed and expanded as POST-032 | +| SPEC-027 Normative/audit history mixing | Reconfirmed and combined into POST-032 | + +## Recommended Remediation Order + +1. **Before relying on recovery across deployments:** implement persisted flow/schema + identity and version dispatch (POST-001, POST-005). +2. **Before expanding production volume:** define and implement durable idempotency and + complete abort propagation (POST-007, POST-008). +3. **Before treating the spec as an audit baseline:** split normative, risk, conformance, + and history documents; add the block architecture module (POST-009, POST-032, + POST-033). +4. **Before accepting subsidy configuration as a financial control:** remove raw-unit + fallback, fix zero-cap semantics, and add aggregate reservations/circuit breakers + (POST-011 through POST-014). +5. **Before enabling disabled cross-chain corridors:** require source/destination + transaction evidence and versioned recovery support (POST-014, POST-015, POST-020). +6. **Before expanding partner webhook use:** define ownership, SSRF, replay, and retry + semantics (POST-022). +7. **Then correct remaining auth, recipient, contract, and lifecycle contradictions.** + +## Suggested Proof-Reading Questions + +The next reviewer should specifically challenge: + +1. Is any existing column or metadata field intended to be a flow version even though it + is not named/documented that way? +2. Are handler short-circuits intentionally allowed to leave `phaseFlow`; if so, what is + the complete allowed-edge list? +3. Can any supported flow contain the same `RampPhase` twice? +4. Does an upstream provider support idempotency keys that were missed in this review? +5. Is there an operational single-replica or deployment-draining guarantee that changes + the severity of unversioned catalog re-resolution or process-local discount state? +6. Is `maxSubsidy=0` deliberately “unlimited,” and can an administrator distinguish that + from “disabled” without reading implementation code? +7. Are raw invite tokens removed by a scheduled job not mentioned in the spec? +8. Are webhook callback destinations restricted by infrastructure outside the repository? +9. Are any stale audit/history documents intentionally normative despite their dates? +10. Which current production corridors still need compatibility fields flattened into + `StateMetadata`, and can those fields be enumerated now? + +## Second-Pass Verification — 2026-07-30 (Claude) + +Independent proof-reading pass over all 33 findings. Every cited spec and implementation +location was re-read against the working tree at `codex/security-spec-review-post-1232-2026-07-30` +(baseline `84b991f64`), including `phase-processor.ts`, `blocks/core/*`, `blocks/flows/catalog.ts`, +`register-handlers.ts`, `ramp.service.ts:170-215/860-1039`, `ephemeral-freshness.ts`, the webhook +service/model/route/delivery code, `TokenRelayer.sol`, and the referenced spec files. + +### Overall verdict + +**All 33 findings are substantively confirmed. No false positives.** Every quoted line +citation checked out (content matched, no misattributed line numbers). Disagreements are +limited to two severity calls, one framing, and one recommendation detail, listed below. +Findings not listed are **agreed as written, including the recommended approach**. + +### Per-finding notes + +- **POST-001 — Agree, with one material nuance the finding omits.** The runtime phase + *sequence* IS persisted per ramp: `Flow.prepareTxs` writes + `stateMeta.phaseFlow = ["initial", ...phases, "complete"]` (`blocks/core/flow.ts:115`), + and the processor advances from that persisted array. So an in-flight ramp's ordering is + stable across deployments. The unversioned re-resolution exposure is therefore + (a) the quote→registration window — bounded by the ≤10-minute quote TTL — and + (b) `register`/`update`/`start`/recovery **lifecycle hooks**, which re-resolve + `resolveBlockFlow(request)` over ramp lifetimes of days (`ramp.service.ts:883`, `:1025`) + and can diverge from the persisted `phaseFlow` after a catalog change. The full 5-part + version scheme is the right end state; a cheap first increment is: persist the resolved + flow name at quote time, and have registration/start assert the re-resolved flow's name + and phase list match the persisted values, failing closed on mismatch. +- **POST-002 — Agree.** Verified: `resolveNextPhase` honors any handler-returned phase + before consulting `phaseFlow` (`phase-processor.ts:188-191`), and `indexOf` resolves + duplicates from the first occurrence (`:201`). Note that legitimate short-circuits exist + and are spec-blessed (e.g. `finalSettlementSubsidy` → `destinationTransfer` on degenerate + routes, `fund-routing.md` invariant 11), so the fix must be an enumerated allowed-edge + set per flow, not a ban on overrides. The duplicate-phase risk is theoretical today (no + cataloged flow repeats a phase, and flow tests deep-equal explicit sequences), but + asserting uniqueness in `FlowBuilder.build()` is a one-line guard worth adding. +- **POST-003 — Agree.** Current predicates appear pairwise disjoint (Base/non-Base and + token exclusions), so no live misrouting — this is a latent-hazard finding, and Medium + is right. The fix is cheap: `filter` instead of `find`, throw on length > 1 (zero-match + already fails closed with a 400). +- **POST-004 — Agree.** Verified all three gaps: `build()` checks only duplicate context + keys (`flow.ts:60-66`); `getBlockFlowHandlers` rejects only conflicting *classes* and + silently dedupes otherwise (`register-handlers.ts:16-19`); and a missing handler makes + the processor log a warning and return (`phase-processor.ts:229-232`) — a silent + mid-flow stall with funds potentially in motion. Recommended asserts 1–3 and 5 are + cheap startup checks; 4 is already partially covered by the conflicting-class rejection. +- **POST-005 — Agree.** `getFlowMetadata`/`getBlockMetadata`/`getBlockState` verified as + existence-check-then-cast. Suggest sequencing the remediation: runtime validation at the + recovery/start boundary first (where old JSONB meets new code — the POST-001 window), + then quote-load/registration. +- **POST-006 — Agree.** `Object.assign` flatten verified (`ramp.service.ts:903-907`; + `responseArtifacts` are flattened the same way at `:908`). Minimum viable fix: detect and + throw on duplicate destination keys during the flatten; the full typed projection can follow. +- **POST-007 — Agree; this is the most consequential finding in the review.** All five + windows are as `INHERITED-ISSUES.md` describes. Suggested prioritization within the + recommendation: (1) Avenia mint / PIX payout intent-before-call persistence (provider + operations with no chain-nonce protection), (2) funding-account transfers + (subsidies/final settlement — operator funds, concurrent nonce space), (3) the + broadcast-to-persist hash windows, which chain nonces at least partially guard. +- **POST-008 — Agree with a framing correction.** The two documents are not strictly + contradictory: `state-machine.md` claims the *shared polling helpers* honor the abort + signal (true), while `INHERITED-ISSUES.md` lists block waits that bypass those helpers. + The real defect is that the "FIXED (2026-07-08)" presentation and checklist line 58 + overstate coverage as complete. The recommended fix stands: mark abort propagation as + partial conformance and cross-link the gap list, as `xcm-transfers.md` already does for + its own heuristics. +- **POST-009 — Agree.** Verified: only `ramp-phase-flows.md` and `xcm-transfers.md` link + `INHERITED-ISSUES.md`; the README index does not, and `state-machine.md`/`fund-routing.md` + claim PASS on adjacent properties without the caveat. Immediate fix is an index entry + plus cross-links; the full risk-register structure can come with POST-032. +- **POST-010 — Agree, Critical confirmed.** Grep confirms `calculateTotalReceiveOnramp` / + `calculateTotalReceive` no longer exist anywhere in `apps/` or `packages/`, yet + fee-integrity.md describes them in the present tense as "ACTUALLY USED" with checked + PASS boxes (lines 74–75). The rounding correction is also right: Big.js mode `0` is + round-*down* (spec says "round half up") and mode `1` is round-*half-up* (spec says + "round half down") — wrong in both directions. +- **POST-011 — Agree with the finding; severity arguably High rather than Critical.** + The contradiction and the live code path (`discount.ts:126-134`) are confirmed, and the + overview also contradicts quote-lifecycle invariant 9's fail-closed rule. Exposure + context: the raw-input fallback triggers only when the peg-rate lookup fails *and* no + `evmToEvm` bridged-USDC amount exists — concretely the Base-BRLA offramp source — and + the resulting over-subsidy is still bounded by partner `maxSubsidy` × (inflated) + expected output plus the runtime post-swap caps. Those caps blunt, but do not eliminate, + the ~5× misdenomination. The recommended fix (fail the quote) is correct and cheap; + recommend applying it regardless of the severity label. +- **POST-012 — Agree.** Code confirmed (`discount.ts:232-237`). Additional data point: the + admin pricing-config endpoint rejects only *negative* `maxSubsidy` (per + discount-mechanism threat 1), so `0` reaches the engine and silently means uncapped. + `0` = disabled is the right semantics. +- **POST-013 — Agree.** The spec self-acknowledges the gap (`ramp-phase-flows.md:218`, + `:245`). The full atomic multi-level reservation scheme is a large system; the pragmatic + first step is a global funding-account rolling-window circuit breaker, and any shared + counter depends on resolving F-DISC-01 (process-local state / single-replica constraint) + first — the recommendation should reference that dependency. +- **POST-014 — Agree, with bounded-exposure context.** The subsidy is clamped to the + observable shortfall and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, so the overfund is at most + ~10% of expected per ramp within the cap — but the late-arriving remainder lands on the + *client-generated* ephemeral, so the platform cannot reliably recover it. Renaming the + property (heuristic, not "actual delivery") is right. Feasibility note: the bridge + status polling in `squidRouterPay` already consumes Squid/Axelar terminal status; + gating final settlement on the same terminal signal is implementable, not aspirational. +- **POST-015 — Agree.** One scoping note: every listed heuristic leg sits on the + quote-disabled BRL↔AssetHub recovery flows, so no *new* ramp reaches them. The + recommended "explicit temporary exception with bounded exposure" framing fits exactly. +- **POST-016 — Agree.** Invariant 6 (`ramp-phase-flows.md:203`) is simply stale: the + documented pre-Nabla offramp ordering is intentional and correct in fee-currency terms, + and the same file's corridor sections and checklist celebrate it. Fix the invariant to + per-flow ordering; do not change the flows. +- **POST-017 — Agree.** Both fail-open clauses verified (invariants 10 and 12 plus + checklist line 242). The recommended classification — parse failure of a + server-generated payload is corruption, RPC unavailability is a recoverable service + failure, neither proceeds to broadcast — is the right decomposition. +- **POST-018 — Agree as written.** +- **POST-019 — Agree.** Direction of the fix is unambiguous: invariant 17, the code + (`ramp.service.ts:187-204`), and the api-keys spec all agree that anonymous quotes are + claimable; checklist line 121 item (b) is the lone wrong statement and should be corrected. +- **POST-020 — Agree with the contradiction; push back on one recommendation element.** + Verified: the spec's threat/checklist rows list Hydration in the checked set + (`ephemeral-accounts.md:47`, `:63`; `transaction-validation.md:68`) while the code + intentionally excludes it (`ephemeral-freshness.ts:13`, comment at `:27-29`); the EVM + zero-balance discrepancy is softer since the checklists accurately say EVM = nonce-only + and invariant 9 hedges with "chain-appropriate definition". However, deriving the + required check set *from the persisted flow* would reintroduce exactly the gap + invariant 9 deliberately closed (a future phase addition silently widening the signing + surface for an already-checked ephemeral). Better: derive the set from the globally + registered catalog capabilities (all chains any cataloged flow can sign on), which + legitimately excludes disabled Hydration today, and update the spec to say so. +- **POST-021 — Agree on substance.** "MUST NOT be accessible to third-party scripts" is + unenforceable for same-origin storage as written, and the module's own threat table + concedes it. One caution for the remediation: retention is a deliberate fund-recovery + tradeoff — an ephemeral key is the only path to stranded funds on a failed ramp — so + terminal-state cleanup must be conditioned on confirmed sweeps/terminal settlement, not + merely on elapsed time or ramp status flips. +- **POST-022 — Agree, Critical confirmed.** All five implementation citations verified, + plus one addition: registration/deletion require `apiKeyAuth({ required: true })` + (`webhook.route.ts:7-9`), so the attack surface is any *authenticated partner* rather + than the anonymous internet — but nothing binds the webhook row to the registering + principal, quoteId existence is the only resource check, deletion has no owner + predicate, the URL check is a literal `startsWith("https://")` (internal HTTPS hosts, + redirects, and DNS games all pass), and the timestamp header is demonstrably outside + the signed bytes (`webhook-delivery.service.ts:25-27` signs the body alone). The + recommended envelope (`version.timestamp.eventId.bodyHash`) is the standard fix. +- **POST-023 — Agree on outage semantics; qualified agreement on the downgrade half.** + The 401-on-provider-outage rule for access-token verification vs. 503 for refresh is a + real inconsistency; `503` for indeterminate outcomes is correct. Rejecting + present-but-invalid credentials on `optionalAuth` routes (instead of silent anonymous + downgrade) is sound hardening and prevents the surprise-anonymous-ownership case, but + it deliberately changes availability behavior on public quote routes (an expired token + currently still gets a quote) — adopt it as a spec change with product sign-off rather + than as a pure correction. +- **POST-024 — Agree.** The checklist already marks the anon-key implementation FAIL + (F-018) while calling it "functionally correct" — the spec canonized the wrong + property. Fix the spec, close F-018 as invalid-requirement. +- **POST-025 — Agree as written.** +- **POST-026 — Agree.** The spec itself documents the O(n) scan (invariant 7) and + anticipates the key-ID format fix; the review is right that the 10-key cap bounds + per-user key count, not total users nor bcrypt work per *invalid* request (every failed + sk\_ attempt costs N × cost-10 bcrypt). The digest-with-embedded-key-ID recommendation + is correct — uniform 190-bit secrets don't need password hashing. +- **POST-027 — Agree as written.** Deliberate design, correctly documented; the finding + is a product/ops roadmap item, and the existing `METRICS_DASHBOARD_SECRET` split shows + the separation pattern has already started. Medium is right. +- **POST-028 — Agree; all four sub-conflicts independently verified**, including the + direct invariant-1 vs invariant-3 contradiction on expired-row visibility, and the + absence of any sweep job (no worker under `apps/api/src/api/workers/` touches + `recipient_invitations` — expiry runs only on accept/list paths, so an unlisted expired + row retains its raw token at rest indefinitely, falsifying invariant 1's "no live + secret at rest" claim). On the remediation: raw retention is a documented product + decision (sender re-copy), so the variant that preserves the feature is + issue-a-replacement-token-on-recopy with hash-only storage. +- **POST-029 — Agree with the finding; severity arguably Medium.** The module is already + unusually candid that eligibility is UX-only, and current exposure is bounded: + third-party payout is impossible on Mykobo/Alfredpay and consistency-checked on BRL. + Marking recipient payout non-normative until the §7.1 contract lands is cheap and + correct regardless of the label. +- **POST-030 — Agree.** The file even contradicts itself: invariant 4 and the overview + say deployer-only, while its own threat row I-1 records the migration to transferable + OZ `Ownable`. Contract verified (`Ownable(msg.sender)`, `onlyOwner` withdrawals). +- **POST-031 — Agree; severity depends on the deployed token set.** The invariant is + false for fee-on-transfer/rebasing tokens (no received-amount check between + `safeTransferFrom` and `forceApprove(value)`), but the relayer is currently used with + standard tokens (Polygon USDT), so the practical fix is stating the supported-token + assumption normatively (allowlist) rather than balance-delta engineering. On calldata: + the EIP-712 digest binds the exact calldata and the destination is immutable, so the + recommendation's final clause — explicitly state that the user authorizes specific + calldata to the immutable destination — is the honest framing. +- **POST-032 — Agree; examples verified**, including `[x]` boxes containing FAIL + (`supabase-otp.md:46`, `:51`), checked entries for deleted handlers + (`ramp-phase-flows.md:237-240`: spacewalk, Hydration XCM, post-swap), and + the former `AUDIT-RESULTS.md` (dated 2026-04-02) describing pre-#1232 code in the + present tense. The current module/risk/evidence authority split is the right end state; + the old audit-results and findings snapshots were later removed after their current + exceptions were indexed in `RISK-REGISTER.md`. +- **POST-033 — Agree.** Confirmed absent from the README index. The new module is also + the natural home for the normative content POST-001 through POST-006 call for; write it + once, not six times. + +### Answers to the proof-reading questions + +1. **No.** `flowVariant` is a deployment/business variant and `state.phaseFlow` is a + persisted phase sequence without a version; nothing else is a candidate. +2. **Yes, short-circuits are intentional** (e.g. `finalSettlementSubsidy` → + `destinationTransfer` on degenerate routes, per `fund-routing.md` invariant 11). No + complete allowed-edge list exists anywhere; it must be authored per flow (POST-002). +3. **Not today.** Flow tests deep-equal explicit expected sequences, so a duplicate would + surface — but nothing at construction time prevents one (POST-002/POST-004). +4. **Not determined in this pass.** Requires provider API documentation review + (Avenia/Alfredpay/Mykobo order- and ticket-creation endpoints); worth a follow-up. +5. **Partially.** F-DISC-01 (`discount-mechanism.md`) already mandates a single API + replica for discount state — a documented constraint, not an enforced guarantee, and + it does nothing for deploy-time catalog reinterpretation (POST-001 stands). +6. **Deliberate per the spec's own threat text, but not admin-distinguishable** — the + admin endpoint rejects only negative values, so `0` silently means uncapped. POST-012's + `0` = disabled recommendation stands. +7. **No.** No scheduled job touches `recipient_invitations`; the workers are cleanup, + ramp-recovery, unhandled-payment, and api-client-events-retention. Expiry is + read-path-only, confirming POST-028. +8. **Unknown from the repository.** Production runs on render.com; no egress restriction + is visible in-repo. Assume unrestricted until ops confirms otherwise (POST-022 should + not rely on infrastructure controls). +9. **No indication they were intended as normative.** The former + `AUDIT-RESULTS.md`/`FINDINGS.md` snapshots predated #1232 and were removed after the + review; Git history retains them (POST-032). +10. **Enumerable now.** The flatten at `ramp.service.ts:903-917` plus the compatibility + reads listed in `blocks/README.md` (e.g. `subsidizePostSwap` topping up to + `evmToEvm.inputAmountRaw`) are the complete surface; a one-off inventory belongs in + the new block-architecture module (POST-006/POST-033). + +### Existing unmerged remediation the review does not mention + +The prior review's agreed code fixes were **implemented but never merged**: branch +`codex/security-spec-review-2026-07-24` (13 commits, unmerged into `staging`; merge-base +`1ff8299b1`, i.e. before #1232 rewrote the pipeline underneath it) contains working +implementations for several reconfirmed findings: + +| Commit | Fix | Reconfirmed as | +|---|---|---| +| `716191627`, `703a019c5` | Webhook owner scoping, delivery hardening, signed `timestamp.body`, retry-stable event IDs, SSRF checks | POST-022 | +| `7d3564626` | Discount math fails closed when input cannot be valued in USD | POST-011 | +| `4ae55747b` | `maxSubsidy = 0` means disabled | POST-012 | +| `a36cf3703`, `594227b50` | API-key identifier-prefix lookup + digest (kills the O(n) bcrypt scan), with legacy backfill | POST-026 | +| `2a53a1bce`, `79f988719` | Route-scoped ephemeral freshness incl. EVM balance check | POST-020 | +| `4b81a3216` | fee-integrity.md rewritten around the actual fee engine (also fixes the fee-ordering invariant) | POST-010, POST-016 | + +Additionally, a declarative phase-transition graph enforcing handler-returned advances +(the POST-002 shape) was implemented as `fe335c287` and deliberately dropped — it +survives only on branch `backup-before-spec025-drop`, presumably because #1232's +`phaseFlow` mechanism landed concurrently. + +These will conflict with the post-#1232 tree (the block refactor deleted the code paths +they patch), so they are reference implementations to port, not merges — but the design +decisions they embody (webhook signature format and its breaking-change coordination, +`maxSubsidy` semantics, key-digest scheme) were already made and should not be +re-litigated. Porting these covers six of the review's findings, including one Critical +and most of remediation batches 4 and 6, at a fraction of from-scratch cost. + +### On the remediation order + +Agreed as sequenced. Two additions: the single cheapest high-value code change in the +whole review is the POST-011 fix (delete the raw-input fallback branch and fail the +quote) — it can ship independently of, and ahead of, remediation batch 4 — and much of +batches 4 and 6 already exists on the unmerged branch above. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md new file mode 100644 index 000000000..c96b7a03f --- /dev/null +++ b/docs/security-spec/RISK-REGISTER.md @@ -0,0 +1,45 @@ +# Security Risk Register + +## Authority and status + +This is the authoritative index of current, consciously accepted, deferred, or +deployment-dependent security risk. Module specifications define required behavior; this +register records the approved exceptions. A module checklist, historical finding, review +document, or implementation note cannot silently create another exception. + +Statuses have the following meanings: + +- **Accepted** — the residual risk is an intentional current product/architecture decision. +- **Deferred** — the behavior is intentionally out of scope and must not be enabled or expanded + until the exit criteria are met. +- **Deployment pending** — source is fixed, but production is not protected until rollout + evidence is recorded. + +Changes to an accepted boundary require business/architecture approval and updates to both this +register and the owning module specification. + +## Current risks + +| ID | Status | Severity | Owner role | Scope and decision | Existing controls | Revisit / exit criteria | +|---|---|---:|---|---|---|---| +| RISK-001 | Accepted | High | Platform + Finance | Subsidy limits are per component/ramp; there is no atomically reserved principal, partner, corridor, funding-wallet, or rolling-window budget. Current aggregate behavior is preserved. | Quote-bound amounts, per-component caps, fail-closed USD valuation, durable operation claims, funding-wallet balance. | Before materially increasing volume, adding concurrent workers, or widening subsidy-eligible corridors. | +| RISK-002 | Accepted | Medium | Operations | Administrative writes use one shared `ADMIN_SECRET`; there is no individual principal, MFA, role separation, selective revocation, or per-operator attribution. | Independent high-entropy secret, constant-time equal-length comparison, route middleware, rate limiting, operational rotation. | Introduce an identity provider before broadening the admin surface or team access. | +| RISK-003 | Accepted | Medium | Product + Security | Pending recipient invitations retain the raw bearer token so the sender can re-copy the link. | 192-bit random token, 14-day TTL, hash-only redemption lookup, sender-scoped listing, optional email binding, first-redeemer binding, raw token cleared on acceptance/observed expiry. | Revisit if invitations gain money-movement authority or threat exposure changes. | +| RISK-004 | Deferred | High | Product + Payments Architecture | Recipient eligibility is advisory; recipient-directed payout is unsupported. Ramp registration is a sender self-offramp and rejects common recipient-context fields. | Authenticated/entity-scoped recipient APIs; explicit registration rejection prevents accidental reliance on ignored fields. | A separate PR must define the second principal, relationship ownership, hard eligibility gate, and provider-side payout reference resolution before enabling recipient payout. | +| RISK-005 | Accepted | Medium | Product + Operations | The product promises the exact quoted amount. A ramp does not downgrade that promise or report a lesser amount as successful when automated delivery cannot complete. | Exact quote-bound targets, balance checks, capped subsidy paths, recoverable/terminal phase states, reconciliation data. | Add a formal deadline and automatic return of in-transit funds without weakening the exact-amount promise. | +| RISK-006 | Accepted | Medium | Client Platform | Client recovery keys are retained until a terminal ramp state is observed, then for 90 days; unresolved ramps are retained indefinitely. | Route-scoped freshness, pruning on storage access, terminal timestamp recorded once, no count-based eviction of unresolved records. | Revisit if storage pressure or client compromise data shows the retention window is too broad. | +| RISK-007 | Deployment pending | High | Smart Contracts + Operations | TokenRelayer source rejects fee-on-transfer shortfalls, partial consumption, codeless destinations, and cross-execution balance subsidy. Existing deployed addresses do not inherit the fix. Automatic discrepancy subsidy is intentionally absent because no immutable cap/funding policy has been approved. | Execution-local token/native balance accounting, exact transient allowance, refund attribution, events, contract tests. | Redeploy and verify bytecode on every supported chain, update the address registry, retire old deployments, and record rollout evidence. | +| RISK-008 | Accepted | High | Payments Platform | Squid/Axelar terminal status is preferred, but an EVM destination-balance fallback remains necessary because provider indexing can miss real arrivals. The fallback waits for baseline plus 90% of the exact route output; a late remainder can still overfund the ephemeral. | Route/source/token/amount/baseline-bound persisted evidence, explicit fallback kind and ratio, structured logging, per-ramp settlement cap, subsidy clamped to observed shortfall. | Add provider receipt proof or late-arrival reconciliation/recovery before raising caps or expanding exposure. | +| RISK-009 | Deferred | High | Cross-chain Platform | Quote-disabled BRL↔AssetHub recovery retains XCM evidence exceptions: Pendulum→AssetHub re-entry trusts an internally persisted finalized source block without proving AssetHub arrival, and Pendulum→Moonbeam recovery may use source depletion. | Public quote creation rejects both directions; new submissions wait for source finalization/events; destination amount checks exist where supported. | Destination receipt/balance-delta proof and durable ambiguous-broadcast recovery are release blockers before re-enabling either corridor. | +| RISK-010 | Accepted | Medium | Payments Architecture | Fee distribution is final even if a later phase fails; there is no fee-refund path. | Per-flow fee ordering, later-phase recovery/retry, exact phase metadata and durable external-operation claims. | Revisit when implementing automatic failure deadlines/refunds. | +| RISK-011 | Accepted | High | Infrastructure + Security | Application secrets are environment variables; there is no integrated secrets manager, access audit, dual-secret rollout, or automated rotation. | Deployment access controls, independent credentials, startup/runtime presence checks on security-critical paths, operational rotation. | Adopt managed secret storage and dual-key rotation before materially expanding privileged operators or deployments. | +| RISK-012 | Accepted | High | Rebalancer Operations | Rebalancer state has no distributed lock and several externally visible steps can be ambiguous across a crash; single-run scheduling is an operational assumption. | One-shot process, saved state, chain nonces/balance checks on some steps, daily bridge limit and route-cost policy. | Add a lease and durable operation claims before allowing overlapping schedules or multiple replicas. | +| RISK-014 | Accepted | Medium | Pricing + Treasury | CoinGecko’s `usd-coin` price is used as a USD/fiat fallback or sanity reference, so a USDC depeg can distort the reference. | FastForex/Binance primary routes, sanity bands, short cache TTL, fail-closed when no valid provider remains, operational depeg monitoring. | Replace with an independent fiat FX reference before raising depeg-sensitive exposure. | +| RISK-015 | Accepted | Low | EVM Operations | Base cleanup sweeps supported ERC-20 residuals after completion but does not sweep small native gas dust. | Just-in-time gas funding and token sweeps limit residual value. | Revisit if observed native residuals become material. | +| RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base ramps are not swept by the Base post-process handler, and AssetHub cleanup is a no-op. | Cleanup worker selects terminal ramps; completed Base token cleanup works; AssetHub corridors remain quote-disabled. | Widen safe Base cleanup to failed/timed-out states and implement/remove AssetHub cleanup before enabling AssetHub or relying on automatic failure refunds. | + +## Review cadence + +The owning role must review an entry when its trigger occurs and during any security release +review that changes the referenced scope. “Accepted” never means unbounded: if a control named +above is removed or a limit is raised, the exception requires fresh approval. diff --git a/docs/security-spec/SPEC-DELTA-2026-05.md b/docs/security-spec/SPEC-DELTA-2026-05.md deleted file mode 100644 index 729305e9e..000000000 --- a/docs/security-spec/SPEC-DELTA-2026-05.md +++ /dev/null @@ -1,306 +0,0 @@ -# Spec Delta — May 2026 (BRL on Base + Speedy BRL Flow) - -**Branch context:** `speedy-brl-flow` was merged into `create-spec-and-security-audit`. This delta documents: - -1. The architectural simplification of BRL on/off-ramp flows (Pendulum/Moonbeam/XCM removed → Base + EVM-Nabla + Squid). -2. New mechanisms touching multiple modules (no-permit fallback, deposit-QR gating, presigned-tx partitioning, EVM fee distribution, EVM subsidization). -3. Open audit findings introduced or surfaced by these changes — to be addressed in the next audit pass. - -> Existing finding IDs (F-001 through F-067) are preserved. New findings introduced in this delta are numbered **F-NEW-01** through **F-NEW-11** (with **F-NEW-06** split into **06a** and **06b**). - ---- - -## 1. Architectural Changes - -### 1.1 BRL on-ramp (Avenia → Base → user destination) - -**Old flow:** PIX → BRLA mint on Moonbeam → XCM → Pendulum → Nabla swap → XCM out → destination chain. - -**New flow:** PIX → Avenia mints BRLA on **Base** ephemeral → Nabla-on-EVM swap (BRLA → USDC) on Base → optional Squid bridge to user's destination EVM chain → `destinationTransfer`. - -Trivial passthrough: if destination is **Base + USDC**, Squid is skipped entirely (commit `4b0017adb`). - -Code references: -- Route builder: `apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts` -- Mint handler: `apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts` -- Onramp Nabla wrapper: `addNablaSwapTransactionsOnBase` → `createNablaTransactionsForOnrampOnEVM` (`@vortexfi/shared`) - -### 1.2 BRL off-ramp (user EVM → Base → Avenia PIX) - -**Old flow:** User's crypto → Pendulum (Nabla swap) → Moonbeam (XCM) → BRLA payout via `brla-payout-moonbeam-handler`. - -**New flow:** User EVM (any supported) → Squid bridge to **Base USDC** → `distributeFees` (USDC fees first) → Nabla-on-EVM swap (USDC → BRLA) on Base → `brla-payout-base-handler` triggers Avenia PIX payout. - -Code references: -- Route builder: `apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts` -- Payout handler: `apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts` - -**Removed:** `apps/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts` (no longer registered; phase `brlaPayoutOnMoonbeam` deleted). - -### 1.3 Phase additions - -| New Phase | Handler | Purpose | -|---|---|---| -| `brlaPayoutOnBase` | `brla-payout-base-handler.ts` | BRLA→Avenia transfer + PIX payout trigger | -| `squidRouterNoPermitTransfer` | (handled in `squidrouter-permit-execution-handler.ts` no-permit branch) | User-wallet ERC-20 direct transfer (no permit available) | -| `squidRouterNoPermitApprove` | (same handler) | User-wallet approve to Squid spender | -| `squidRouterNoPermitSwap` | (same handler) | User-wallet Squid swap call | - -`nablaApprove`, `nablaSwap`, `subsidizePreSwap`, `subsidizePostSwap`, and `distributeFees` are polymorphic phases whose handlers dispatch to a Substrate (Pendulum) or EVM (Base) branch at runtime based on the ephemeral chain involved. They are not new phases; they were extended with EVM branches as part of this delta. - -### 1.4 Phase ordering changes - -- **BRL offramp on Base**: `distributeFees` (EVM branch) runs **before** `nablaSwap` (EVM branch) (commit `423a38c79`) so partner/vortex fees are taken in USDC before swapping to BRLA. - -### 1.5 Cross-cutting infrastructure changes - -| Area | Change | Commit | -|---|---|---| -| Presigned-tx exposure | `partitionUnsignedTxs` + `filterUnsignedTxsForResponse` hide ephemeral txs from SDK until `ephemeralPresignChecksPass=true` | `4838e3c69` | -| Deposit-QR release | BRL on-ramp QR code only released to client after presign checks pass | `32be1659c` | -| No-permit fallback | New `isNoPermitFallback` path with user-submitted approve+swap (or direct transfer); backend verifies via `waitForTransactionReceipt` | `b45768be3` | -| Squid arrival timeout | `waitUntilTrue` enforces a finite timeout | `f7905dc40` | -| Squid 429 backoff | Exponential retry on rate-limit responses | `ff0b82feb` | -| EVM fee distribution | New Multicall3 path; `Partner.payout_address_evm` column added (migration 026); old `payout_address` renamed to `payout_address_substrate` (migration 027) | `544f70aee`, `f3dbb7ea7` | -| EVM fee balance precondition | 60-second poll (`FEE_BALANCE_POLL_TIMEOUT_MS`) before the EVM branch of `distributeFees` | `b518fcec8` | -| Skip-Squid trivial case | Quote engine + route builder short-circuit for Base+USDC destination | `4b0017adb` | -| Mint optimization | Skip `brlaOnrampMint` polling if balance already present (recovery scenario) | `6ea53d9d0` | - ---- - -## 2. Spec Files Updated - -| File | Change Type | Summary | -|---|---|---| -| `00-system-overview/architecture.md` | Patch | Added Base to chain list; updated BRL provider name to "BRLA/Avenia" | -| `03-ramp-engine/ramp-phase-flows.md` | Major rewrite (BRL section) | Replaced Moonbeam/Pendulum BRL corridors with Base flows; updated handler categories table; added new audit checklist items | -| `03-ramp-engine/ephemeral-accounts.md` | Patch | Added Base ephemeral; F-045/F-NEW-05 resolved by `BaseChainPostProcessHandler` (sweeps BRLA + USDC on Base) | -| `03-ramp-engine/fee-integrity.md` | Patch | Added EVM Multicall3 distribution mechanism; documented `Partner.payout_address_evm`/`payout_address_substrate`; documented BRL ordering invariants | -| `03-ramp-engine/transaction-validation.md` | Patch | Documented partitioning + filtering + deposit-QR gating; documented no-permit fallback phase skip | -| `05-integrations/brla.md` | **Full rewrite** | Replaced Moonbeam/PIX/XCM content with Base + Avenia API flow; added three-amount model; new audit checklist | -| `05-integrations/squid-router.md` | **Full rewrite** | Added Base as supported chain; documented skip-Squid path, no-permit fallback, arrival timeout, 429 retry; updated audit checklist | -| `06-cross-chain/fund-routing.md` | Patch | Added EVM subsidization handlers; documented `MOONBEAM_FUNDING_PRIVATE_KEY` cross-EVM reuse and proposed rename | - ---- - -## 3. Open Findings Introduced (or Surfaced) by This Delta - -These are findings **the user has confirmed direction on** during the spec rewrite session. Severity is the spec author's estimate; user confirmation noted per finding. - -### F-NEW-01 — Hardcoded BRL offramp validation amount (HIGH, confirmed bug) - -**Location:** `apps/api/src/api/services/transactions/offramp/validation.ts` → `validateBRLOfframp`. - -**Issue:** Hardcoded `offrampAmountBeforeAnchorFeesRaw: "200"` with a TODO comment, never validated against `quote.outputAmount`. - -**Risk:** Any BRL offramp could pass validation regardless of the actual offramp amount, bypassing a critical anchor-fee precondition check. - -**User decision:** **Bug — must validate against quote.** - -**Suggested fix:** Replace the hardcoded value with the real pre-anchor-fee amount derived from `quote.metadata.nablaSwapEvm.outputAmountRaw` (or equivalent), and assert equality with the actual presigned BRLA transfer amount. - ---- - -### F-NEW-02 — EVM subsidy handlers lack USD cap (MEDIUM, confirmed bug) - -**Location:** `apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` (EVM branches). - -**Issue:** Unlike `final-settlement-subsidy.ts` (which enforces `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` after the F-001 fix), the EVM branches of the subsidize-pre/post handlers had **no USD cap**. They trusted `quote.metadata.nablaSwapEvm.inputAmountForSwapRaw` / `outputAmountRaw` directly. - -**Risk:** If quote metadata is ever manipulable (DB compromise, race in quote engine, partner-controlled input fed without sanitization), the funding key on Base can be drained on a single ramp. Same risk class as original F-001. - -**User decision:** **Bug — EVM needs equivalent USD cap.** - -**Suggested fix:** Port the `validateSubsidyAmount` + USD cap logic from `final-settlement-subsidy.ts` into the EVM subsidy handlers. Use a Base-native USD reference (USDC at 1.0 or chainlink feed). When the cap is exceeded, throw a recoverable phase error before submitting any transfer so the ramp waits for operator action instead of requiring manual repair of an unrecoverably failed phase. - ---- - -### F-NEW-03 — `backupApprove` uses `maxUint256` allowance (LOW, design-debt) - -**Location:** `apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts:213-232`. - -**Issue:** The destination-chain backup approve presigned transaction grants `maxUint256` allowance to the funding-account-derived spender (same risk class as F-055). - -**Risk:** If the funding key (`MOONBEAM_FUNDING_PRIVATE_KEY`) is compromised, the attacker has unlimited ERC-20 allowance from each user's destination ephemeral for the bridged token. This is the existing F-055 pattern duplicated for the new BRL onramp path. - -**User decision:** Implicit (existing F-055 pattern). Confirm reduction to a precise needed amount. - -**Suggested fix:** Calculate the exact maximum amount the backup may need (e.g., `inputAmountRawFinalBridge`) and approve only that amount. - ---- - -### F-NEW-04 — No-permit fallback receipt validation is shallow (MEDIUM, needs hardening) - -**Location:** `apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts` → `waitForUserHash`. - -**Issue:** `waitForUserHash` only verifies `receipt.status === "success"`. It does NOT verify: -- `receipt.from === expected user address` -- `receipt.to === expected Squid router contract` -- Decoded calldata matches the expected approve/swap parameters (token, spender, amount) -- Transferred token / value matches the ramp - -**Risk (current):** A user (or attacker controlling the user's signing flow) could report any successful tx hash from their wallet. The subsequent `squidRouterPay` balance-check on Base provides a backstop — if no funds actually arrive, the ramp times out. So the worst plausible outcome is a stuck ramp (DoS), not a fund-routing exploit. - -**Risk (theoretical):** A clever sequence of unrelated successful txs reported as approve+swap could let the ramp advance into states it shouldn't be in. Combined with weaknesses in subsidization caps (F-NEW-02), this could compound. - -**User decision:** **Investigate.** This spec entry surfaces the gap; a code-side hardening task is appropriate. - -**Suggested fix:** In `waitForUserHash`, decode `receipt` and assert: -- `receipt.from === state.userAddress` (or equivalent) -- For `squidRouterNoPermitApprove`: `receipt.to === inputTokenAddress`, calldata is `approve(squidSpender, amount)`, amount matches expected -- For `squidRouterNoPermitSwap`: `receipt.to === SQUID_ROUTER_ADDRESS`, calldata matches expected swap params -- For `squidRouterNoPermitTransfer`: `receipt.to === inputTokenAddress`, calldata is `transfer(baseEphemeral, amount)`, amount matches the ramp's input amount - ---- - -### F-NEW-05 — Base ephemeral cleanup (RESOLVED) - -**Location:** `apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts`; presigned approvals in `apps/api/src/api/services/transactions/base/cleanup.ts`. - -**Issue (original):** Base ephemerals could accumulate residual BRLA/USDC after BRL ramps. Other EVM ephemerals were treated similarly: no cleanup. - -**Resolution:** A `BaseChainPostProcessHandler` is now registered. After `currentPhase === "complete"`, it sweeps BRLA and USDC residuals from the Base ephemeral via presigned `approve(funding, MAX_UINT256)` (ephemeral-signed) + `transferFrom(ephemeral, funding, balance)` (funding-key-signed), mirroring the Polygon pattern. ETH gas dust remains unswept by design (gas is funded just-in-time and rarely accumulates). Polygon and Hydration cleanups remain active. AssetHub cleanup remains a no-op stub. - ---- - -### F-NEW-12 — BRL on-ramp skipped EVM pre-swap subsidization (RESOLVED) - -**Location:** `apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts:220-222`. - -**Issue:** The BRL on-ramp runtime phase chain transitioned `fundEphemeral → nablaApprove` directly, skipping `subsidizePreSwap`. The handler was registered and wired downstream (`subsidizePreSwap → nablaApprove`), but no upstream handler returned `"subsidizePreSwap"` as its next phase for BRL onramps. The symmetric `subsidizePostSwap` phase was reached normally via `nablaSwap`'s nextPhase logic, producing an asymmetric flow where pre-swap subsidization was unreachable. - -**Risk:** If the Avenia BRLA mint underdelivers (e.g. anchor fee not pre-deducted, transient rounding, or mint amount slightly below `inputAmountForSwapRaw`), the on-ramp would fail at `nablaSwap` with insufficient input balance instead of being topped up by the funding key (capped by the configured EVM subsidy fraction for that handler; default `0.05`). User funds remained on the Base ephemeral until manual recovery. - -**Resolution:** Changed the BRL onramp branch of `FundEphemeralHandler.nextPhaseSelector` to return `"subsidizePreSwap"`. The phase chain is now `fundEphemeral → subsidizePreSwap → nablaApprove → nablaSwap → ...`, symmetric with the BRL off-ramp pre-swap subsidization path. - ---- - -### F-NEW-06a — `Partner.payout_address_evm` NULL on vortex row throws (LOW, operational) - -**Location:** `apps/api/src/api/services/transactions/common/feeDistribution.ts:232-241`. - -**Issue:** When the active `vortex` partner row has `payout_address_evm = NULL`, the EVM branch of `distributeFees` throws `Error("Vortex partner is missing payout_address_evm...")` and the phase fails. There is no env-var fallback (e.g., `DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS`) despite team intent to fall back to a default Vortex address. - -**Risk:** No fund loss (phase aborts before any transfer). Operational risk only — a misconfigured or pre-026 vortex row blocks all EVM fee distribution. - -**Suggested fix:** -1. Define `DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS` env var. -2. In `feeDistribution.ts`, coalesce `vortexPartner.payoutAddressEvm ?? DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS`. -3. Log a warning when the fallback is used so reconciliation can flag the misconfigured row. - ---- - -### F-NEW-06b — Partner `payout_address_evm` NULL silently drops markup fees (MEDIUM) - -**Location:** `apps/api/src/api/services/transactions/common/feeDistribution.ts:245-253, 273`. - -**Issue:** When the quote's partner has `payout_address_evm = NULL`, the code falls through silently: `partnerPayoutAddressEvm` stays `null`, `hasPartnerFees` becomes `false`, and the partner markup fee is never distributed. Vortex still gets paid; the partner does not. No error is surfaced to the partner or in logs at WARN/ERROR level. - -**Risk:** Silent fee loss for the partner on every BRL-on-Base ramp where the partner row is missing EVM payout config. Partners onboarded before migration 026 (or any new partner who forgot the EVM column) lose markup with no operational signal. - -**Suggested fix:** -1. At minimum: emit a WARN log when `partnerMarkupFeeUSD > 0` but `partnerPayoutAddressEvm === null`, identifying the partner ID. -2. Preferred: fail quote creation in `quote/engines/squidrouter/index.ts` (or upstream) if the requested ramp is BRL-on-Base and the partner has `payout_address_evm = NULL`. -3. Add a unit test for partner with NULL `payout_address_evm` exercising both the WARN path and the quote-time failure. - ---- - -### F-NEW-07 — `MOONBEAM_FUNDING_PRIVATE_KEY` is misnamed (LOW, refactor) - -**Location:** `apps/api/src/config/index.ts` (constant); `subsidize-*-evm-handler.ts`, `avenia-to-evm-base.ts:214`. - -**Issue:** The same private key now funds operations on **Moonbeam, Base, and any other EVM chain**. The "MOONBEAM_" prefix is misleading and creates a cognitive trap. - -**User decision:** **Rename to `EVM_FUNDING_PRIVATE_KEY` and refactor from a top-level constant to a getter (e.g., `getEvmFundingAccount(network)`)** so the cross-EVM reuse is explicit. - -**Suggested fix:** -1. Rename env var `MOONBEAM_FUNDING_PRIVATE_KEY` → `EVM_FUNDING_PRIVATE_KEY` (with deprecation alias). -2. Replace direct constant import with a service/getter that takes a `Networks` parameter and returns the correct viem account (currently always the same key, but the API is forward-compatible with chain-specific keys). -3. Update all callers in `subsidize-*-evm-handler.ts`, `final-settlement-subsidy.ts`, `avenia-to-evm-base.ts`, and any Squid handler that funds gas. -4. Update spec audit checklist (F-029 line) accordingly. - ---- - -## 4. Open Items NOT Resolved in This Pass - -These are findings that surfaced during the rewrite but were not investigated to closure. They warrant follow-up. - -### F-NEW-08 — Skip-Squid path: validation parity with full path (LOW, investigate) - -The skip-Squid trivial path (Base+USDC destination) emits only a `destinationTransfer` presigned tx. The destination address validation that normally runs during quote `validate()` is shared between paths, so no checks are bypassed in principle — but a code-side audit comparing the two paths phase-by-phase would be reassuring. - -### F-NEW-09 — `payOutTicketId` recovery branch and `brlaPayoutTxHash` recovery branch interaction (LOW, edge case) - -`brla-payout-base-handler.ts` has two independent recovery branches (existing ticket ID, existing tx hash). If a ramp recovers with both fields set, the handler short-circuits to `checkTicketStatusPaid` before re-broadcasting the on-chain tx. Confirm: is it possible to reach a state where the on-chain tx never confirmed but a ticket exists? If yes, polling-only recovery would miss the on-chain failure. - -### F-NEW-10 — Avenia anchor-fee assumption in three-amount model (MEDIUM, monitoring) - -The off-ramp three-amount model assumes `transferAmount ≥ payoutAmount` (i.e., Avenia anchor fee ≥ 0). If Avenia ever introduces a credit or promotional rate that violates this, `quote.outputAmount` could exceed the deposited BRLA. Add a runtime invariant check: `Big(brlaTransferAmountRaw).gte(quote.outputAmount.times(10**brlaDecimals))` before the on-chain transfer. - -### F-NEW-11 — Audit existing `F-029` (`MOONBEAM_FUNDING_PRIVATE_KEY` = `MOONBEAM_EXECUTOR_PRIVATE_KEY`) under new BRL flow - -Under the old flow, this key collision was scoped to Moonbeam. Now it applies to Base too. Re-rate severity in light of the larger blast radius (compromise affects BRL flows + EUR flows + Squid permit execution). - ---- - -## 5. Carried-Over Findings (No Status Change) - -These pre-existing findings remain open and are unchanged by the BRL migration: - -- **F-014**: Avenia/external API timeouts not configured -- **F-029**: `MOONBEAM_FUNDING_PRIVATE_KEY` and `MOONBEAM_EXECUTOR_PRIVATE_KEY` collide (now applies to Base too — see F-NEW-11) -- **F-038, F-039, F-040, F-041, F-042, F-043, F-047, F-048, F-049, F-050**: Validation gaps in presigned tx content -- **F-053**: Five phase handlers lack idempotency guards -- **F-054**: `backupSquidRouterApprove` / `backupSquidRouterSwap` / `backupApprove` have no registered phase handler -- **F-055**: `backupApprove` uses `maxUint256` (now also applies to BRL onramp — see F-NEW-03) -- **F-056**: `sandboxEnabled` bypass -- **F-057**: `destinationTransfer` does not validate `to` address against quote -- **F-058**: No per-presigned-transaction TTL -- **F-051, F-052**: Cleanup observability gaps — now partially relevant again since Base/Polygon/Hydration cleanups are active and benefit from per-handler success/failure metrics. - ---- - -## 6. Suggested Next Audit Pass - -Priority order for the next audit/dev cycle, based on severity × likelihood. Resolution status reflects fixes landed during the 2026-05 remediation pass. Post-review fixes on 2026-05-12 also closed the Supabase quote-ownership bypass in `assertQuoteOwnership`, restored signed-payload-aware presigned transaction matching, removed duplicate Squid permit relayer execution, restored direct-transfer permit execution, and documented the recoverable-wait policy for EVM subsidy cap breaches. - -| # | Finding | Status | -|---|---|---| -| 1 | **F-NEW-02** (HIGH if cap matters in practice) — Add EVM subsidy USD cap. Mirror F-001 fix. | RESOLVED — env-configured EVM subsidy cap fractions are enforced in the pre/post-swap EVM handlers. `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` defaults to `0.05`; post-swap discount-derived subsidy is capped separately via `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`, also defaulting to `0.05`. Over-cap cases are recoverable waits with no transfer submitted. | -| 2 | **F-NEW-01** (HIGH) — Replace hardcoded `validateBRLOfframp` amount. | RESOLVED — `validateBRLOfframpMetadata(quote)` reads `quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw`. Dead `evm-to-brl.ts` route deleted. | -| 3 | **F-NEW-06b** (MEDIUM) — Surface or fail-fast on partner `payout_address_evm` NULL (silent markup loss). | RESOLVED — quote-time rejection (`APIError 400`) when partner has markup AND `payout_address_evm` NULL on EVM-payout routes; runtime WARN if it slips through. | -| 4 | **F-NEW-04** (MEDIUM) — Harden no-permit fallback receipt validation. | RESOLVED — `waitForUserHash` now verifies receipt `to` and tx `input` against the presigned `EvmTransactionData`. | -| 5 | **F-NEW-11** (MEDIUM) — Re-evaluate F-029 severity with Base in scope. | RESOLVED — `fund-routing.md` and `secret-management.md` updated to reflect Base blast radius (BRLA payouts, EVM fee distribution, ephemeral subsidization across all EVM chains). | -| 6 | **F-NEW-06a** (LOW) — Add `DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS` env-var fallback. | RESOLVED — `config.defaults.vortexEvmPayoutAddress` falls back when `vortexPartner.payoutAddressEvm` is NULL. | -| 7 | **F-NEW-07** (LOW, mostly hygiene) — Rename `MOONBEAM_FUNDING_PRIVATE_KEY` → `EVM_FUNDING_PRIVATE_KEY` with proper getter abstraction. | RESOLVED — new `EVM_FUNDING_PRIVATE_KEY` env (back-compat fallback to `MOONBEAM_EXECUTOR_PRIVATE_KEY`); all 13 call sites migrated to `getEvmFundingAccount(network)` helper at `apps/api/src/api/services/phases/evm-funding.ts`. | -| 8 | **F-NEW-03** (LOW) — Tighten `backupApprove` allowance from `maxUint256` to a calculated bound. | RESOLVED — `avenia-to-evm-base.ts` `backupApprove` now uses `inputAmountRawFinalBridge × 1.05`. | -| 9 | **F-NEW-08** — Investigate skip-Squid passthrough divergence. | NO BUG — same-chain same-token passthrough has no Squid fee; `networkFeeUSD="0"` and 1:1 rate are correct. | -| 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`. `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. `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` | `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 | -| `GET /v1/ramp/:id/errors` | `optionalPartnerOrUserAuth()` | `assertRampOwnership(req, id)` — same condition as update | -| `GET /v1/ramp/history` | `requirePartnerOrUserAuth()` | Effective user required; direct `RampState.userId` filter across wallets. No partner-wide fallback and never anonymous. | -| `GET /v1/ramp/history/:walletAddress` | `requirePartnerOrUserAuth()` | Service-layer filter: partner → owned `quoteId`s; user → matching `userId`. **Never anonymous.** | -| `/v1/brla/*` user data | `requireAuth` | Supabase userId scoping | -| `/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 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 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 deleted file mode 100644 index d19e5190b..000000000 --- a/docs/test-audit-findings.md +++ /dev/null @@ -1,438 +0,0 @@ -# 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 deleted file mode 100644 index 438111d3f..000000000 --- a/docs/test-suite-integrity-check.md +++ /dev/null @@ -1,169 +0,0 @@ -# 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/memory-bank/activeContext.md b/memory-bank/activeContext.md deleted file mode 100644 index 47eb02b7c..000000000 --- a/memory-bank/activeContext.md +++ /dev/null @@ -1,56 +0,0 @@ -# Active Context: Pendulum Pay Features - -## Current Work Focus -[2025-06-17] - Implementing Monerium integration as an alternative issuer/anchor for EUR transactions. - -## Recent Changes -[2025-06-13 11:34:00] - Completed the design for the 'Under Maintenance' feature. - - Database schema for `maintenance_schedules` defined. - - API endpoint `GET /api/v1/maintenance/status` specified. - - Design documented in `docs/architecture/maintenance-feature-design.md`. - -[2025-06-13 11:40:00] - Completed implementation of the 'Under Maintenance' feature backend logic. - - Created maintenance.service.ts with in-memory store for maintenance schedules. - - Created maintenance.controller.ts with API endpoint handlers. - - Created maintenance.route.ts with route definitions. - - Registered maintenance routes in the main v1 router. - - API endpoint GET /api/v1/maintenance/status is now functional. - - Additional admin endpoints created for testing and management. - -[2025-06-13 15:31:00] - Completed database integration for the 'Under Maintenance' feature. - - Replaced in-memory store with PostgreSQL database integration using Sequelize ORM. - - Created migration 007-maintenance-schedules-table.ts for the maintenance_schedules table. - - Created MaintenanceSchedule model with proper field mappings and indexes. - - Updated MaintenanceService to use database queries with Sequelize operations. - - Fixed controller methods to handle async database operations. - - Created seed script for testing with sample maintenance schedules. - - Verified API endpoints are working correctly with database integration. - - API endpoint GET /v1/maintenance/status returns proper responses based on database data. - -[2025-06-17] - Started Monerium integration implementation. - - Created Zustand store (moneriumStore.ts) to manage Monerium flow state - - Implemented authentication service (moneriumAuth.ts) with OAuth PKCE flow - - Created API service (monerium.service.ts) for backend communication - - Created useMoneriumFlow hook to manage authentication state and redirects - - Integrated Monerium flow into useSubmitRamp hook for EUR transactions - - Updated useRegisterRamp to include Monerium auth data in ramp registration - -## Next Steps -1. Backend implementation: - - Create Monerium service endpoints for user status and auth validation - - Implement routing logic to determine when to use Monerium vs Stellar anchors - - Handle Monerium auth tokens in ramp registration - - Implement offramp execution logic (backend-only) - -2. Frontend refinements: - - Test the complete authentication flow - - Handle edge cases and error scenarios - - Add loading states and user feedback - - Ensure proper cleanup on component unmount - -3. Integration testing: - - Test new user signup flow - - Test existing user SIWE login - - Test ramp registration with Monerium auth data - - Verify proper routing between Monerium and Stellar anchors - diff --git a/memory-bank/decisionLog.md b/memory-bank/decisionLog.md deleted file mode 100644 index 703262ebd..000000000 --- a/memory-bank/decisionLog.md +++ /dev/null @@ -1,523 +0,0 @@ -# Decision Log - -## 2025-04-28: Enhanced Fee Structure Design - -### Decision -Adopted a new database schema and backend logic to support a granular fee structure (network, processing, partner markup) standardized in USD. Replaced the single `fee` field in `quote_tickets`. Introduced `partners` and `fee_configurations` tables. Partner identification will be via `partner_id` (UUID) passed in the quote request. - -### Rationale -To provide transparency and flexibility in fee calculation, allowing for different fee components (base fees, network costs, partner-specific markups) to be tracked and applied individually. Simplifies future adjustments and partner integrations compared to a single opaque fee. Using a direct `partner_id` from the frontend was chosen over API key authentication for initial simplicity. - -### Implementation Details -- **Database:** Modify `quote_tickets`, create `partners`, create `fee_configurations` tables as specified in `docs/architecture/fee-enhancement-plan.md`. -- **Backend:** Update `QuoteService` to validate optional `partner_id`, fetch configurations from new tables, calculate fee components in USD, and save the breakdown to `quote_tickets`. -- **API:** Update `/v1/ramp/quotes` DTOs to accept optional `partner_id` and return the fee breakdown. -- **Network Fee:** Use a static 1 USD estimate initially, configured in `fee_configurations`. - - -## 2025-04-29: Fee Calculation Refactoring Implementation - -### Decision -Refactored the fee calculation logic in `api/src/api/services/ramp/quote.service.ts` to use database configurations, dynamically calculate network fees, and deduct the total fee from the gross output amount. - -### Rationale -To improve accuracy, transparency, and maintainability of fee handling, aligning with the enhanced fee structure design decision from 2025-04-28. Addresses previous placeholder logic and incorporates dynamic network cost estimation. - -### Implementation Details -1. **Fee Sources:** - * Vortex Foundation fee sourced from the 'vortex_foundation' partner record. - * Anchor fees sourced from `fee_configurations` table (`feeType: 'anchor_base'`). - * Partner markup sourced from the quote's specific partner. - * Static `network_estimate` fee type removed from `FeeConfiguration` model and migrations. -2. **Calculation Flow:** - * Renamed `calculateOutputAmount` to `calculateGrossOutputAndNetworkFee`. This function now returns the gross swap output and a dynamically calculated `networkFeeUSD` (using a stub 1 GLMR = 0.08 USD rate for EVM on-ramps via Squidrouter, '0' otherwise). - * Refactored `calculateFeeComponents` to *only* calculate `vortexFee`, `anchorFee`, and `partnerMarkupFee` in USD. - * Updated `createQuote` to: - * Call the two calculation functions. - * Sum all four fee components (`networkFeeUSD`, `vortexFee`, `anchorFee`, `partnerMarkupFee`) to get `totalFeeUSD`. - * Convert `totalFeeUSD` to the output currency using a **new placeholder function `convertFeeToOutputCurrency` (marked with a TODO for proper implementation with price feeds)**. - * Calculate `finalOutputAmount` by subtracting the (placeholder-converted) total fee from `grossOutputAmount`. - * Store `finalOutputAmount` and the detailed USD fee breakdown (`{ network, vortex, anchor, partnerMarkup, total, currency }`) in the `QuoteTicket`. - * Updated `getQuote` to correctly transform the stored detailed fees into the summarized API response format. -3. **Cleanup:** Deleted the obsolete helper file `api/src/api/helpers/quote.ts`. - -### Known Issues/TODOs -- The `convertFeeToOutputCurrency` function uses placeholder logic. It **must** be implemented with real price feed data for accurate fee deduction, especially for non-USD output currencies. -- The GLMR->USD conversion rate in `calculateGrossOutputAndNetworkFee` is hardcoded (0.08) and needs replacement with dynamic price fetching. - -## 2025-12-06: Implementation of updateRamp Endpoint - -### Decision -Implemented a new `updateRamp` endpoint that allows the frontend to submit presigned transactions and additional data before calling `startRamp`. This decouples data submission from process initiation, improving resilience against failures where Vortex doesn't process transactions properly. - -### Rationale -In the past, there were issues where an offramp could not be processed because while the user signed and submitted the transaction on their side, Vortex did not process it properly and never called into the 'startRamp' endpoint. Since 'startRamp' was never called with the presigned transactions of the ephemerals, the ramp couldn't be completed even though the funds left the user's account. - -### Implementation Details -1. **Backend Changes:** - - Added `UpdateRampRequest` and `UpdateRampResponse` DTOs to shared package - - Extended `RampState` model with `additionalData` field (nullable JSONB) - - Implemented `updateRamp` controller and service methods - - Added `POST /v1/ramp/:rampId/update` route - - Modified `startRamp` to use data from `RampState` instead of request parameters - - Created database migration `007-add-additional-data-to-ramp-states.ts` - -2. **Frontend Changes:** - - Updated `StartRampRequest` DTO to only require `rampId` - - Added `updateRamp` method to frontend `RampService` - - Integrated `updateRamp` calls in `useRegisterRamp` hook: - - Called after ephemeral transactions are signed - - Called again after user transactions are signed (for offramps) with additional data - -3. **New Flow:** - - `register` → `updateRamp` (ephemeral txs) → `updateRamp` (user txs + hashes) → `start` - - The backend merges data from multiple `updateRamp` calls - - `startRamp` validates that required data is present before processing - -### Benefits -- Improved resilience: Data is stored before process initiation -- Better error recovery: Failed `startRamp` calls can be retried without re-signing -- Cleaner separation of concerns: Data submission vs. process execution -- Maintains existing security model: No private keys stored on backend - -## 2025-06-13 11:32:00 - Maintenance Feature Design - -### Decision -Designed a database schema (`maintenance_schedules` table) and an API endpoint (`GET /api/v1/maintenance/status`) for the 'under maintenance' feature. The design includes fields for start/end times, a display message, and an active configuration flag. The API will return the current maintenance status and relevant details. - -### Rationale -To provide a mechanism for informing users when the application is undergoing scheduled maintenance, improving user experience during downtime. The design allows for pre-configuration of maintenance windows and a clear way for the frontend to query the status. - -### Implementation Details -- **Database Table:** `maintenance_schedules` as defined in `docs/architecture/maintenance-feature-design.md`. -- **API Endpoint:** `GET /api/v1/maintenance/status` as defined in `docs/architecture/maintenance-feature-design.md`. -- Administrator interaction will be handled via direct database manipulation, as per user confirmation. - -## 2025-10-29: API Key Authentication System Implementation - -### Decision -Implemented a comprehensive API key authentication system for partner discount protection. The system uses bcrypt-hashed API keys stored in a new `api_keys` table, with optional middleware-based authentication and strict partner-payload validation. - -### Rationale -Previously, anyone could use any `partnerId` in quote requests without authentication, creating a security vulnerability where unauthorized parties could claim partner discounts. The API key system ensures only authenticated partners can access their discounts while maintaining backward compatibility for non-partner requests. - -### Implementation Details - -**Phase 1 - Foundation:** -- Created `api_keys` table with migration `017-create-api-keys-table.ts` -- Fields: `id`, `partner_id`, `key_hash`, `key_prefix`, `name`, `last_used_at`, `expires_at`, `is_active` -- Indexes on `partner_id`, `key_prefix`, `is_active`, and composite `(is_active, key_prefix)` -- Created `ApiKey` model with Sequelize ORM -- Established Partner ↔ ApiKey associations (one-to-many) -- Added `bcrypt` and `@types/bcrypt` dependencies - -**Phase 2 - Authentication Layer:** -- Implemented `apiKeyAuth.helpers.ts` with core functions: - - `generateApiKey()`: Creates keys in format `vrtx_(live|test)_[32_chars]` - - `hashApiKey()`: Bcrypt hashing with 10 salt rounds - - `validateApiKey()`: Prefix-based lookup + bcrypt comparison - - `isValidApiKeyFormat()`: Regex validation - - `getKeyPrefix()`: Extracts first 8 characters for display/lookup -- Implemented `apiKeyAuth.ts` middleware: - - `apiKeyAuth({ required, validatePartnerMatch })`: Main auth middleware - - `enforcePartnerAuth()`: Validates partnerId match when present in payload -- Extended Express Request type with `authenticatedPartner` property - -**Phase 3 - Admin Interface:** -- Created admin controller `admin/partnerApiKeys.controller.ts`: - - `createApiKey()`: Generate and return new API key (shown only once) - - `listApiKeys()`: List all keys for a partner (without raw keys) - - `revokeApiKey()`: Soft delete by setting `isActive = false` -- Created admin routes `admin/partner-api-keys.route.ts`: - - `POST /v1/admin/partners/:partnerId/api-keys` - - `GET /v1/admin/partners/:partnerId/api-keys` - - `DELETE /v1/admin/partners/:partnerId/api-keys/:keyId` -- Registered routes in main v1 router - -**Phase 4 - Quote Integration:** -- Updated quote routes to include authentication middleware: - - `apiKeyAuth({ required: false })`: Optional authentication - - `enforcePartnerAuth()`: Required when `partnerId` in payload -- Authentication flow: - - No API key + no partnerId → Continues normally (backward compatible) - - No API key + partnerId → 403 Forbidden (auth required) - - Valid API key + matching partnerId → Success with discount - - Valid API key + mismatched partnerId → 403 Forbidden (partner mismatch) - -### Security Features -- Bcrypt hashing (10 rounds) for API key storage -- Never store or retrieve raw API keys (shown once on creation) -- Prefix-based indexing reduces bcrypt operations -- Automatic `last_used_at` tracking -- Optional expiration dates -- Soft deletion (preserves audit trail) -- Constant-time comparison via bcrypt -- Environment separation (live/test keys) - -### Key Format -- Pattern: `vrtx_(live|test)_[32_alphanumeric_chars]` -- Example: `vrtx_live_a7f3b2c9d1e4f5g6h7i8j9k0l1m2n3o4` -- 32 characters provide ~191 bits of entropy - -### Authentication Header -- Uses `X-API-Key` header (not `Authorization`) -- Clearly distinguishes from future OAuth/JWT tokens - -### Backward Compatibility -- All existing quote endpoints work without API keys -- Authentication only required when `partnerId` is specified -- No breaking changes to existing API consumers - -### Error Responses -- `401 INVALID_API_KEY`: Invalid/expired/missing required key -- `403 AUTHENTICATION_REQUIRED`: partnerId without authentication -- `403 PARTNER_MISMATCH`: Authenticated partner ≠ payload partnerId - -### Benefits -- Secures partner discount system -- Prevents unauthorized use of partner IDs -- Maintains backward compatibility -- Supports multiple keys per partner (rotation) -- Comprehensive audit trail -- Scalable architecture for future enhancements - -## 2025-10-29: Admin Endpoint Protection - -### Decision -Implemented Bearer token authentication for admin endpoints using an environment-based secret. The system uses constant-time comparison to prevent timing attacks and provides clear error messages for authentication failures. - -### Rationale -Admin endpoints for API key management need protection to prevent unauthorized access. Using a simple Bearer token approach with an environment variable provides a secure, easy-to-manage solution suitable for internal team use. - -### Implementation Details - -**Files Created:** -- `apps/api/src/api/middlewares/adminAuth.ts` - Admin authentication middleware - -**Files Modified:** -- `apps/api/src/config/vars.ts` - Added `adminSecret` configuration -- `apps/api/src/api/routes/v1/admin/partner-api-keys.route.ts` - Applied `adminAuth` middleware -- `apps/api/.env.example` - Documented `ADMIN_SECRET` environment variable - -**Authentication Flow:** -1. Client sends request with `Authorization: Bearer ` header -2. Middleware extracts and validates Bearer token format -3. Performs constant-time comparison against configured secret -4. Returns 401/403 on failure, proceeds on success - -**Security Features:** -- Constant-time string comparison prevents timing attacks -- Clear separation between missing auth (401) and invalid token (403) -- Environment-based secret configuration -- No hardcoded credentials -- Detailed error messages for debugging - -**Error Responses:** -- `401 ADMIN_AUTH_REQUIRED`: No Authorization header provided -- `401 INVALID_AUTH_FORMAT`: Malformed Authorization header -- `403 INVALID_ADMIN_TOKEN`: Token doesn't match configured secret -- `500 ADMIN_AUTH_NOT_CONFIGURED`: ADMIN_SECRET not set in environment - -**Usage:** -```bash -# Generate a secure secret -openssl rand -base64 32 - -# Set in environment -export ADMIN_SECRET="your-generated-secret" - -# Use in API calls -curl -H "Authorization: Bearer your-generated-secret" \ - https://api.example.com/v1/admin/partners/:partnerId/api-keys -``` - -### Benefits -- Protects sensitive admin operations -- Simple to configure and use -- Suitable for internal team access -- No additional authentication infrastructure needed -- Constant-time comparison enhances security -- Clear error messages aid debugging - -## 2025-10-29: Environment-Based API Key Prefixes - -### Decision -Updated API key generation to automatically use environment-appropriate prefixes based on the `SANDBOX_ENABLED` environment variable. Sandbox environments generate `vrtx_test_*` keys while production generates `vrtx_live_*` keys. - -### Rationale -Distinguishing between sandbox and production API keys prevents accidental use of test keys in production and vice versa. The prefix provides immediate visual identification of the key's intended environment. - -### Implementation Details - -**Files Modified:** -- `apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts` - Added environment detection -- `apps/api/.env.example` - Documented `SANDBOX_ENABLED` variable - -**Key Generation Logic:** -```typescript -const environment = SANDBOX_ENABLED === "true" ? "test" : "live"; -const apiKey = generateApiKey(environment); -``` - -**Key Formats:** -- **Production:** `vrtx_live_[32_random_chars]` - - Example: `vrtx_live_a7f3b2c9d1e4f5g6h7i8j9k0l1m2n3o4` -- **Sandbox:** `vrtx_test_[32_random_chars]` - - Example: `vrtx_test_a7f3b2c9d1e4f5g6h7i8j9k0l1m2n3o4` - -**Environment Configuration:** -- `SANDBOX_ENABLED="true"` → Generates test keys -- `SANDBOX_ENABLED="false"` or unset → Generates live keys - -### Benefits -- Clear visual distinction between environments -- Prevents accidental cross-environment key usage -- Aligns with existing sandbox configuration pattern -- No code changes needed to switch environments -- Follows industry best practices (e.g., Stripe's key format) - -### Security Implications -- Both key types are validated identically -- Same security measures apply to both prefixes -- Validation accepts both formats in any environment -- Prevents test keys from being used in production workflows (if additional validation is added later) - -## 2025-10-29: Partner Name-Based API Key Association - -### Decision -Changed API key association from partner ID (one-to-one) to partner name (one-to-many). API keys are now created and validated based on the partner's `name` field, allowing a single key to work for all partner records with the same name (e.g., both BUY and SELL configurations). - -### Rationale -Partners can have multiple records in the database - one for each ramp type (BUY/SELL). Previously, each record would need its own API key, which was cumbersome. By associating keys with the partner name instead of the specific record ID, a single API key works across all configurations for that partner. - -### Implementation Details - -**Database Schema Changes:** -- **Old:** `partner_id UUID` with foreign key to `partners(id)` -- **New:** `partner_name VARCHAR(100)` with no foreign key - -**Migration:** `017-create-api-keys-table.ts` -- Removed `partnerId` field and foreign key constraint -- Added `partnerName` field (VARCHAR 100) -- Changed index from `idx_api_keys_partner_id` to `idx_api_keys_partner_name` - -**Model Changes:** `apps/api/src/models/apiKey.model.ts` -- Changed `partnerId: string` to `partnerName: string` -- Removed Partner association (no foreign key) -- Added comment about manual matching - -**Model Associations:** `apps/api/src/models/index.ts` -- Removed `Partner.hasMany(ApiKey)` and `ApiKey.belongsTo(Partner)` -- Added comment about manual matching via `partnerName` - -**Controller Changes:** `apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts` -- Routes now use `:partnerName` instead of `:partnerId` -- `createApiKey()`: Finds all partners with the given name, creates single key -- `listApiKeys()`: Returns keys for a partner name -- `revokeApiKey()`: Revokes key by name and ID -- Response includes `partnerCount` showing how many records share the key - -**Authentication Helper:** `apps/api/src/api/middlewares/apiKeyAuth.helpers.ts` -- `validateApiKey()` now: - 1. Finds ApiKey by prefix - 2. Validates bcrypt hash - 3. Looks up Partner by `partnerName` field - 4. Returns first active partner found (any with that name) - -**Route Changes:** -- Admin endpoints: `/v1/admin/partners/:partnerName/api-keys` (was `:partnerId`) -- Documentation updated to clarify name-based behavior - -### Example Scenario - -**Before (Partner ID-based):** -``` -Partners Table: -- ID: uuid-1, name: "TestPartner", rampType: "BUY" -- ID: uuid-2, name: "TestPartner", rampType: "SELL" - -API Keys Needed: 2 (one for each ID) -``` - -**After (Partner Name-based):** -``` -Partners Table: -- ID: uuid-1, name: "TestPartner", rampType: "BUY" -- ID: uuid-2, name: "TestPartner", rampType: "SELL" - -API Keys Needed: 1 (works for both records) -``` - -### API Usage - -**Create Key:** -```bash -POST /v1/admin/partners/TestPartner/api-keys -# Creates one key that works for all "TestPartner" records -``` - -**Use Key:** -```bash -POST /v1/quotes -X-API-Key: vrtx_live_abc123... -{ - "partnerId": "uuid-1", # Can be either BUY or SELL record - ... -} -# System validates key matches partner name, not specific ID -``` - -### Benefits -- Simpler key management for partners with multiple configurations -- Single key for both BUY and SELL flows -- Cleaner admin interface (use names instead of UUIDs) -- Reduces number of keys needed -- More intuitive for partners - -### Trade-offs -- No database-level referential integrity (no foreign key) -- Manual lookup required to find partners by name -- Slightly more complex validation logic -- Partner name changes would break API key association - -### Migration Considerations -- Existing systems must regenerate API keys after migration -- Keys created before this change will not work -- Partner names must remain stable (don't change names) - -## 2025-10-29: Public/Secret Key Architecture (Dual-Key System) - -### Decision -Refactored the API key system from a single-key model to a dual-key architecture with public keys (pk_*) for tracking and secret keys (sk_*) for authentication. Public keys can be exposed client-side while secret keys remain server-only. - -### Rationale -The single API key approach had limitations: -1. Keys needed to be kept secret, limiting client-side usage -2. No way to track quote origins without exposing secrets -3. Couldn't be used in URLs/widgets safely -4. Following industry standard (Stripe, PayPal) two-key pattern provides better security and flexibility - -### Implementation Details - -**Key Formats:** -- **Public Keys:** `pk_live_*` or `pk_test_*` (plaintext storage, can be exposed) -- **Secret Keys:** `sk_live_*` or `sk_test_*` (bcrypt hashed, server-only) - -**Database Schema Changes:** -- Added `key_type` ENUM('public', 'secret') field -- Added `key_value` VARCHAR(255) for plaintext public keys -- Made `key_hash` nullable (only for secret keys) -- Updated indexes to include key_type -- Added `api_key` field to quote_tickets for tracking - -**Models Updated:** -- `ApiKey`: Added keyType, keyValue fields -- `QuoteTicket`: Added apiKey field -- Both support the new dual-key structure - -**Key Generation:** -- Admin creates both keys as a pair simultaneously -- Public key: Stored in plaintext (`keyValue`) -- Secret key: Bcrypt hashed and stored (`keyHash`) -- Environment-based prefixes (test/live) - -**Usage Patterns:** - -1. **Public Key (pk_*):** - - Can be in request body or query params - - Used for tracking which partner created quotes - - Validates existence only - - Stored on quote records - - Applies partner discounts if valid - - Can be exposed in JavaScript, URLs, widgets - -2. **Secret Key (sk_*):** - - Must be in X-API-Key header - - Used for server-to-server authentication - - Bcrypt validation - - Required for high-risk operations - - Never exposed client-side - -**Middleware Architecture:** -- `validatePublicKey()`: Validates public keys (optional) -- `apiKeyAuth()`: Validates secret keys (optional, required for partnerId) -- `enforcePartnerAuth()`: Ensures secret key matches partnerId -- Both work together in quote flow - -**Quote Flow:** -```typescript -POST /v1/quotes -{ - "apiKey": "pk_live_abc123...", // Public key for tracking - "partnerId": "PartnerName", // Requires secret key in header - ... -} -Headers: - X-API-Key: sk_live_xyz789... // Secret key for authentication -``` - -**Admin Endpoints:** -```typescript -POST /v1/admin/partners/:partnerName/api-keys -Response: { - publicKey: { id, key: "pk_live_...", ... }, - secretKey: { id, key: "sk_live_...", ... } // Shown only once! -} - -GET /v1/admin/partners/:partnerName/api-keys -Response: { - apiKeys: [ - { type: "public", key: "pk_live_...", ... }, // Full key shown - { type: "secret", keyPrefix: "sk_live_", ... } // Only prefix shown - ] -} -``` - -**Security Model:** -- Public keys: Plaintext, indexed, can leak without security impact -- Secret keys: Bcrypt hashed, never retrievable, validated with constant-time comparison -- Both support expiration and soft deletion -- Both track last_used_at - -**Migration Files:** -- `017-create-api-keys-table.ts`: Main api_keys table with dual-key support -- `018-add-api-key-to-quote-tickets.ts`: Adds tracking field to quotes - -### Benefits -1. **Client-Side Safe:** Public keys can be used in JavaScript/URLs -2. **Quote Tracking:** Every quote can be attributed to a partner -3. **Widget Support:** Public keys enable iframe/widget integrations -4. **Analytics:** Track which integrations generate most quotes -5. **Security:** Secret keys protect high-value operations -6. **Industry Standard:** Follows Stripe/PayPal pattern -7. **Flexible:** Can use public key alone, or both together -8. **Backward Compatible:** Existing flows still work - -### Use Cases -**Public Key Only:** -- Widget embeds -- Client-side quote generation -- Public integrations -- Tracking and analytics - -**Secret Key Only:** -- Server-to-server with partnerId -- High-security operations -- Backend integrations - -**Both Keys Together:** -- Full partner integration -- Quote tracking + authentication -- Discount application with audit trail - -### Trade-offs -- More complex to manage (2 keys instead of 1) -- Public key exposure acceptable (by design) -- Need to educate partners on proper usage -- Both keys must be managed separately - -### Security Guarantees -- Public key exposure: No security risk (expected behavior) -- Secret key exposure: Complete security breach (must protect) -- Secret keys never appear in logs, responses, or storage (except hash) -- Public keys fully auditable and trackable diff --git a/memory-bank/phases.md b/memory-bank/phases.md deleted file mode 100644 index b2f86438b..000000000 --- a/memory-bank/phases.md +++ /dev/null @@ -1,218 +0,0 @@ -# Ramping Processes Technical Documentation - -## 1. System Architecture - -### 1.1 Component Diagram - -```mermaid -graph TD - F[Frontend] --> A[Signer Service] - A --> Q(Quote Service) - A --> R(Ramp Service) - A --> C(Cleanup Worker) - Q --> DB[(PostgreSQL)] - R --> DB - C --> DB - R --> X[XCM Executor] - R --> N[Nabla AMM] - R --> B[BRLA API] -``` - -## 2. Offramping Process - -### 2.1 Phase Sequence - -```mermaid -sequenceDiagram - participant F as Frontend - participant S as SignerService - participant P as PendulumNode - participant M as MoonbeamNode - - F->>S: InitiateOfframp - S->>S: prepareTransactions - S->>P: createPendulumEphemeralSeed - S->>M: executeMoonbeamToPendulumXCM - S->>S: subsidizePreSwap - S->>N: nablaApprove - S->>N: nablaSwap - S->>S: subsidizePostSwap - S->>M: performBrlaPayoutOnMoonbeam - S->>P: pendulumCleanup -``` - -### 2.2 Phase Specifications - -| Phase | Location | Key Functions | Dependencies | -| --------------------- | ----------------------------- | --------------------------------------------------- | --------------- | -| prepareTransactions | src/phases/signedTransactions | - Generate ephemeral account
- Calculate subsidy | TokenConfig | -| squidRouter | src/phases/squidrouter | - Create XCM payload
- Generate receiver hash | Squid API | -| pendulumFundEphemeral | src/phases/polkadot/ephemeral | - Fund ephemeral account
- XCM transfer | Treasury Pallet | -| performBrlaPayout | src/phases/brla | - Validate KYC status
- Execute BRL.A transfer | BRLA Contracts | - -## 3. Onramping Process - -### 3.1 Phase Sequence - -```mermaid -sequenceDiagram - participant F as Frontend - participant S as SignerService - participant B as BRLA API - participant M as MoonbeamNode - - F->>S: InitiateOnramp - S->>B: brlaTeleport - S->>M: createMoonbeamEphemeralSeed - S->>M: executeMoonbeamToPendulumXCM - S->>S: subsidizePreSwap - S->>N: nablaApprove - S->>N: nablaSwap - S->>P: executePendulumToAssetHubXCM - S->>S: pendulumCleanup -``` - -## 4. Data Model Implementation - -### 4.1 RampState Schema - -```typescript -// src/models/rampState.model.ts -interface RampState { - id: string; - phase: 'preSwap' | 'swapPending' | 'complete'; - network: Networks; - inputToken: string; - outputToken: string; - amountIn: string; - amountOut: string; - subsidy: { - hardLimit: string; - softLimit: string; - consumed: string; - }; - ephemeral: { - pendulumSeed: EncryptedString; - moonbeamSeed?: EncryptedString; - }; - transactions: { - xcmHashes: string[]; - swapHashes: string[]; - }; - createdAt: Date; - updatedAt: Date; -} -``` - -### 4.2 State Transitions - -```typescript -// src/api/services/ramp/ramp.service.ts -async transitionState(state: RampState, nextPhase: RampPhase) { - const validTransitions = { - prepareTransactions: ['squidRouter', 'pendulumFundEphemeral'], - subsidizePreSwap: ['nablaApprove'], - nablaApprove: ['nablaSwap'], - // ... other transitions - }; - - if (!validTransitions[state.phase].includes(nextPhase)) { - throw new InvalidStateTransitionError(); - } - - return this.updateState(state.id, { phase: nextPhase }); -} -``` - -## 5. Subsidy System - -### 5.1 Calculation Algorithm - -```javascript -// From constructBrlaOnrampInitialState -function calculateSubsidy(inputAmount, tokenConfig) { - const HARD_MARGIN = 0.005; // 0.5% - const SOFT_MARGIN = 0.003; // 0.3% - - return { - hardMinimum: inputAmount * (1 - HARD_MARGIN), - softMinimum: inputAmount * (1 - SOFT_MARGIN), - subsidyAmount: inputAmount * SUBSIDY_RATE, - }; -} -``` - -### 5.2 Treasury Interaction - -```typescript -// src/phases/polkadot/ephemeral.ts -async function subsidizePreSwap(state: RampState) { - const api = await getApi(); - const batch = [api.tx.tokens.transfer(state.ephemeralAddress, SUBSIDY_AMOUNT), api.tx.xcm.send(/* XCM params */)]; - - await api.tx.utility.batchAll(batch).signAndSend(treasuryKey); -} -``` - -## 6. Security Implementation - -### 6.1 Ephemeral Account Lifecycle - -1. Generated per transaction using `createPendulumEphemeralSeed()` -2. Secured with AES-256-GCM encryption -3. Storage duration limited to 10 minutes -4. Automatic cleanup via worker process - -### 6.2 Nonce Management - -```typescript -// From offrampingFlow.ts -const nonceSequence = { - nablaApprove: state.nonceBase, - nablaSwap: state.nonceBase + 1, - xcm: state.nonceBase + 2, -}; -``` - -## 7. Failure Recovery - -### 7.1 Cleanup Process - -```typescript -// src/api/workers/cleanup.worker.ts -async function recoverFailedStates() { - const states = await RampState.findAll({ - where: { - status: 'pending', - updatedAt: { - [Op.lt]: new Date(Date.now() - 600000), - }, - }, - }); - - await Promise.all( - states.map(async (state) => { - await refundSubsidy(state); - await state.update({ status: 'failed' }); - }), - ); -} -``` - -## 8. Testing Requirements - -### 8.1 Core Test Cases - -1. XCM execution rollback on chain disconnection -2. Subsidy overflow protection -3. Ephemeral account cleanup validation -4. BRLA KYC integration tests -5. Cross-chain nonce collision tests - -### 8.2 Performance Benchmarks - -| Operation | Target Latency | Success Criteria | -| --------------- | -------------- | ---------------- | -| XCM Transfer | < 2.5s | 95th percentile | -| AMM Swap | < 1.8s | 99% success rate | -| Ephemeral Setup | < 400ms | 100% reliability | diff --git a/memory-bank/productContext.md b/memory-bank/productContext.md deleted file mode 100644 index 832b30694..000000000 --- a/memory-bank/productContext.md +++ /dev/null @@ -1,70 +0,0 @@ -# Product Context: Pendulum Pay (Vortex) - -## Why This Project Exists - -Pendulum Pay (Vortex) exists to bridge the gap between traditional fiat currencies and blockchain-based stablecoins. It -provides a seamless way for users to convert between fiat currencies (EUR, BRL, ARS) and stablecoins (USDC, USDT, BRLA) -across different blockchain networks. - -## Problems It Solves - -1. **Accessibility Barriers**: Traditional financial systems often have limited accessibility, especially in emerging - markets. Vortex provides an alternative on-ramp and off-ramp solution. - -2. **Cross-Chain Complexity**: Moving assets between different blockchain networks is complex. Vortex simplifies this - process by handling the technical details. - -3. **Fiat-to-Crypto Conversion**: Converting between fiat currencies and cryptocurrencies typically requires multiple - steps and platforms. Vortex streamlines this into a single flow. - -4. **Transaction Reliability**: Blockchain transactions can fail due to various reasons. Vortex ensures transactions are - properly executed and provides recovery mechanisms. - -## How It Should Work - -### Offramping Flow - -1. User selects a stablecoin (USDC or USDT) from an EVM chain or USDC from Assethub -2. User specifies the amount to convert and the target fiat currency (EUR, ARS, or BRL) -3. For Brazilian users, bank account details are collected -4. The system generates a quote with the expected conversion rate -5. User approves the transaction -6. The system executes the conversion and delivers the fiat currency to the user - -### Onramping Flow - -1. User starts with BRL in their bank account -2. User specifies the amount to convert and the target token -3. User provides their wallet address and selects the target network (EVM chain or Assethub) -4. The system generates a quote with the expected conversion rate -5. User approves the transaction -6. The system converts the fiat BRL to BRLA stablecoin and then to the target token -7. The system sends the tokens to the specified wallet address - -## User Experience Goals - -1. **Simplicity**: Users should be able to complete transactions without understanding the underlying blockchain - technology. - -2. **Transparency**: Users should have clear visibility into exchange rates, fees, and transaction status. - -3. **Reliability**: The system should handle errors gracefully and provide clear feedback on transaction status. - -4. **Security**: User funds should be secure throughout the process, with no private keys stored on the backend. - -5. **Efficiency**: Transactions should be processed quickly, with minimal waiting time for users. - - -## Frontend Application Details (Vortex) - -Based on the codebase analysis, the frontend application, named Vortex, is built using: -- **Framework/Library:** React v19 -- **Build Tool:** Vite v6 -- **Language:** TypeScript v5.7 -- **Styling:** Tailwind CSS v4, DaisyUI v5, custom CSS -- **State Management:** Zustand v5 -- **Key Functionality:** Provides a user interface for stablecoin (USDC, USDT) off-ramping from various EVM chains (Polygon, Ethereum, BSC, Arbitrum, Base, Avalanche) and Polkadot AssetHub to fiat currencies (EUR via SEPA, ARS via Stellar/Anclap, BRL via Moonbeam/BRLA). -- **Blockchain Integration:** Uses Wagmi/Viem for EVM interactions, @polkadot/api and @talismn/connect-wallets for Polkadot, stellar-sdk for Stellar, and @reown/appkit for wallet connections. -- **Features:** Includes components for swapping, network selection, multi-wallet connection (EVM & Polkadot), fee display/comparison, KYC forms (specifically for BRLA), transaction status updates, and error reporting via Sentry. - -[2025-04-04 16:45:11] - Added frontend application details based on codebase analysis. diff --git a/memory-bank/progress.md b/memory-bank/progress.md deleted file mode 100644 index ed8ed0f35..000000000 --- a/memory-bank/progress.md +++ /dev/null @@ -1,168 +0,0 @@ -# Progress: Pendulum Pay Backend Migration - -## What Works - -1. **Database Integration** - - - ✅ PostgreSQL connection configuration - - ✅ Database models (QuoteTicket, RampState, IdempotencyKey, Partner, ApiKey) - - ✅ Database migrations - - ✅ Model associations (Partner ↔ ApiKey) - -2. **API Endpoints** - - - ✅ Quote creation and retrieval - - ✅ Ramping process initiation - - ✅ Status polling - - ✅ Phase and state updates - - ✅ Admin API key management endpoints - -3. **Service Layer** - - - ✅ Base service with common functionality - - ✅ Quote service for quote generation - - ✅ Ramp service for ramping process management - - ✅ Transaction validation - -4. **Background Processing** - - ✅ Cleanup worker for expired quotes - - ✅ Cleanup worker for expired idempotency keys - -5. **Authentication & Security** - - ✅ API key authentication system - - ✅ Partner discount protection - - ✅ Bcrypt-based key hashing - - ✅ Optional authentication middleware - - ✅ Partner-payload validation - - ✅ Backward compatibility maintained - - ✅ Admin endpoint protection (Bearer token) - - ✅ Constant-time comparison for security - -## What's Left to Build - -1. **Frontend Integration** - - - ❌ Modify transaction signing process - - ❌ Implement status polling - -2. **Testing** - - - ❌ Unit tests for services - - ❌ Integration tests for API endpoints - - ❌ End-to-end tests for ramping flows - -3. **Deployment** - - - ❌ Set up PostgreSQL in production - - ❌ Deploy updated backend - - ❌ Monitor system - -4. **Documentation** - - ❌ Update API documentation - - ❌ Create developer guides - - ❌ Document database schema - -## Known Issues - -1. **Quote Calculation** - - - The current quote calculation is a placeholder and needs to be replaced with actual exchange rate logic - - We need to integrate with external price oracles for accurate quotes - -2. **Transaction Validation** - - - The transaction validation logic needs to be enhanced to verify that the transactions match the expected parameters - - We need to add more robust error handling for invalid transactions - -3. **Error Handling** - - - The error handling in the API endpoints could be improved - - We need to add more detailed error messages and logging - -4. **Performance** - - - The performance of the database queries has not been optimized - - We may need to add indexes for frequently accessed fields - -5. **Security** - - ✅ API key authentication implemented for partner discounts - - ❌ Rate limiting for API endpoints still needed - - -## Frontend Progress (Vortex - Based on Codebase Analysis) - -**Completed Components/Features:** -- ✅ Core application setup (`main.tsx`, `app.tsx`, `index.html`) -- ✅ Basic layout (`Navbar`, `Footer`, `BaseLayout`) -- ✅ Swap page UI (`SwapPage`, `Swap` component, `AssetNumericInput`, `FeeCollapse`) -- ✅ Landing page sections (`PitchSection`, `TrustedBy`, `WhyVortex`, `HowToSell`, `PopularTokens`, `FAQAccordion`, `GotQuestions`) -- ✅ Network selection UI (`NetworkSelector`, `NetworkIcon`) -- ✅ Wallet connection UI (`ConnectWalletButton`, EVM/Polkadot variants, `PolkadotWalletSelectorDialog`) -- ✅ Context providers for core services (Network, Wallets, Events, SIWE, Polkadot Nodes) -- ✅ Zustand stores for form, offramp, SEP-24, Safe wallet state -- ✅ Basic UI components (`Dialog`, `Button`, `Input`, `Accordion`, `Spinner`, etc.) -- ✅ BRLA KYC form components (`BrlaComponents`) -- ✅ User rating component (`Rating`) -- ✅ Sentry and Google Tag Manager integration -- ✅ Helper functions for formatting, calculations, storage, etc. -- ✅ Basic contract ABIs included (`ERC20`, `ERC20Wrapper`, `Router`, `SquidReceiver`) - -**Inferred Pending/In-Progress:** -- ⏳ Full integration of swap logic with backend API (Quote, Ramp Start, Status) -- ⏳ Complete implementation of transaction signing and submission flow for all ramp types -- ⏳ Robust handling of backend state machine updates and transitions in the UI -- ⏳ Comprehensive error display and handling for API/blockchain issues -- ⏳ End-to-end testing for all supported ramp flows -- ⏳ Finalization of BRLA KYC flow integration and testing -- ⏳ Potential refinement of state management interactions between Zustand and Context - -[2025-04-04 16:49:09] - Added frontend progress summary based on codebase analysis. - -[2025-10-29 09:03:00] - Implemented API key authentication system (Phases 1-4): - - Created api_keys database table and migration - - Implemented ApiKey model with Partner associations - - Built authentication middleware (apiKeyAuth, enforcePartnerAuth) - - Created admin endpoints for API key management - - Integrated authentication into quote routes - - Added bcrypt dependency for secure key hashing - - System maintains backward compatibility while securing partner discounts - -[2025-10-29 09:17:00] - Enhanced API key system with environment-based prefixes: - - Added admin authentication with Bearer token (ADMIN_SECRET) - - Implemented constant-time comparison for security - - Added environment-based key generation (test vs live prefixes) - - Keys automatically use 'vrtx_test_' prefix in sandbox (SANDBOX_ENABLED=true) - - Keys automatically use 'vrtx_live_' prefix in production (SANDBOX_ENABLED=false) - - Updated .env.example with documentation - -[2025-10-29 09:50:00] - Changed API key association from partner ID to partner name: - - Modified database schema: partner_id (UUID) → partner_name (VARCHAR) - - Removed foreign key constraint (now manual lookup) - - Updated ApiKey model to use partnerName field - - Changed admin routes from :partnerId to :partnerName - - Modified controllers to find all partners by name - - Updated authentication helper to lookup partners by name - - Single API key now works for all partner records with same name (BUY & SELL) - - Response includes partnerCount showing affected records - - Simplified key management for multi-configuration partners - -[2025-10-29 10:08:00] - Fixed middleware to validate partner names instead of IDs: - - Updated apiKeyAuth middleware to lookup partner by ID and compare names - - Updated enforcePartnerAuth middleware to use name comparison - - Both middlewares now async to perform partner lookup - - Validation: partnerId (UUID) → lookup Partner → compare name with API key's partner name - - Error messages now show partner names instead of IDs for clarity - - Added PARTNER_NOT_FOUND error when partnerId doesn't exist - -[2025-10-29 16:12:00] - Implemented dual-key architecture (public/secret keys): - - Refactored from single vrtx_* keys to dual pk_*/sk_* system - - Public keys (pk_live_*, pk_test_*): Plaintext storage, client-side safe, tracking only - - Secret keys (sk_live_*, sk_test_*): Bcrypt hashed, server-only, authentication - - Updated database schema with key_type, key_value fields - - Added api_key field to quote_tickets for tracking - - Admin creates both keys as a pair - - Public key middleware validates existence and stores on quotes - - Secret key middleware authenticates and applies discounts - - Supports flexible usage: public only, secret only, or both together - - Enables widget/iframe integrations with public keys - - Maintains security for partner discounts with secret keys diff --git a/memory-bank/projectbrief.md b/memory-bank/projectbrief.md deleted file mode 100644 index fcbecbeba..000000000 --- a/memory-bank/projectbrief.md +++ /dev/null @@ -1,33 +0,0 @@ -# Project Brief: Pendulum Pay Backend Migration - -## Core Requirements - -Pendulum Pay (Vortex) is a decentralized application (dapp) that enables users to on-ramp and off-ramp stablecoins to -different countries. The project requires migrating the ramping logic from the frontend to the backend to improve -resilience and maintainability. - -## Goals - -1. **Unify Ramping Logic**: Move all on-ramping and off-ramping logic from the frontend to the backend (signer-service). -2. **Implement State Persistence**: Create a PostgreSQL database to store ramping state information. -3. **Create Resilient Flows**: Design the backend to handle crashes and restarts without losing state. -4. **Support Presigned Transactions**: Allow the frontend to provide presigned transactions for the backend to execute. -5. **Provide Status Polling**: Create endpoints for the frontend to check the status of ramping processes. - -## Scope - -### In Scope - -- Database schema design and implementation -- State machine for ramping flows -- API endpoints for quotes and ramping -- Transaction validation and execution -- Error handling and recovery mechanisms -- Background workers for cleanup tasks - -### Out of Scope - -- Frontend implementation changes -- User authentication and authorization -- Payment provider integrations -- Blockchain node infrastructure diff --git a/memory-bank/sharedModulePlan.md b/memory-bank/sharedModulePlan.md deleted file mode 100644 index 0c116c263..000000000 --- a/memory-bank/sharedModulePlan.md +++ /dev/null @@ -1,55 +0,0 @@ -# Shared Module Integration Plan - -## Problem Analysis -- Frontend (Vite/TypeScript) and signer-service (Bun/TypeScript) couldn't resolve shared code -- Original setup used direct TS imports without proper build process -- Mixed package managers (Yarn + Bun) caused resolution issues - -## Implemented Solutions -1. **Shared Module Configuration** - - Added dual TS configs for ESM/CJS outputs - - Configured package.json exports: - ```json - "main": "./dist/cjs/index.js", - "module": "./dist/esm/index.js", - "types": "./dist/types/index.d.ts" - ``` - - Added build scripts: - ```json - "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json", - "prepack": "yarn build" - ``` - -2. **Consumer Setup** - - Frontend (Yarn): - ```bash - yarn add ../shared - ``` - - Signer-Service (Bun): - ```bash - bun add ../shared - ``` - -3. **Validation Steps** - ```bash - # Build shared module - cd shared && yarn build - - # Test frontend resolution - cd ../frontend && yarn dev - - # Test Bun resolution - cd ../signer-service && bun run -c "import { parseCurrency } from '@vortexfi/shared'" - - -# Resolved Items -✓ Frontend path aliases updated in vite.config.ts -✓ Signer-service TS config paths configured -✓ Shared module build process validated -✓ Cross-package manager imports working - -# Next Steps -- Update frontend's vite.config.ts aliases -- Configure signer-service's tsconfig.json paths -- Test production builds for both projects -- Document dependency management strategy diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md deleted file mode 100644 index 38ae2ec0e..000000000 --- a/memory-bank/systemPatterns.md +++ /dev/null @@ -1,137 +0,0 @@ -# System Patterns: Pendulum Pay Backend - -## Core Architecture - -```mermaid -graph TD - F[Frontend] -->|API Calls| B[Backend] - B -->|State Management| DB[(PostgreSQL)] - B -->|Blockchain| EVM[EVM Chains] - B -->|XCM| Substrate[Substrate Chains] - B -->|Stellar SDK| Stellar - B -->|Background Jobs| W[Worker] -``` - -## State Machine Implementation - -### Ramp Process Flow -```mermaid -stateDiagram-v2 - [*] --> Initiated - Initiated --> Quoted: Generate quote - Quoted --> Preparing: Start ramp - Preparing --> Funding: Create ephemeral account - Funding --> Swapping: Execute cross-chain swap - Swapping --> Completing: Finalize transfer - Completing --> [*]: Cleanup - state ErrorState { - [*] --> Error - Error --> [*] - } - Initiated --> ErrorState: Validation failed - Quoted --> ErrorState: Quote expired - Preparing --> ErrorState: Funding failed - Funding --> ErrorState: XCM failed - Swapping --> ErrorState: Swap failed -``` - -## Data Model - -### RampState Schema -```typescript -interface RampState { - id: string; - type: 'onramp' | 'offramp'; - phase: 'init' | 'quoted' | 'executing' | 'completed'; - network: string; - amountIn: string; - amountOut: string; - transactions: { - fundingTx?: string; - swapTx?: string; - completionTx?: string; - }; - createdAt: Date; - updatedAt: Date; -} -``` - -## Security Patterns - -1. **Pre-signed Transactions**: -```mermaid -sequenceDiagram - Frontend->>Backend: Initiate with signed payload - Backend->>Backend: Validate signature - Backend->>Blockchain: Submit pre-signed tx - Blockchain-->>Backend: Transaction receipt - Backend->>Frontend: Status update -``` - -2. **Idempotency Flow**: -```mermaid -sequenceDiagram - Client->>Backend: Request (with idempotency key) - Backend->>DB: Check key existence - alt Key exists - Backend-->>Client: Return cached response - else - Backend->>DB: Store new key - Backend->>Processing: Execute request - Backend->>DB: Store result - Backend-->>Client: Return result - end -``` - -## Cross-Chain Execution - -```mermaid -sequenceDiagram - Participant F as Frontend - Participant B as Backend - Participant P as Pendulum - Participant M as Moonbeam - - F->>B: Initiate cross-chain swap - B->>P: Create ephemeral account - P-->>B: Account details - B->>M: Lock source assets - M-->>B: Lock confirmation - B->>P: Execute XCM transfer - P-->>B: Transfer proof - B->>F: Completion status - - -## Frontend Architecture Patterns (Vortex) - -```mermaid -graph TD - App -->|Renders| SwapPage - SwapPage -->|Uses| SwapComponent - SwapPage -->|Uses| LandingSections[Landing Page Sections] - SwapComponent -->|Uses| FormProvider[React Hook Form] - SwapComponent -->|Uses| ZustandStores[Zustand Stores] - SwapComponent -->|Uses| ContextProviders[Context Providers] - ContextProviders -->|Provide| NetworkContext - ContextProviders -->|Provide| WalletContext[Wallet Contexts] - ContextProviders -->|Provide| EventsContext - ContextProviders -->|Provide| SiweContext - ContextProviders -->|Provide| PolkadotNodeContext - WalletContext -->|Connects| EVMWallets[EVM Wallets (Wagmi/AppKit)] - WalletContext -->|Connects| PolkadotWallets[Polkadot Wallets (Talisman)] - SwapComponent -->|Interacts| BackendAPI[Backend API (via Services)] - LandingSections -->|Display| StaticContent[Static Content & Marketing] -``` - -**Key Patterns:** - -1. **Component-Based UI:** React components organized into pages (`pages/`), sections (`sections/`), and reusable UI elements (`components/`). -2. **Context API for Global State/Services:** Extensive use of React Context (`contexts/`) to provide access to network state, wallet connections, event tracking, SIWE signing, and Polkadot node connections throughout the application. -3. **Zustand for Localized State:** Zustand stores (`stores/`) are used for managing specific feature states like the swap form (`formStore`), offramp process (`offrampStore`), SEP-24 flow (`sep24Store`), and Safe wallet signatures (`safeWalletSignaturesStore`). -4. **Custom Hooks for Logic Abstraction:** Custom hooks (`hooks/`) encapsulate logic for interacting with wallets (`useVortexAccount`), fetching balances (`useOnchainTokenBalance`), signing challenges (`useSignChallenge`), managing network/asset icons (`useGetNetworkIcon`, `useGetAssetIcon`), and handling specific flows like BRLA KYC (`hooks/brla/`). -5. **Service Layer for API Interaction:** Services (`services/`) abstract backend communication (e.g., `services/api/`, `services/backend.ts`, `services/signingService.tsx`). -6. **Type Safety:** TypeScript is used throughout for strong typing. -7. **Modular Styling:** Tailwind CSS and DaisyUI provide utility-based styling, supplemented by custom CSS (`App.css`). -8. **Asynchronous Operations:** TanStack Query is used for managing data fetching and caching. - -[2025-04-04 16:48:11] - Added frontend architecture patterns based on codebase analysis. diff --git a/memory-bank/techContext.md b/memory-bank/techContext.md deleted file mode 100644 index 2b5030143..000000000 --- a/memory-bank/techContext.md +++ /dev/null @@ -1,90 +0,0 @@ -# Technical Context: Pendulum Pay Backend - -## Technologies Used - -### Backend Framework -- **Node.js** (v16+) - JavaScript runtime -- **Express** - Web framework for API endpoints -- **TypeScript** - Type-safe development - -### Database & State Management -- **PostgreSQL** - Primary database for state persistence -- **Sequelize ORM** - Database interactions and modeling -- **Umzug** - Database migration management -- **In-memory caching** - For quote and idempotency key tracking - -### Blockchain Integration -- **viem** - EVM chain interaction (Ethereum, Polygon, BSC, etc.) -- **@polkadot/api** - Substrate chains (Pendulum, AssetHub) -- **stellar-sdk** - Stellar network integration -- **@noble/curves** - Cryptographic primitives -- **web3.js** - Legacy Ethereum interactions - -### Shared Utilities -- **Network configuration** - Unified network definitions (EVM/Substrate/Stellar) -- **Token management** - Cross-chain token configurations -- **Decimal handling** - Precision management for financial operations -- **BigNumber** - Arbitrary-precision decimal arithmetic - -### Security -- **Joi** - Request validation -- **Encrypted storage** - Sensitive data protection -- **Rate limiting** - API endpoint protection - -## Key Architectural Components - -### Core Services -- **Quote Service** - Manages FX rates and validity windows -- **Ramp Service** - Coordinates cross-chain transaction flows -- **Idempotency Service** - Ensures operation safety - -### Cross-Chain Infrastructure -- **XCM Handlers** - Cross-consensus messaging -- **Bridge Contracts** - Asset transfer coordination -- **Subsidy Management** - Transaction cost optimization - -### Monitoring & Reliability -- **Winston** - Structured logging -- **Slack Integration** - Real-time alerts -- **Transaction Recovery** - Failed operation handling - -## Development Setup - -```bash -# Install dependencies -yarn install - -# Run migrations -yarn migrate - -# Start development -yarn dev - -# Production build -yarn build && yarn start -``` - -## Key Constraints -- **No key storage** - All transactions pre-signed by frontend -- **10-minute quotes** - Price validity windows -- **Multi-chain sync** - Coordinated nonce management - - -### Frontend Technologies (Vortex) - -- **Framework/Library:** React v19 -- **Build Tool:** Vite v6 -- **Language:** TypeScript v5.7 -- **Styling:** Tailwind CSS v4, DaisyUI v5, Motion (animations), custom CSS -- **State Management:** Zustand v5 -- **Form Handling:** React Hook Form v7, Yup v1.4 -- **Data Fetching:** TanStack Query v5 -- **Wallet Connection:** Wagmi v2, @reown/appkit v1.6, @polkadot/extension-dapp v0.53, @talismn/connect-wallets v1.2, WalletConnect v2, @safe-global/api-kit v2.5 -- **Blockchain Libraries:** Viem v2, Web3.js v4, @polkadot/api v13, @pendulum-chain/api v1.1, Stellar SDK v13 -- **Utilities:** Big.js, BN.js, Buffer (via polyfill) -- **Linting/Formatting:** ESLint, Prettier, Husky, lint-staged -- **Testing:** Vitest v3, Happy DOM -- **Error Reporting:** Sentry -- **Analytics:** Google Tag Manager - -[2025-04-04 16:46:40] - Added frontend technology stack based on codebase analysis. diff --git a/packages/kyc/CLAUDE.md b/packages/kyc/CLAUDE.md new file mode 100644 index 000000000..a76c252d9 --- /dev/null +++ b/packages/kyc/CLAUDE.md @@ -0,0 +1,28 @@ +# packages/kyc — shared provider KYC/KYB machines + +Headless XState v5 machines and provider-neutral contracts used by both the widget and +dashboard. App-specific API calls, browser redirects, and UI rendering are injected at the +application boundary. + +## Conventions + +- Use `setup({ ... }).createMachine(...)`. +- Keep provider workflow state here when both apps need it; keep forms and navigation in + the consuming app. +- Normalize provider responses at the boundary rather than leaking provider-specific + status vocabularies into consumers. +- Update both app bindings and tests when a machine contract changes. + +## Commands (from `packages/kyc/`) + +```bash +bun test +bun typecheck +``` + +## Documentation + +Follow [`docs/README.md`](../../docs/README.md). Current identity architecture belongs in +`docs/architecture-identity-model.md`, product behavior in the relevant product spec, and +provider security requirements in `docs/security-spec/05-integrations/`. Do not create +provider implementation plans or progress files in this package. diff --git a/packages/sdk/.env.example b/packages/sdk/.env.example index b910d5ac1..86a0a7f60 100644 --- a/packages/sdk/.env.example +++ b/packages/sdk/.env.example @@ -8,7 +8,8 @@ # ── 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) +# Unified credential from `bun run scripts/login-and-create-api-key.ts` (see .api-key.json). (required) +# Public-only reads can omit VORTEX_SECRET_KEY; authenticated ramp operations require it. # For Alfredpay (MXN) flows this MUST be a user-linked sk_* key, not a partner-only key. VORTEX_PUBLIC_KEY= VORTEX_SECRET_KEY= diff --git a/packages/sdk/ARCHITECTURE.md b/packages/sdk/ARCHITECTURE.md index c357f3817..9895fe233 100644 --- a/packages/sdk/ARCHITECTURE.md +++ b/packages/sdk/ARCHITECTURE.md @@ -1,56 +1,53 @@ - -## Overview - -The Vortex SDK abstracts Vortex's API and ephemeral key handling into a self-contained package. It provides a clean interface for creating quotes, registering ramps, and managing the signing process for cross-chain transactions. - -## Core Components - -### VortexSdk (Main Orchestrator) - -The `VortexSdk` class is the main entry point that users interact with. It bundles together: - -- **ApiService**: Handles all backend API interactions -- **NetworkManager**: Manages RPC connections for transaction signing -- **RampHandlers**: Business logic for different ramp types (e.g., BrlaHandler) - -Key responsibilities: -- Automatic initialization of network connections -- Ephemeral key generation for multiple networks -- Transaction signing coordination -- API request orchestration - -### Ramp Handler Pattern - -Any class that implements `RampHandler` abstracts the business logic required to start a ramp. The current implementation includes: - -- **BrlaHandler**: Handles Brazilian Real (BRLA) onramp operations - -#### Ramp Flow - -From the user's perspective, ramp operations follow a consistent pattern: - -1. **Register**: Mandatory call to register a ramp with the backend -2. **Update**: Optional intermediate steps (if transaction hashes are needed) -3. **Start**: Mandatory call to initiate the actual ramp process - -The `update` call to create pre-signed transactions happens automatically in the background and does not require user interaction. This logic must be implemented by the `Handler` for the specific flow. - -### Service Layer - -#### ApiService -- Provides abstraction for all backend interactions -- Handles error parsing and transformation -- Manages HTTP requests and responses - -#### NetworkManager -- Handles configuration and connection with RPC nodes -- Required for signing pre-signed transactions -- Manages WebSocket connections to blockchain networks - -## Stateless Design - -- No ramp state is stored in memory -- Users are responsible for persisting ramp IDs and managing state -- Each operation is independent and can be called without prior context -- Ephemeral keys are generated on-demand and passed explicitly to signing operations -- Ephemeral keys are essentially "discarded" after the ramp is registered +# SDK Architecture + +`@vortexfi/sdk` is a stateless integration layer over the Vortex API. It owns ephemeral +account generation, presigning of platform-owned transactions, classification of +user-wallet transactions, and typed API error handling. It does not own long-lived ramp +state or a user's wallet. + +## Main components + +- `VortexSdk.ts` is the public orchestrator. +- `services/ApiService.ts` owns HTTP requests and error mapping. +- `services/NetworkManager.ts` owns the RPC connections needed for ephemeral signing and + preflight balance checks. +- `handlers/BrlHandler.ts`, `AlfredpayHandler.ts`, and `MykoboHandler.ts` adapt + corridor-specific registration and update data to the common lifecycle. +- `eip712.ts` classifies and attaches signatures for user-owned typed-data operations. +- `storage.ts` optionally persists ephemeral recovery material for the caller. + +## Lifecycle + +```text +createQuote + -> registerRamp + -> submitUserTransactions or updateRamp (SELL flows when required) + -> startRamp + -> getRampStatus +``` + +Quotes are eligible for anonymous rate discovery. Registration requires a user-linked +secret key because provider identity is resolved server-side for that user. The SDK does +not mint keys or complete KYC/KYB. + +`registerRamp` returns user-owned transactions separately from ephemeral-owned +transactions. The SDK signs only the ephemeral-owned set. For user-owned entries, the +integrator supplies wallet callbacks to `submitUserTransactions`, or handles each entry +through `getUserTransactionType`, `getTypedDataToSign`, and +`getTransactionToBroadcast`. + +## State and custody + +- The SDK never receives a connected wallet object or its private key. +- Ephemeral accounts are generated per registration and are required for recovery until + the ramp's recovery window ends. +- Ramp IDs and business correlation state belong to the integrating application. +- `storeEphemeralKeys` defaults to enabled for Node-based recovery; applications with + their own secure storage may disable it and persist the material themselves. + +## Package boundary + +The SDK is published as a Node.js ESM package from `dist/index.js`, with declarations in +`dist/index.d.ts`. `bun test` runs unit tests, builds the package, and smoke-loads the +output. The public API and examples belong in [`README.md`](README.md); partner-facing +integration guides belong in [`docs/api/`](../../docs/api/README.md). diff --git a/packages/sdk/CHANGELOG_DUAL_BUILD.md b/packages/sdk/CHANGELOG_DUAL_BUILD.md deleted file mode 100644 index e195f6395..000000000 --- a/packages/sdk/CHANGELOG_DUAL_BUILD.md +++ /dev/null @@ -1,124 +0,0 @@ -# Dual Build Implementation (v0.4.0) - -## Summary - -The Vortex SDK now publishes with **both ESM and CommonJS** formats, making it compatible with: -- ✅ Modern JavaScript/TypeScript projects (ESM) -- ✅ PythonMonkey and other CommonJS-only environments -- ✅ All bundlers and build tools -- ✅ Node.js (all versions) - -## Changes Made - -### 1. Package Structure -``` -dist/ -├── cjs/ # CommonJS build -│ ├── index.js -│ └── package.json # {"type": "commonjs"} -├── esm/ # ESM build -│ └── index.js -└── types/ # TypeScript definitions - └── index.d.ts -``` - -### 2. Updated Files - -#### `package.json` -- **main**: Points to CJS (`./dist/cjs/index.js`) -- **module**: Points to ESM (`./dist/esm/index.js`) -- **types**: Points to types (`./dist/types/index.d.ts`) -- **exports**: Conditional exports for modern resolution -- **build**: Now builds both formats - -#### `tsconfig.json` -- **declarationDir**: Changed to `./dist/types` -- **target**: Changed to ES2020 for better compatibility -- **lib**: Simplified to ES2020 - -#### `scripts/post-build.js` (NEW) -- Creates `package.json` in `dist/cjs/` to mark it as CommonJS -- Runs automatically after build - -### 3. Build Scripts - -```bash -# Full build (runs all) -bun run build - -# Individual builds -bun run build:cjs # CommonJS -bun run build:esm # ESM -bun run build:types # TypeScript definitions -``` - -### 4. Testing - -```bash -# Test both formats -bun run test - -# Or manually: -node -e "const sdk = require('@vortexfi/sdk'); console.log('CJS:', sdk);" -node --input-type=module -e "import * as sdk from '@vortexfi/sdk'; console.log('ESM:', sdk);" -``` - -## Usage - -### JavaScript/TypeScript (ESM) -```javascript -import { VortexSdk } from '@vortexfi/sdk'; -``` - -### JavaScript/TypeScript (CommonJS) -```javascript -const { VortexSdk } = require('@vortexfi/sdk'); -``` - -### PythonMonkey -```python -import pythonmonkey as pm -sdk = pm.require('@vortexfi/sdk') # Automatically uses CJS -``` - -## Impact on Existing Projects - -### ✅ No Breaking Changes -- ESM imports continue to work -- Tree-shaking still available for modern bundlers -- TypeScript types unchanged -- API surface unchanged - -### 📦 Package Size -- Slight increase (~50KB) due to dual format -- Users only download what they use (via package manager deduplication) - -## Publishing - -```bash -# Build and publish -bun run build -npm publish -``` - -The npm package will include both formats, and Node.js/bundlers will automatically select the appropriate one based on the import method. - -## Rollback Plan - -If issues arise, revert to ESM-only by: -1. Remove dual build scripts -2. Restore original package.json exports -3. Use single build output - -## Benefits - -1. **Universal Compatibility**: Works with all JavaScript environments -2. **No Breaking Changes**: Existing users unaffected -3. **Modern Optimization**: ESM still available for tree-shaking -4. **Python Integration**: Enables PythonMonkey wrapper -5. **Future-Proof**: Ready for all ecosystems - -## Version History - -- **v0.3.9**: ESM only -- **v0.4.0**: Dual build (ESM + CommonJS) diff --git a/packages/sdk/CLAUDE.md b/packages/sdk/CLAUDE.md index 94dca9406..a21fe1a48 100644 --- a/packages/sdk/CLAUDE.md +++ b/packages/sdk/CLAUDE.md @@ -10,8 +10,8 @@ treat its API surface as a stable contract — breaking changes ripple to integr - **`test` also builds and smoke-loads the dist**: `bun test` runs the suite, then `bun run build`, then `node -e "require('./dist/index.js')"`. A green `bun test` means the built bundle imports cleanly too. -- **Dual build**: see `ARCHITECTURE.md`, `DUAL_BUILD_GUIDE.md`, and - `CHANGELOG_DUAL_BUILD.md` before touching the build config. +- **Package architecture**: read `ARCHITECTURE.md` before changing lifecycle, custody, or + build boundaries. ## Commands (from `packages/sdk/`) @@ -28,3 +28,10 @@ Partner-facing usage patterns (quotes, on/off-ramp flows, webhooks, auth, error recovery) are documented in the **`vortex-integration`** skill (`.agents/skills/vortex-integration/SKILL.md`). Keep that skill in sync when the SDK's public surface changes. + +## Documentation + +Follow [`docs/README.md`](../../docs/README.md). Keep public SDK usage in `README.md`, +internal boundaries in `ARCHITECTURE.md`, partner guides in `docs/api/`, and durable +security requirements in `docs/security-spec/`. Do not create build-change journals or +completed implementation guides. diff --git a/packages/sdk/DUAL_BUILD_GUIDE.md b/packages/sdk/DUAL_BUILD_GUIDE.md deleted file mode 100644 index a1d78ac9d..000000000 --- a/packages/sdk/DUAL_BUILD_GUIDE.md +++ /dev/null @@ -1,219 +0,0 @@ -# Dual Build Configuration (ESM + CommonJS) - -This guide shows how to configure the SDK to publish both ESM and CommonJS formats for maximum compatibility. - -## Option 1: Dual Package (Recommended) - -Build both formats and use conditional exports: - -### 1. Update package.json - -```json -{ - "name": "@vortexfi/sdk", - "version": "0.3.9", - "type": "module", - "main": "./dist/cjs/index.js", - "module": "./dist/esm/index.js", - "types": "./dist/types/index.d.ts", - "exports": { - ".": { - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js", - "types": "./dist/types/index.d.ts" - } - }, - "files": [ - "dist", - "README.md" - ], - "scripts": { - "build": "npm run build:cjs && npm run build:esm && npm run build:types", - "build:cjs": "bun build ./src/index.ts --outdir ./dist/cjs --target=node --format=cjs --external=@polkadot/api --external=stellar-sdk --external=viem", - "build:esm": "bun build ./src/index.ts --outdir ./dist/esm --target=node --format=esm --external=@polkadot/api --external=stellar-sdk --external=viem", - "build:types": "tsc -p tsconfig.json", - "clean": "rm -rf ./dist" - } -} -``` - -### 2. Update tsconfig.json - -```json -{ - "compilerOptions": { - "declaration": true, - "declarationDir": "./dist/types", - "emitDeclarationOnly": true, - "module": "ESNext", - "target": "ES2020", - "lib": ["ES2020"], - "moduleResolution": "node", - "strict": true, - "skipLibCheck": true - }, - "include": ["./src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} -``` - -### 3. Create package.json for CommonJS output - -Create `dist/cjs/package.json`: -```json -{ - "type": "commonjs" -} -``` - -This can be done in the build script or manually. - -## Option 2: CommonJS Only (Simpler, Works with PythonMonkey) - -### 1. Update package.json - -```json -{ - "name": "@vortexfi/sdk", - "type": "commonjs", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "build": "rm -rf ./dist && npm run build:cjs && npm run build:types", - "build:cjs": "bun build ./src/index.ts --outdir ./dist --target=node --format=cjs --external=@polkadot/api --external=stellar-sdk --external=viem", - "build:types": "tsc -p tsconfig.json" - } -} -``` - -### 2. Update tsconfig.json - -```json -{ - "compilerOptions": { - "module": "CommonJS", - "target": "ES2020", - "declaration": true, - "declarationDir": "./dist", - "emitDeclarationOnly": true, - "moduleResolution": "node", - "strict": true, - "skipLibCheck": true - } -} -``` - -## Implications - -### ✅ Pros of Dual Build (Option 1) - -1. **Maximum Compatibility**: Works with ESM, CommonJS, and PythonMonkey -2. **No Breaking Changes**: Existing users continue to work -3. **Modern & Legacy**: Tree-shaking for modern bundlers, compatibility for older tools -4. **Future-Proof**: Ready for when PythonMonkey adds ESM support - -### ✅ Pros of CommonJS Only (Option 2) - -1. **Simpler Build**: Single output format -2. **Universal Compatibility**: Works everywhere (Node, PythonMonkey, bundlers) -3. **Smaller Package**: No duplicate code -4. **Easier Debugging**: Single source of truth - -### ⚠️ Cons of CommonJS Only - -1. **No Tree-Shaking**: Modern bundlers can't optimize unused code -2. **Larger Bundles**: Users importing one function get entire SDK -3. **Breaking Change**: Existing ESM users need to update imports -4. **Less Modern**: ESM is the future of JavaScript - -### 📊 Impact on Your Project - -**Current SDK users (TypeScript/JavaScript):** -- **Dual build**: No impact, seamless upgrade ✅ -- **CommonJS only**: May need import syntax changes ⚠️ - -**PythonMonkey wrapper:** -- **Dual build**: Works with `require()` path ✅ -- **CommonJS only**: Works perfectly ✅ - -**Bundle sizes:** -- **Dual build**: ~2x disk space (both formats) -- **CommonJS only**: Smaller npm package - -**Other Python wrappers:** -- Node-based tools generally prefer CommonJS -- Makes SDK more accessible to non-JS environments - -## Recommendation - -**Use Option 1 (Dual Build)** because: - -1. ✅ No breaking changes for existing users -2. ✅ Python wrapper works via CommonJS export -3. ✅ Modern bundlers get ESM for optimization -4. ✅ Future-compatible with all ecosystems -5. ✅ Minimal (~50KB) size increase - -## Migration Path - -### For Existing Users - -```javascript -// ESM (still works) -import { VortexSdk } from '@vortexfi/sdk'; - -// CommonJS (now also works) -const { VortexSdk } = require('@vortexfi/sdk'); -``` - -### For PythonMonkey - -```python -# Now works with published npm package! -npm install @vortexfi/sdk -pm.require('@vortexfi/sdk') # Uses CommonJS export -``` - -## Testing - -After implementing, test both formats: - -```bash -# Test CommonJS -node -e "const sdk = require('@vortexfi/sdk'); console.log(sdk)" - -# Test ESM -node --input-type=module -e "import * as sdk from '@vortexfi/sdk'; console.log(sdk)" - -# Test types -tsc --noEmit test.ts -``` - -## Build Script Enhancement - -Add to `package.json`: - -```json -{ - "scripts": { - "postbuild": "node scripts/post-build.js" - } -} -``` - -Create `scripts/post-build.js`: -```javascript -import fs from 'fs'; -import path from 'path'; - -// Add package.json to CommonJS output -const cjsPackageJson = { - type: 'commonjs' -}; - -fs.writeFileSync( - path.join(process.cwd(), 'dist/cjs/package.json'), - JSON.stringify(cjsPackageJson, null, 2) -); - -console.log('✓ Post-build complete: CommonJS package.json created'); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 174aaae8b..d45b3c68b 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -83,7 +83,9 @@ 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. +Quotes can be requested without any key (anonymous rate discovery). Registering through the SDK requires the configured `secretKey` to resolve to an onboarded user. This can be a user-scoped key or a partner key delegated to the user; a `publicKey` or partner-only secret key is insufficient. The SDK does not accept Supabase Bearer tokens. The same user must have completed Alfredpay KYC for the country, so registration resolves to that user's Alfredpay customer automatically. + +Use `sdk.getRampInfo()` to read the credential-bound, sanitized KYC and buy/sell availability by country. It accepts either configured key and returns no identifiers, limits, or personal data. > 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. @@ -213,12 +215,12 @@ Only the base Vortex API is required. If the RPC URL's are not provided, default ### API keys -Two optional keys can be passed to the SDK: +Each API credential has a public and secret key. Either key can be configured independently: -- `publicKey` (`pk_live_*` / `pk_test_*`): attached to quote requests for partner attribution and discount eligibility. -- `secretKey` (`sk_live_*` / `sk_test_*`): sent as the `X-API-Key` header on every request, authenticating the partner. +- `publicKey` (`pk_live_*` / `pk_test_*`): sent as `X-Public-Key` and retained in quote bodies for compatibility, enabling attribution and approved low-sensitivity reads such as `getRampInfo()`. +- `secretKey` (`sk_live_*` / `sk_test_*`): sent as the `X-API-Key` header on every request. SDK ramp registration requires it to resolve to a Vortex user, either directly or through a delegated partner key. -Both are optional today. After the grace period, partner-scoped endpoints will reject calls that omit them, so it is recommended to start passing them now. +The public key is optional for quotes. The secret key is required by SDK `registerRamp`; it must be kept server-side and is returned only once when created. When both keys are configured, they must belong to the same credential or requests fail with `VortexSdkError.code === "CREDENTIAL_MISMATCH"`. User-scoped credentials can be created through the OTP-authenticated `/v1/api-credentials` endpoint and revoked atomically by credential ID. Raw API clients may alternatively register with the user's Supabase Bearer session. ```typescript const sdk = new VortexSdk({ diff --git a/packages/sdk/scripts/create-api-key.ts b/packages/sdk/scripts/create-api-key.ts index 233c20e43..2c3a6983b 100644 --- a/packages/sdk/scripts/create-api-key.ts +++ b/packages/sdk/scripts/create-api-key.ts @@ -1,4 +1,4 @@ -// Create a new public + secret API key pair (POST /v1/api-keys). +// Create a unified public + secret API credential (POST /v1/api-credentials). // Requires a valid auth token from scripts/login.ts. // // Run: @@ -23,12 +23,13 @@ interface AuthToken { userId: string; } -interface ApiKeyResponse { +interface ApiCredentialResponse { 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" }; + id: string; + name: string; + publicKey: string; + secretKey: string; } function loadAuthToken(): AuthToken { @@ -41,8 +42,8 @@ function loadAuthToken(): 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`, { + console.log(`🗝️ Creating API credential "${API_KEY_NAME}" ...`); + const response = await fetch(`${API_BASE_URL}/v1/api-credentials`, { body: JSON.stringify({ name: API_KEY_NAME }), headers: { Authorization: `Bearer ${auth.accessToken}`, @@ -52,22 +53,21 @@ async function main(): Promise { }); const text = await response.text(); if (!response.ok) { - throw new Error(`${response.status} /v1/api-keys: ${text}`); + throw new Error(`${response.status} /v1/api-credentials: ${text}`); } - const keyPair = JSON.parse(text) as ApiKeyResponse; + const credential = JSON.parse(text) as ApiCredentialResponse; - console.log(" publicKey:", keyPair.publicKey.key); - console.log(" secretKey:", keyPair.secretKey.key, " (shown once)"); + console.log(" publicKey:", credential.publicKey); + console.log(" secretKey:", credential.secretKey, " (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, + createdAt: credential.createdAt, + credentialId: credential.id, + expiresAt: credential.expiresAt, + name: credential.name, + publicKey: credential.publicKey, + secretKey: credential.secretKey, userId: auth.userId }; fs.writeFileSync(API_KEY_OUTFILE, JSON.stringify(out, null, 2)); diff --git a/packages/sdk/scripts/delete-api-key.ts b/packages/sdk/scripts/delete-api-key.ts index 2833bbeff..cfc17a17f 100644 --- a/packages/sdk/scripts/delete-api-key.ts +++ b/packages/sdk/scripts/delete-api-key.ts @@ -1,9 +1,6 @@ -// Delete (revoke) a user API key pair (DELETE /v1/api-keys/:keyId). +// Delete (revoke) a user API credential (DELETE /v1/api-credentials/:credentialId). // 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 @@ -22,19 +19,15 @@ interface AuthToken { accessToken: string; } -interface ApiKeyEntry { +interface ApiCredential { id: string; - key?: string; name: string; - type: "public" | "secret"; -} - -interface ListApiKeysResponse { - apiKeys: ApiKeyEntry[]; + publicKey: string; + secretKeyPrefix: string; } -function stripSuffix(name: string): string { - return name.replace(/\s*\((Public|Secret)\)$/, ""); +interface ListApiCredentialsResponse { + apiCredentials: ApiCredential[]; } function askQuestion(query: string): Promise { @@ -57,70 +50,44 @@ function loadAuthToken(): AuthToken { async function main(): Promise { const auth = loadAuthToken(); - console.log("📋 Fetching API keys ..."); - const response = await fetch(`${API_BASE_URL}/v1/api-keys`, { + console.log("📋 Fetching API credentials ..."); + const response = await fetch(`${API_BASE_URL}/v1/api-credentials`, { headers: { Authorization: `Bearer ${auth.accessToken}` } }); const text = await response.text(); if (!response.ok) { - throw new Error(`${response.status} /v1/api-keys: ${text}`); + throw new Error(`${response.status} /v1/api-credentials: ${text}`); } - const data = JSON.parse(text) as ListApiKeysResponse; + const data = JSON.parse(text) as ListApiCredentialsResponse; - if (data.apiKeys.length === 0) { - console.log("No active API keys to delete."); + if (data.apiCredentials.length === 0) { + console.log("No API credentials 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}`); + console.log("\nAPI credentials:\n"); + data.apiCredentials.forEach((credential, i) => { + console.log(` ${i + 1}. [${credential.id}] ${credential.name} — ${credential.publicKey} / ${credential.secretKeyPrefix}`); }); - const choice = await askQuestion(`\n➡️ Enter the number (1-${data.apiKeys.length}) of the key to delete: `); + const choice = await askQuestion(`\n➡️ Enter the number (1-${data.apiCredentials.length}) to revoke: `); const index = Number.parseInt(choice, 10) - 1; - if (Number.isNaN(index) || index < 0 || index >= data.apiKeys.length) { + if (Number.isNaN(index) || index < 0 || index >= data.apiCredentials.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 selected = data.apiCredentials[index]; + console.log(`\n🗑️ Revoking credential: ${selected.id} (${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}` - }, + const deleteResponse = await fetch(`${API_BASE_URL}/v1/api-credentials/${selected.id}`, { + headers: { Authorization: `Bearer ${auth.accessToken}` }, method: "DELETE" }); if (!deleteResponse.ok) { const errText = await deleteResponse.text(); - throw new Error(`${deleteResponse.status} /v1/api-keys/${keyId}: ${errText}`); + throw new Error(`${deleteResponse.status} /v1/api-credentials/${selected.id}: ${errText}`); } - console.log(pairedKeyId ? "✅ Key pair revoked." : "✅ Key revoked."); + console.log("✅ Credential revoked."); } if (import.meta.main) { diff --git a/packages/sdk/scripts/fetch-api-keys.ts b/packages/sdk/scripts/fetch-api-keys.ts index df987b3a0..5983f95e2 100644 --- a/packages/sdk/scripts/fetch-api-keys.ts +++ b/packages/sdk/scripts/fetch-api-keys.ts @@ -1,4 +1,4 @@ -// List active user API keys (GET /v1/api-keys). +// List active user API credentials (GET /v1/api-credentials). // Requires a valid auth token from scripts/login.ts. // // Run: @@ -18,21 +18,20 @@ interface AuthToken { accessToken: string; } -interface ApiKeyEntry { +interface ApiCredential { createdAt: string; expiresAt: string; id: string; - isActive: boolean; - key?: string; - keyPrefix: string; - lastUsedAt: string | null; name: string; - type: "public" | "secret"; - updatedAt: string; + publicKey: string; + publicLastUsedAt: string | null; + revokedAt: string | null; + secretKeyPrefix: string; + secretLastUsedAt: string | null; } -interface ListApiKeysResponse { - apiKeys: ApiKeyEntry[]; +interface ListApiCredentialsResponse { + apiCredentials: ApiCredential[]; } function loadAuthToken(): AuthToken { @@ -45,34 +44,32 @@ function loadAuthToken(): AuthToken { async function main(): Promise { const auth = loadAuthToken(); - console.log("📋 Fetching API keys ..."); - const response = await fetch(`${API_BASE_URL}/v1/api-keys`, { + console.log("📋 Fetching API credentials ..."); + const response = await fetch(`${API_BASE_URL}/v1/api-credentials`, { headers: { Authorization: `Bearer ${auth.accessToken}` } }); const text = await response.text(); if (!response.ok) { - throw new Error(`${response.status} /v1/api-keys: ${text}`); + throw new Error(`${response.status} /v1/api-credentials: ${text}`); } - const data = JSON.parse(text) as ListApiKeysResponse; + const data = JSON.parse(text) as ListApiCredentialsResponse; - if (data.apiKeys.length === 0) { - console.log("No active API keys found."); + if (data.apiCredentials.length === 0) { + console.log("No API credentials 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(`\n${data.apiCredentials.length} credential(s):\n`); + for (const credential of data.apiCredentials) { + console.log(` ${credential.id}`); + console.log(` Name: ${credential.name}`); + console.log(` Public key: ${credential.publicKey}`); + console.log(` Secret prefix: ${credential.secretKeyPrefix}`); + console.log(` Created: ${credential.createdAt}`); + console.log(` Expires: ${credential.expiresAt}`); + console.log(` Public last used: ${credential.publicLastUsedAt ?? "never"}`); + console.log(` Secret last used: ${credential.secretLastUsedAt ?? "never"}`); + console.log(` Revoked: ${credential.revokedAt ?? "no"}`); console.log(); } } diff --git a/packages/sdk/scripts/login-and-create-api-key.ts b/packages/sdk/scripts/login-and-create-api-key.ts index b0f3083cd..8e9f36f29 100644 --- a/packages/sdk/scripts/login-and-create-api-key.ts +++ b/packages/sdk/scripts/login-and-create-api-key.ts @@ -4,7 +4,7 @@ // 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) +// 4. POST /v1/api-credentials { Authorization: Bearer ... } -> public + secret credential // 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 @@ -27,12 +27,13 @@ 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 { +interface ApiCredentialResponse { 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" }; + id: string; + name: string; + publicKey: string; + secretKey: string; } interface VerifyOtpResponse { @@ -77,8 +78,8 @@ async function verifyOtp(email: string, token: string): Promise("/auth/verify-otp", { email, token }); } -async function createApiKey(accessToken: string, name: string): Promise { - return postJson("/api-keys", { name }, accessToken); +async function createApiCredential(accessToken: string, name: string): Promise { + return postJson("/api-credentials", { name }, accessToken); } async function main(): Promise { @@ -94,20 +95,19 @@ async function main(): Promise { 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)"); + console.log(`\n🗝️ Creating user-scoped API credential "${API_KEY_NAME}" ...`); + const credential = await createApiCredential(auth.access_token, API_KEY_NAME); + console.log(" publicKey:", credential.publicKey); + console.log(" secretKey:", credential.secretKey, " (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, + createdAt: credential.createdAt, + credentialId: credential.id, + expiresAt: credential.expiresAt, + name: credential.name, + publicKey: credential.publicKey, + secretKey: credential.secretKey, userId: auth.user_id }; fs.writeFileSync(API_KEY_OUTFILE, JSON.stringify(out, null, 2)); diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index afd7fc69d..658995e7e 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -8,6 +8,7 @@ import { EphemeralAccount, EphemeralAccountType, EvmTransactionData, + GetRampInfoResponse, GetRampStatusResponse, isAlfredpayToken, isEvmTransactionData, @@ -60,7 +61,7 @@ export class VortexSdk { private storeEphemeralKeys: boolean; constructor(config: VortexSdkConfig) { - this.apiService = new ApiService(config.apiBaseUrl, config.secretKey); + this.apiService = new ApiService(config.apiBaseUrl, config.publicKey, config.secretKey); this.networkManager = new NetworkManager(config); this.storeEphemeralKeys = config.storeEphemeralKeys ?? true; this.publicKey = config.publicKey; @@ -110,6 +111,10 @@ export class VortexSdk { return this.apiService.getRampStatus(rampId); } + async getRampInfo(): Promise { + return this.apiService.getRampInfo(); + } + async getUserTransactions(rampProcess: RampProcess, userAddress: string): Promise { if (!rampProcess.unsignedTxs) { return []; @@ -127,7 +132,7 @@ export class VortexSdk { }> { 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." + "Ramp registration requires a secretKey (sk_*) that resolves to a Vortex user. Use a user-scoped key or a partner key delegated to a user." ); } diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index aa922e4d9..2c99cf134 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -5,6 +5,7 @@ export interface APIErrorResponse { errors?: unknown[]; status: number; isPublic?: boolean; + code?: string; } export class VortexSdkError extends Error { @@ -12,14 +13,16 @@ export class VortexSdkError extends Error { public readonly isPublic: boolean; public readonly errors?: unknown[]; public readonly originalError?: Error; + public readonly code?: string; - constructor(message: string, status = 500, isPublic = false, errors?: unknown[], originalError?: Error) { + constructor(message: string, status = 500, isPublic = false, errors?: unknown[], originalError?: Error, code?: string) { super(message); this.name = "VortexSdkError"; this.status = status; this.isPublic = isPublic; this.errors = errors; this.originalError = originalError; + this.code = code; } } @@ -441,9 +444,11 @@ function extractErrorMessage(value: unknown): string | undefined { */ export function parseAPIError(response: unknown, fallbackStatus?: number): VortexSdkError { if (response && typeof response === "object") { - const { message, error, errors } = response as Record; + const { message, error, errors, code } = response as Record; const normalizedStatus = extractErrorStatus(response as Record) ?? fallbackStatus ?? 500; const errorMessage = extractErrorMessage(message) ?? extractErrorMessage(error); + const nestedCode = error && typeof error === "object" ? (error as Record).code : undefined; + const errorCode = typeof code === "string" ? code : typeof nestedCode === "string" ? nestedCode : undefined; if (errorMessage) { if (errorMessage?.includes("Missing required fields")) { @@ -557,7 +562,9 @@ export function parseAPIError(response: unknown, fallbackStatus?: number): Vorte errorMessage ?? "Unknown API error", normalizedStatus, true, - Array.isArray(errors) ? errors : undefined + Array.isArray(errors) ? errors : undefined, + undefined, + errorCode ); } diff --git a/packages/sdk/src/services/ApiService.ts b/packages/sdk/src/services/ApiService.ts index ff4ac8b58..a97777b2c 100644 --- a/packages/sdk/src/services/ApiService.ts +++ b/packages/sdk/src/services/ApiService.ts @@ -2,6 +2,7 @@ import type { AlfredPayCountry, AlfredpayFiatAccount, CreateQuoteRequest, + GetRampInfoResponse, GetRampStatusResponse, QuoteResponse, RampDirection, @@ -18,6 +19,7 @@ import type { BrlKycResponse } from "../types"; export class ApiService { constructor( private readonly apiBaseUrl: string, + private readonly publicKey?: string, private readonly secretKey?: string ) {} @@ -25,6 +27,9 @@ export class ApiService { const headers: Record = { "Content-Type": "application/json" }; + if (this.publicKey) { + headers["X-Public-Key"] = this.publicKey; + } if (this.secretKey) { headers["X-API-Key"] = this.secretKey; } @@ -89,6 +94,15 @@ export class ApiService { return handleAPIResponse(response, `/v1/ramp/status?id=${rampId}`); } + async getRampInfo(): Promise { + const response = await fetch(`${this.apiBaseUrl}/v1/ramp-info`, { + headers: this.buildHeaders(), + method: "GET" + }); + + return handleAPIResponse(response, "/v1/ramp-info"); + } + async getBrlKycStatus(taxId?: string): Promise { const url = new URL(`${this.apiBaseUrl}/v1/brla/getUser`); if (taxId) { diff --git a/packages/sdk/src/services/NetworkManager.ts b/packages/sdk/src/services/NetworkManager.ts index 09dd56efc..11828f0ba 100644 --- a/packages/sdk/src/services/NetworkManager.ts +++ b/packages/sdk/src/services/NetworkManager.ts @@ -17,7 +17,7 @@ const DEFAULT_NETWORKS: NetworkConfig[] = [ }, { name: "hydration", - wsUrl: "wss://hydration.ibp.network" + wsUrl: "wss://hydration.dotters.network" } ]; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 34e152b41..47d51a404 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -227,15 +227,12 @@ export interface NetworkConfig { export interface VortexSdkConfig { apiBaseUrl: string; /** - * Public API key (pk_live_* or pk_test_*). Sent in request bodies for tracking - * and partner-specific discounts. Optional during the grace period; some - * endpoints will require it once enforcement begins. + * Public API key (pk_live_* or pk_test_*). Sent as `X-Public-Key` and retained + * in quote request bodies for compatibility. */ publicKey?: string; /** - * Secret API key (sk_live_* or sk_test_*). Sent as the `X-API-Key` header for - * partner authentication. Optional during the grace period; endpoints that - * accept a `partnerId` will require it once enforcement begins. + * Secret API key (sk_live_* or sk_test_*). Sent as the `X-API-Key` header. */ secretKey?: string; pendulumWsUrl?: string; diff --git a/packages/sdk/test/apiService.credentials.test.ts b/packages/sdk/test/apiService.credentials.test.ts new file mode 100644 index 000000000..ae3310b55 --- /dev/null +++ b/packages/sdk/test/apiService.credentials.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { VortexSdkError } from "../src/errors"; +import { ApiService } from "../src/services/ApiService"; + +const originalFetch = globalThis.fetch; +const rampInfo = { + corridors: { + BR: { canBuy: true, canSell: true, kycStatus: "approved" } + } +}; + +afterEach(() => { + globalThis.fetch = originalFetch; + mock.restore(); +}); + +describe("ApiService credentials", () => { + test("sends a configured public key", async () => { + const fetchMock = mock(() => Promise.resolve(Response.json(rampInfo))); + globalThis.fetch = fetchMock as typeof fetch; + + await new ApiService("https://api.example", "pk_test_public").getRampInfo(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example/v1/ramp-info", + expect.objectContaining({ headers: expect.objectContaining({ "X-Public-Key": "pk_test_public" }) }) + ); + expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty("X-API-Key"); + }); + + test("sends a configured secret key", async () => { + const fetchMock = mock(() => Promise.resolve(Response.json(rampInfo))); + globalThis.fetch = fetchMock as typeof fetch; + + await new ApiService("https://api.example", undefined, "sk_test_secret").getRampInfo(); + + expect(fetchMock.mock.calls[0]?.[1]?.headers).toEqual( + expect.objectContaining({ "X-API-Key": "sk_test_secret" }) + ); + expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty("X-Public-Key"); + }); + + test("sends both configured keys", async () => { + const fetchMock = mock(() => Promise.resolve(Response.json(rampInfo))); + globalThis.fetch = fetchMock as typeof fetch; + + await new ApiService("https://api.example", "pk_test_public", "sk_test_secret").getRampInfo(); + + expect(fetchMock.mock.calls[0]?.[1]?.headers).toEqual( + expect.objectContaining({ "X-API-Key": "sk_test_secret", "X-Public-Key": "pk_test_public" }) + ); + }); + + test("preserves a credential mismatch error code", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + Response.json( + { + error: { + code: "CREDENTIAL_MISMATCH", + message: "Public and secret keys belong to different credentials", + status: 403 + } + }, + { status: 403 } + ) + ) + ) as typeof fetch; + + const request = new ApiService("https://api.example", "pk_test_public", "sk_test_other").getRampInfo(); + + await expect(request).rejects.toMatchObject({ code: "CREDENTIAL_MISMATCH", status: 403 }); + }); +}); diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts index 8e11b90c5..56b61030d 100644 --- a/packages/sdk/test/errors.test.ts +++ b/packages/sdk/test/errors.test.ts @@ -24,6 +24,15 @@ describe("parseAPIError", () => { expect(error.message).toBe("Invalid or expired Bearer token."); }); + test("preserves stable string error codes", () => { + const error = parseAPIError({ + error: { code: "CREDENTIAL_MISMATCH", message: "Credentials do not match", status: 403 } + }); + + expect(error.code).toBe("CREDENTIAL_MISMATCH"); + expect(error.status).toBe(403); + }); + test("maps Alfredpay onramp auth and KYC errors", () => { const error = parseAPIError({ code: 401, diff --git a/packages/shared/CLAUDE.md b/packages/shared/CLAUDE.md index 2c74e2431..6bf312c3f 100644 --- a/packages/shared/CLAUDE.md +++ b/packages/shared/CLAUDE.md @@ -25,3 +25,9 @@ when shared is rebuilt. If `@pendulum-chain/types` isn't detected properly, ensure all `@polkadot/*` packages match the versions in the types package. The root `package.json` manages versions via `catalog:`. + +## Documentation + +Follow [`docs/README.md`](../../docs/README.md). A code-adjacent README may document a +non-obvious shared contract, but cross-module architecture, plans, and security behavior +belong in their canonical `docs/` locations. Do not add memory or progress files. diff --git a/packages/shared/src/endpoints/index.ts b/packages/shared/src/endpoints/index.ts index 09f282e05..4fb011570 100644 --- a/packages/shared/src/endpoints/index.ts +++ b/packages/shared/src/endpoints/index.ts @@ -3,6 +3,7 @@ export * from "./alfredpay.endpoints"; export * from "./brla.endpoints"; export * from "./contact.endpoints"; export * from "./email.endpoints"; +export * from "./limits.endpoints"; export * from "./moonbeam.endpoints"; export * from "./payment-methods.endpoints"; export * from "./pendulum.endpoints"; diff --git a/packages/shared/src/endpoints/limits.endpoints.ts b/packages/shared/src/endpoints/limits.endpoints.ts new file mode 100644 index 000000000..9301ea519 --- /dev/null +++ b/packages/shared/src/endpoints/limits.endpoints.ts @@ -0,0 +1,28 @@ +import type { CorridorCountry } from "../corridors"; +import type { RampCurrency } from "../tokens/types/base"; +import type { RampDirection } from "../types/rampDirection"; + +export type LimitsCorridor = Exclude; + +export interface GetUserLimitsRequest { + corridors: LimitsCorridor[]; +} + +export interface UserLimitPeriod { + type: "calendar_month"; + startsAt: string; + endsAt: string; +} + +export interface UserLimit { + corridor: LimitsCorridor; + direction: RampDirection; + currency: RampCurrency; + max: string; + used: string; + period: UserLimitPeriod; +} + +export interface GetUserLimitsResponse { + limits: UserLimit[]; +} diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 098038da6..ec9e7fb45 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -64,7 +64,8 @@ export type CleanupPhase = | "baseCleanupUsdc" | "baseCleanupBrla" | "baseCleanupEurc" - | "baseCleanupAxlUsdc"; + | "baseCleanupAxlUsdc" + | "ethereumCleanupUsdc"; export enum EphemeralAccountType { Substrate = "Substrate", @@ -87,8 +88,8 @@ export interface EvmTransactionData { } export interface TypedDataDomain { - name: string; - version: string; + name?: string; + version?: string; salt?: `0x${string}`; chainId?: number; verifyingContract: EvmAddress; @@ -190,6 +191,15 @@ export interface RegisterRampRequest { sessionId?: string; email?: string; // Required for Mykobo EUR ramps (binds ramp to anchor profile) ipAddress?: string; // Required for Mykobo EUR ramps (user IP for fraud checks; auto-filled from req.ip if omitted) + /** + * Recipient-directed payout is intentionally unsupported in this API version. + * The server rejects common recipient-context keys instead of silently treating + * the request as a sender self-offramp. + */ + recipientId?: never; + recipientPayoutReferenceId?: never; + recipientRelationshipId?: never; + senderRecipientId?: never; [key: string]: unknown; }; } @@ -251,6 +261,17 @@ export interface GetRampStatusRequest { id: string; } +export interface GetRampInfoResponse { + corridors: Record< + string, + { + kycStatus: "not_started" | "pending" | "approved" | "rejected"; + canBuy: boolean; + canSell: boolean; + } + >; +} + export interface GetRampStatusResponse extends RampProcess { // Fee fields in fiat currency anchorFeeFiat: string; diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 177bbb838..dba918272 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -46,12 +46,16 @@ export interface WebhookPayloadBase { } export interface TransactionCreatedWebhookPayload { + /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ + eventId: string; eventType: WebhookEventType.TRANSACTION_CREATED; timestamp: string; payload: WebhookPayloadBase; } export interface StatusChangeWebhookPayload { + /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ + eventId: string; eventType: WebhookEventType.STATUS_CHANGE; timestamp: string; payload: WebhookPayloadBase; diff --git a/packages/shared/src/helpers/signUnsigned.test.ts b/packages/shared/src/helpers/signUnsigned.test.ts new file mode 100644 index 000000000..f7af85cdd --- /dev/null +++ b/packages/shared/src/helpers/signUnsigned.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; +import type { WalletClient } from "viem"; +import { polygonAmoy } from "viem/chains"; +import type { UnsignedTx } from "../endpoints/ramp.endpoints"; + +// Importing ./signUnsigned pulls in the package barrel, which freezes src/constants.ts from +// process.env for the whole test run. Provide the env defaults other test files rely on before +// that happens (same pattern as alfredpayApiService.test.ts), hence the dynamic import. +process.env.ALFREDPAY_API_KEY ||= "test-key"; +process.env.ALFREDPAY_API_SECRET ||= "test-secret"; + +const { Networks } = await import("./networks"); +const { createEvmClient, groupUnsignedTxsForSigning } = await import("./signUnsigned"); + +const EPHEMERAL = { + address: "0x0000000000000000000000000000000000000000", + secret: "0x0000000000000000000000000000000000000000000000000000000000000001" +}; + +function transportUrls(client: WalletClient): (string | undefined)[] { + const transport = client.transport as unknown as { transports: { value?: { url?: string } }[] }; + return transport.transports.map(t => t.value?.url); +} + +function makeTx(network: UnsignedTx["network"], phase: UnsignedTx["phase"]): UnsignedTx { + return { + meta: {}, + network, + nonce: 0, + phase, + signer: "0x0000000000000000000000000000000000000000", + txData: { + data: "0x", + gas: "21000", + maxFeePerGas: "1", + maxPriorityFeePerGas: "1", + to: "0x0000000000000000000000000000000000000000", + value: "0" + } + }; +} + +describe("createEvmClient Polygon Amoy transports", () => { + it("prefers Alchemy for signing and keeps viem's default transport as the fallback", () => { + const client = createEvmClient(Networks.PolygonAmoy, EPHEMERAL, "test-api-key"); + + expect(transportUrls(client)).toEqual([ + "https://polygon-amoy.g.alchemy.com/v2/test-api-key", + polygonAmoy.rpcUrls.default.http[0] + ]); + }); + + it("uses only viem's default transport without an Alchemy API key", () => { + const client = createEvmClient(Networks.PolygonAmoy, EPHEMERAL); + + expect(transportUrls(client)).toEqual([polygonAmoy.rpcUrls.default.http[0]]); + }); +}); + +describe("groupUnsignedTxsForSigning", () => { + it("assigns destination-phase transactions on directly signed EVM networks to the EVM group only", () => { + const tx = makeTx(Networks.Arbitrum, "destinationTransfer"); + + const groups = groupUnsignedTxsForSigning([tx]); + + expect(groups.evmTxs).toEqual([tx]); + expect(groups.destinationNetworkTxs).toEqual([]); + }); + + it("keeps destination-phase transactions on other networks in the destination group", () => { + const tx = makeTx(Networks.BaseSepolia, "destinationTransfer"); + + const groups = groupUnsignedTxsForSigning([tx]); + + expect(groups.destinationNetworkTxs).toEqual([tx]); + expect(groups.evmTxs).toEqual([]); + }); + + it("never assigns a transaction to both the EVM and destination groups", () => { + const destinationPhases: UnsignedTx["phase"][] = [ + "destinationTransfer", + "backupSquidRouterApprove", + "backupSquidRouterSwap", + "backupApprove" + ]; + const txs = Object.values(Networks).flatMap(network => destinationPhases.map(phase => makeTx(network, phase))); + + const groups = groupUnsignedTxsForSigning(txs); + + for (const tx of txs) { + expect(groups.evmTxs.includes(tx) && groups.destinationNetworkTxs.includes(tx)).toBe(false); + } + }); +}); diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 229e276f1..869265a51 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -18,6 +18,47 @@ import { } from "../index"; import logger from "../logger"; +// Networks whose transactions are signed directly with the EVM ephemeral, regardless of phase. +const EVM_EPHEMERAL_SIGNING_NETWORKS: Networks[] = [ + Networks.Polygon, + Networks.PolygonAmoy, + Networks.Base, + Networks.Arbitrum, + Networks.Avalanche, + Networks.BSC, + Networks.Ethereum +]; + +const DESTINATION_NETWORK_PHASES = [ + "destinationTransfer", + "backupSquidRouterApprove", + "backupSquidRouterSwap", + "backupApprove" +]; + +/** + * Groups transactions by the signing flow that handles them. The destination group must + * exclude every network the EVM group selects, or the same transaction would be signed + * (and returned) twice. + */ +export function groupUnsignedTxsForSigning(unsignedTxs: UnsignedTx[]): { + destinationNetworkTxs: UnsignedTx[]; + evmTxs: UnsignedTx[]; + hydrationTxs: UnsignedTx[]; + moonbeamTxs: UnsignedTx[]; + pendulumTxs: UnsignedTx[]; +} { + return { + destinationNetworkTxs: unsignedTxs.filter( + tx => DESTINATION_NETWORK_PHASES.includes(tx.phase) && !EVM_EPHEMERAL_SIGNING_NETWORKS.includes(tx.network) + ), + evmTxs: unsignedTxs.filter(tx => EVM_EPHEMERAL_SIGNING_NETWORKS.includes(tx.network)), + hydrationTxs: unsignedTxs.filter(tx => tx.network === Networks.Hydration), + moonbeamTxs: unsignedTxs.filter(tx => tx.network === Networks.Moonbeam), + pendulumTxs: unsignedTxs.filter(tx => tx.network === Networks.Pendulum) + }; +} + export function addAdditionalTransactionsToMeta(primaryTx: PresignedTx, multiSignedTxs: PresignedTx[]): PresignedTx { if (multiSignedTxs.length <= 1) { return primaryTx; @@ -77,7 +118,7 @@ async function signMultipleSubstrateTransactions( * @param apiKey - Optional Alchemy API key * @returns WalletClient for the specified network */ -function createEvmClient( +export function createEvmClient( network: string, // Accept string to match UnsignedTx.network type usually being string/enum evmEphemeral: EphemeralAccount, apiKey?: string @@ -95,7 +136,7 @@ function createEvmClient( break; case Networks.PolygonAmoy: chain = polygonAmoy; - rpcUrls = ["https://polygon-amoy.api.onfinality.io/public"]; + rpcUrls = apiKey ? [`https://polygon-amoy.g.alchemy.com/v2/${apiKey}`] : []; break; case Networks.Moonbeam: chain = moonbeam; @@ -206,25 +247,9 @@ export async function signUnsignedTransactions( const signedTxs: PresignedTx[] = []; // Group transactions - const moonbeamTxs = unsignedTxs.filter(tx => tx.network === Networks.Moonbeam); - const evmTxs = unsignedTxs.filter( - tx => tx.network === Networks.Polygon || tx.network === Networks.PolygonAmoy || tx.network === Networks.Base - ); - const hydrationTxs = unsignedTxs.filter(tx => tx.network === Networks.Hydration); - const destinationNetworkTxs = unsignedTxs.filter( - tx => - (tx.phase === "destinationTransfer" || - tx.phase === "backupSquidRouterApprove" || - tx.phase === "backupSquidRouterSwap" || - tx.phase === "backupApprove") && - tx.network !== Networks.Polygon && - tx.network !== Networks.PolygonAmoy && - tx.network !== Networks.Base - ); + const { destinationNetworkTxs, evmTxs, hydrationTxs, moonbeamTxs, pendulumTxs } = groupUnsignedTxsForSigning(unsignedTxs); try { - const pendulumTxs = unsignedTxs.filter(tx => tx.network === "pendulum"); - for (const tx of hydrationTxs) { if (!ephemerals.substrateEphemeral) { throw new Error("Missing Substrate ephemeral account"); @@ -324,10 +349,6 @@ export async function signUnsignedTransactions( throw new Error("Missing EVM ephemeral account"); } - // Check if already signed to avoid duplication - const alreadySigned = signedTxs.some(st => st === tx || (st.txData === tx.txData && st.nonce === tx.nonce)); - if (alreadySigned) continue; - const client = createEvmClient(tx.network, ephemerals.evmEphemeral, alchemyApiKey); const multiSignedTxs = await signMultipleEvmTransactions(tx, client, tx.nonce); const primaryTx = multiSignedTxs[0]; diff --git a/packages/shared/src/services/alfredpay/schemas.ts b/packages/shared/src/services/alfredpay/schemas.ts index 20b7f76e9..85ec02509 100644 --- a/packages/shared/src/services/alfredpay/schemas.ts +++ b/packages/shared/src/services/alfredpay/schemas.ts @@ -19,7 +19,7 @@ import { } from "./types"; /** - * External API contract schemas for Alfredpay (see docs/features/contract-tests.md). + * External API contract schemas for Alfredpay (see docs/operations-testing.md). * * These model the raw wire JSON of the fields Vortex actually consumes — not the full * partner response. Unknown extra fields always pass (loose objects); a removed or diff --git a/packages/shared/src/services/brla/schemas.test.ts b/packages/shared/src/services/brla/schemas.test.ts index e41556eeb..bc2c61b5c 100644 --- a/packages/shared/src/services/brla/schemas.test.ts +++ b/packages/shared/src/services/brla/schemas.test.ts @@ -97,6 +97,20 @@ describe("aveniaAccountLimitsSchema", () => { delete (body.limitInfo.limits[0].usedLimit as Record).usedFiatIn; expect(() => aveniaAccountLimitsSchema.parse(body)).toThrow(); }); + + test("requires the provider usage year and month", () => { + const usedLimit: Record = { month: 7, usedFiatIn: "0", usedFiatOut: "0", year: 2026 }; + const body = { + limitInfo: { limits: [{ currency: "BRL", maxFiatIn: "10000", maxFiatOut: "10000", usedLimit }] } + }; + + expect(() => aveniaAccountLimitsSchema.parse(body)).not.toThrow(); + delete usedLimit.month; + expect(() => aveniaAccountLimitsSchema.parse(body)).toThrow(); + usedLimit.month = 7; + delete usedLimit.year; + expect(() => aveniaAccountLimitsSchema.parse(body)).toThrow(); + }); }); describe("aveniaAccountBalanceSchema", () => { diff --git a/packages/shared/src/services/brla/schemas.ts b/packages/shared/src/services/brla/schemas.ts index 50d2f34cc..9ae7e3c9a 100644 --- a/packages/shared/src/services/brla/schemas.ts +++ b/packages/shared/src/services/brla/schemas.ts @@ -18,7 +18,7 @@ import { } from "./types"; /** - * External API contract schemas for Avenia/BRLA (see docs/features/contract-tests.md). + * External API contract schemas for Avenia/BRLA (see docs/operations-testing.md). * * These model the raw wire JSON of the fields Vortex actually consumes — not the full * partner response. Unknown extra fields always pass (loose objects); a removed or @@ -39,7 +39,7 @@ type ConsumedQuote = Pick & { - usedLimit: Pick; + usedLimit: Pick; }; type ConsumedAccountInfo = Pick & { accountInfo: Pick; @@ -103,8 +103,10 @@ export const aveniaAccountLimitsSchema = z.looseObject({ maxFiatIn: z.string().regex(DECIMAL_STRING), maxFiatOut: z.string().regex(DECIMAL_STRING), usedLimit: z.looseObject({ + month: z.number().int().min(1).max(12), usedFiatIn: z.string().regex(DECIMAL_STRING), - usedFiatOut: z.string().regex(DECIMAL_STRING) + usedFiatOut: z.string().regex(DECIMAL_STRING), + year: z.number().int() }) }) ) diff --git a/packages/shared/src/services/evm/clientManager.test.ts b/packages/shared/src/services/evm/clientManager.test.ts index c5383e7a2..0c0b5f040 100644 --- a/packages/shared/src/services/evm/clientManager.test.ts +++ b/packages/shared/src/services/evm/clientManager.test.ts @@ -1,13 +1,16 @@ import {describe, expect, it, mock} from "bun:test"; import {Networks} from "../../helpers"; import logger from "../../logger"; -import {EvmClientManager, redactRpcUrlForLogs, sanitizeRpcErrorMessage} from "./clientManager"; +import {EvmClientManager, getEvmNetworks, redactRpcUrlForLogs, sanitizeRpcErrorMessage} from "./clientManager"; describe("redactRpcUrlForLogs", () => { it("redacts provider API keys from RPC URLs", () => { expect(redactRpcUrlForLogs("https://polygon-mainnet.g.alchemy.com/v2/test-api-key")).toBe( "https://polygon-mainnet.g.alchemy.com/v2/[redacted]" ); + expect(redactRpcUrlForLogs("https://polygon-amoy.g.alchemy.com/v2/test-api-key")).toBe( + "https://polygon-amoy.g.alchemy.com/v2/[redacted]" + ); }); it("leaves empty viem default RPC markers readable", () => { @@ -21,11 +24,25 @@ describe("redactRpcUrlForLogs", () => { }); }); +describe("EvmClientManager RPC configuration", () => { + it("prefers Alchemy for Polygon Amoy and keeps viem's default transport as the fallback", () => { + const polygonAmoyConfig = getEvmNetworks("test-api-key").find(network => network.name === Networks.PolygonAmoy); + + expect(polygonAmoyConfig?.rpcUrls).toEqual(["https://polygon-amoy.g.alchemy.com/v2/test-api-key", ""]); + }); + + it("uses only viem's default Polygon Amoy transport without an Alchemy API key", () => { + const polygonAmoyConfig = getEvmNetworks().find(network => network.name === Networks.PolygonAmoy); + + expect(polygonAmoyConfig?.rpcUrls).toEqual([""]); + }); +}); + describe("EvmClientManager RPC cache keys", () => { it("keeps viem's default transport distinct from explicit RPC URLs", () => { const manager = EvmClientManager.getInstance(); - const explicitRpcClient = manager.getClient(Networks.PolygonAmoy, "https://polygon-amoy.api.onfinality.io/public"); - const defaultRpcClient = manager.getClient(Networks.PolygonAmoy, ""); + const explicitRpcClient = manager.getClient(Networks.Moonbeam, "https://rpc.api.moonbeam.network"); + const defaultRpcClient = manager.getClient(Networks.Moonbeam, ""); expect(defaultRpcClient).not.toBe(explicitRpcClient); }); diff --git a/packages/shared/src/services/evm/clientManager.ts b/packages/shared/src/services/evm/clientManager.ts index ec63b3374..f694e72e8 100644 --- a/packages/shared/src/services/evm/clientManager.ts +++ b/packages/shared/src/services/evm/clientManager.ts @@ -58,7 +58,7 @@ function isNonRetryableReadContractError(error: Error): boolean { return NON_RETRYABLE_READ_CONTRACT_ERROR_PATTERNS.some(pattern => pattern.test(error.message)); } -function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { +export function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { // Note on defining RPC URLs: '' is equal to viem's default RPC for that chain: http(). return [ { @@ -69,7 +69,7 @@ function getEvmNetworks(apiKey?: string): EvmNetworkConfig[] { { chain: polygonAmoy, name: Networks.PolygonAmoy, - rpcUrls: ["https://polygon-amoy.api.onfinality.io/public", ""] + rpcUrls: apiKey ? [`https://polygon-amoy.g.alchemy.com/v2/${apiKey}`, ""] : [""] }, { chain: moonbeam, diff --git a/packages/shared/src/services/squidrouter/offramp.ts b/packages/shared/src/services/squidrouter/offramp.ts index 271f810cd..30e45719b 100644 --- a/packages/shared/src/services/squidrouter/offramp.ts +++ b/packages/shared/src/services/squidrouter/offramp.ts @@ -1,7 +1,7 @@ import { u8aToHex } from "@polkadot/util"; import { decodeAddress } from "@polkadot/util-crypto"; import { createRandomString, createSquidRouterHash } from "../../helpers/squidrouter"; -import { EvmTransactionData, Networks, SquidrouterRoute } from "../../index"; +import { EvmNetworks, EvmTransactionData, isNetworkEVM, Networks, SquidrouterRoute } from "../../index"; import { EvmClientManager } from "../evm/clientManager"; import { getSquidRouterConfig } from "./config"; import { encodePayload } from "./payload"; @@ -92,9 +92,12 @@ export async function createOfframpSquidrouterTransactionsToEvm( if (params.fromNetwork === Networks.AssetHub) { throw new Error("AssetHub is not supported for Squidrouter offramp"); } + if (!isNetworkEVM(params.fromNetwork)) { + throw new Error(`createOfframpSquidrouterTransactionsToEvm: fromNetwork ${params.fromNetwork} is not an EVM network`); + } const evmClientManager = EvmClientManager.getInstance(); - const moonbeamClient = evmClientManager.getClient(Networks.Moonbeam); + const fromNetworkClient = evmClientManager.getClient(params.fromNetwork as EvmNetworks); const routeParams = createGenericRouteParams({ amount: params.rawAmount, ...params }); @@ -103,7 +106,7 @@ export async function createOfframpSquidrouterTransactionsToEvm( return createTransactionDataFromRoute({ inputTokenErc20Address: params.fromToken, - publicClient: moonbeamClient, + publicClient: fromNetworkClient, rawAmount: params.rawAmount, route }); diff --git a/packages/shared/src/services/squidrouter/route-transactions.ts b/packages/shared/src/services/squidrouter/route-transactions.ts index 2d1a8fbde..61025c5a8 100644 --- a/packages/shared/src/services/squidrouter/route-transactions.ts +++ b/packages/shared/src/services/squidrouter/route-transactions.ts @@ -79,12 +79,14 @@ export async function createTransactionDataFromRoute({ }); const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const bumpedMaxFeePerGas = (maxFeePerGas * 2n).toString(); + const bumpedMaxPriorityFeePerGas = ((maxPriorityFeePerGas ?? maxFeePerGas) * 2n).toString(); const approveData: EvmTransactionData = { data: approveTransactionData as `0x${string}`, gas: "150000", - maxFeePerGas: maxFeePerGas.toString(), - maxPriorityFeePerGas: (maxPriorityFeePerGas ?? maxFeePerGas).toString(), + maxFeePerGas: bumpedMaxFeePerGas, + maxPriorityFeePerGas: bumpedMaxPriorityFeePerGas, to: inputTokenErc20Address as `0x${string}`, value: "0" }; @@ -96,8 +98,8 @@ export async function createTransactionDataFromRoute({ const swapData: EvmTransactionData = { data: transactionRequest.data as `0x${string}`, gas: normalizeBigIntString(transactionRequest.gasLimit), - maxFeePerGas: maxFeePerGas.toString(), - maxPriorityFeePerGas: (maxPriorityFeePerGas ?? maxFeePerGas).toString(), + maxFeePerGas: bumpedMaxFeePerGas, + maxPriorityFeePerGas: bumpedMaxPriorityFeePerGas, to: transactionRequest.target as `0x${string}`, value: normalizeBigIntString(swapValue ?? transactionRequest.value) }; diff --git a/packages/shared/src/services/squidrouter/schemas.ts b/packages/shared/src/services/squidrouter/schemas.ts index 5d082fd9b..9965421ec 100644 --- a/packages/shared/src/services/squidrouter/schemas.ts +++ b/packages/shared/src/services/squidrouter/schemas.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { SquidRouterPayResponse, SquidrouterRoute, SquidrouterRouteEstimate } from "./route"; /** - * External API contract schemas for SquidRouter (see docs/features/contract-tests.md). + * External API contract schemas for SquidRouter (see docs/operations-testing.md). * * These model the raw wire JSON of the fields Vortex actually consumes — not the full * partner response. Unknown extra fields always pass (loose objects); a removed or diff --git a/packages/shared/src/services/xcm/assethubToMoonbeam.test.ts b/packages/shared/src/services/xcm/assethubToMoonbeam.test.ts index 1230a3fdb..d5511b109 100644 --- a/packages/shared/src/services/xcm/assethubToMoonbeam.test.ts +++ b/packages/shared/src/services/xcm/assethubToMoonbeam.test.ts @@ -2,7 +2,7 @@ import {expect, test} from "bun:test"; import {dryRunExtrinsic,} from "../../index"; import {createAssethubToMoonbeamTransferWithSwapOnHydration} from "./assethubToMoonbeam"; -// Hits live AssetHub/Hydration RPCs; opt-in only (see docs/testing-strategy.md). +// Hits live AssetHub/Hydration RPCs; opt-in only (see docs/operations-testing.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). diff --git a/packages/shared/src/services/xcm/send.ts b/packages/shared/src/services/xcm/send.ts index 43b4f2a2f..a438bd245 100644 --- a/packages/shared/src/services/xcm/send.ts +++ b/packages/shared/src/services/xcm/send.ts @@ -214,8 +214,10 @@ export const submitMoonbeamXcm = async ( if (status.isInBlock) { willFinalize = true; - const hash = status.asInBlock.toString(); + } + if (status.isFinalized) { + const hash = status.asFinalized.toString(); // Try to find 'polkadotXcm.Sent' events const xcmSentEvents = events.filter( record => record.event.section === "polkadotXcm" && record.event.method === "Sent" @@ -224,8 +226,9 @@ export const submitMoonbeamXcm = async ( .map(event => parseEventMoonbeamXcmSent(event)) .filter(event => event.originAddress === address); - if (!event) { + if (event.length === 0) { reject(new Error(`No XcmSent event found for account ${address}`)); + return; } resolve({ event: event[0], hash }); } diff --git a/packages/shared/src/substrateEvents/eventListener.ts b/packages/shared/src/substrateEvents/eventListener.ts index 751b86c0f..2bcf7113f 100644 --- a/packages/shared/src/substrateEvents/eventListener.ts +++ b/packages/shared/src/substrateEvents/eventListener.ts @@ -19,9 +19,8 @@ export class EventListener { this.api = api; this.initEventSubscriber(); - this.api?.on("connected", async (): Promise => { + this.api?.on("connected", (): void => { logger.current.info("Connected (or reconnected) to the endpoint."); - await this.checkForMissedEvents(); }); } @@ -81,10 +80,6 @@ export class EventListener { }); } - async checkForMissedEvents() { - // No-op: redeem/spacewalk event recovery removed with Stellar/Spacewalk deprecation. - } - unsubscribe() { if (this.unsubscribeHandle) { this.unsubscribeHandle(); diff --git a/packages/shared/src/tokens/README.md b/packages/shared/src/tokens/README.md index 92b2708a4..368c49f18 100644 --- a/packages/shared/src/tokens/README.md +++ b/packages/shared/src/tokens/README.md @@ -1,111 +1,35 @@ # Token Configuration -This directory contains the token configuration for the Pendulum Pay (Vortex) application. It provides a structured and -modular approach to managing token information across different blockchain networks. +This module is the shared source for fiat and on-chain token metadata used by every +workspace. -## Directory Structure +## Layout -``` -tokens/ -├── README.md -├── index.ts # Main entry point that exports everything -├── constants/ # Shared constants -│ ├── networks.ts # Network definitions -│ ├── pendulum.ts # Pendulum-specific constants -│ └── misc.ts # Miscellaneous constants -├── types/ # Type definitions -│ ├── base.ts # Base types shared across token types -│ ├── evm.ts # EVM-specific types -│ ├── assethub.ts # AssetHub-specific types -│ ├── stellar.ts # Stellar-specific types -│ └── moonbeam.ts # Moonbeam-specific types -├── evm/ # EVM token configuration -│ └── config.ts # EVM token details -├── assethub/ # AssetHub token configuration -│ └── config.ts # AssetHub token details -├── stellar/ # Stellar token configuration -│ └── config.ts # Stellar token details -├── moonbeam/ # Moonbeam token configuration -│ └── config.ts # Moonbeam token details -└── utils/ # Utility functions - ├── typeGuards.ts # Type guards for token types - └── helpers.ts # Helper functions for token operations -``` - -## Usage - -Import the token configuration from the main entry point: - -```typescript -import { - // Types - TokenType, - EvmToken, - FiatToken, - - // Token Details - EvmTokenDetails, - AssetHubTokenDetails, - StellarTokenDetails, - MoonbeamTokenDetails, - - // Configurations - evmTokenConfig, - assetHubTokenConfig, - stellarTokenConfig, - moonbeamTokenConfig, - - // Utility Functions - getOnChainTokenDetails, - getAnyFiatTokenDetails, - isEvmToken, - isStellarToken, - - // Constants - Networks, - PENDULUM_USDC_AXL, - HORIZON_URL, -} from 'signer-service/src/config/tokens'; -``` - -## Examples - -### Get token details for a specific network and token +- `tokenConfig.ts` and `index.ts` expose the public token API. +- `types/` defines token detail shapes for EVM, Pendulum, Moonbeam, and AssetHub. +- `evm/`, `pendulum/`, `moonbeam/`, `assethub/`, and `freeTokens/` contain network + configuration. +- `utils/` contains lookup, normalization, and type-guard functions. -```typescript -import { getOnChainTokenDetails, Networks, EvmToken } from 'signer-service/src/config/tokens'; +Import through the package boundary: -const usdcDetails = getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC); -console.log(usdcDetails.erc20AddressSourceChain); // '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' +```ts +import { EvmToken, FiatToken, Networks, getAnyFiatTokenDetails, getOnChainTokenDetails } from "@vortexfi/shared"; ``` -### Get fiat token details +Do not import this directory through an app-relative path. -```typescript -import { getAnyFiatTokenDetails, FiatToken } from 'signer-service/src/config/tokens'; - -const eurcDetails = getAnyFiatTokenDetails(FiatToken.EURC); -console.log(eurcDetails.fiat.name); // 'Euro' -``` - -### Check token type - -```typescript -import { isEvmToken, getOnChainTokenDetails, Networks, EvmToken } from 'signer-service/src/config/tokens'; - -const tokenDetails = getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC); -if (isEvmToken(tokenDetails)) { - console.log(tokenDetails.erc20AddressSourceChain); -} -``` +## Changing tokens -## Extending +When adding or removing a token: -To add a new token type: +1. Update the relevant enum and detail type. +2. Add or remove every supported network configuration and address. +3. Update normalization and type guards when the new value changes their exhaustiveness. +4. Check all `Record` values. `FiatToken` currently contains `EURC`, + `ARS`, `BRL`, `USD`, `MXN`, and `COP`. +5. Add or update lookup/configuration tests. +6. From the repository root, run `bun build:shared`, then test/typecheck the consumers. -1. Create a new type definition in `types/` -2. Add the token type to the `TokenType` enum in `types/base.ts` -3. Create a new configuration file in a dedicated directory -4. Add type guards in `utils/typeGuards.ts` -5. Add helper functions in `utils/helpers.ts` -6. Export everything in `index.ts` +Chain addresses, decimals, payment rails, and `supportsRamp` flags are runtime behavior; +review them with the matching security spec when they affect a live corridor. diff --git a/packages/shared/src/tokens/constants/misc.ts b/packages/shared/src/tokens/constants/misc.ts index 6d9f7eaad..3bfa16283 100644 --- a/packages/shared/src/tokens/constants/misc.ts +++ b/packages/shared/src/tokens/constants/misc.ts @@ -44,7 +44,6 @@ export function getNablaBasePool( throw new Error(`getNablaBasePool: no Nabla pool on Base supports the pair ${inputTokenAddress} -> ${outputTokenAddress}`); } -export const SPACEWALK_REDEEM_SAFETY_MARGIN = 0.05; export const AMM_MINIMUM_OUTPUT_SOFT_MARGIN = 0.02; export const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index 7f9cd25b1..aa1b30ca0 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -36,6 +36,15 @@ export const evmTokenConfig: Record> = }, maxBuyAmountRaw: "10000000000", maxSellAmountRaw: "10000000000", - minBuyAmountRaw: "1000000", - minSellAmountRaw: "25000000", + minBuyAmountRaw: "500000", + minSellAmountRaw: "500000", type: TokenType.Fiat }, [FiatToken.USD]: { diff --git a/packages/shared/src/tokens/utils/helpers.ts b/packages/shared/src/tokens/utils/helpers.ts index 8cf75c120..8cfe69f69 100644 --- a/packages/shared/src/tokens/utils/helpers.ts +++ b/packages/shared/src/tokens/utils/helpers.ts @@ -53,14 +53,6 @@ export function getOnChainTokenDetailsOrDefault( onChainToken: OnChainTokenSymbol, dynamicEvmTokenConfig?: Record>> ): OnChainTokenDetails { - // AXLUSDC doesn't exist Ethereum - if (onChainToken === EvmToken.AXLUSDC && network === Networks.Ethereum) { - const usdcDetails = getOnChainTokenDetails(network, EvmToken.USDC, dynamicEvmTokenConfig); - if (usdcDetails) { - return usdcDetails; - } - } - const maybeOnChainTokenDetails = getOnChainTokenDetails(network, onChainToken, dynamicEvmTokenConfig); if (maybeOnChainTokenDetails) { return maybeOnChainTokenDetails; diff --git a/relayer-contract/SECURITY_AUDIT.md b/relayer-contract/SECURITY_AUDIT.md deleted file mode 100644 index 256cb5a7f..000000000 --- a/relayer-contract/SECURITY_AUDIT.md +++ /dev/null @@ -1,52 +0,0 @@ -# Token Relayer Smart Contract - Security Audit Report - -## 1. Executive Summary -A comprehensive security review was conducted on the `TokenRelayer.sol` smart contract. The contract is designed to act as a secure intermediary that accepts ERC20 permit signatures alongside an EIP712 arbitrary payload signature, executing pre-approved calls to a designated immutable destination contract. - -**Conclusion:** The smart contract demonstrates exceptional adherence to modern Solidity security best practices and robustness. There are **no critical or high-severity vulnerabilities**. The architecture handles common pitfalls intelligently, particularly regarding strict token isolation, signature protection, and front-running resilience. - ---- - -## 2. Key Security Highlights & Best Practices Implemented - -* **Permit Front-Running Resilience:** The contract successfully neutralizes front-running Denial-of-Service (DoS) attacks on `permit`. By elegantly wrapping `permit` execution in a `try-catch` block, any malicious extraction of the permit into the mempool will simply trigger the fallback allowance check, allowing the primary payload to execute without disruption. -* **Strict Token Approval Isolation:** The relayer implements precise exposure bounds. Before forwarding the transaction to `destinationContract`, the relayer invokes `forceApprove` strictly for `params.token` bounded by `params.value`. This ensures that even if a malicious user invokes the relayer using fake ERC20 tokens, they cannot exploit residual balances of other tokens stuck inside the relayer's possession. -* **Immutable Destination Security:** `destinationContract` is hardcoded at deployment. This severely reduces the attack surface for arbitrary `_forwardCall` exploits since execution paths are statically restricted to one verified application. -* **Trapped Asset Protection:** `_forwardCall` inherently propagates exactly `msg.value` rather than indiscriminately pushing `address(this).balance`. Any un-withdrawn ETH residing in the relayer cannot be accidentally or maliciously weaponized. -* **Replay and Malleability Protections:** Utilizes OpenZeppelin’s `ECDSA.recover` to avoid signature malleability loopholes (rejecting high-S values). Implementing OpenZeppelin's `EIP712` correctly anchors execution to the deployed `chainId` and contract address, rendering cross-chain replays strictly impossible. - ---- - -## 3. Findings & Architectural Considerations (Low / Informational) - -### 3.1 Unspent Token Stranding (Informational) -**Description:** -When the relayer invokes `IERC20(params.token).safeTransferFrom` into `address(this)` and subsequently forces approval to the `destinationContract`, it assumes the destination contract will entirely consume `params.value`. If the `destinationContract` uses fewer tokens than deposited (e.g., executing a swap with a highly favorable slippage outcome), the unspent remainder tokens are stranded inside the `TokenRelayer` contract instead of automatically sweeping back to the user. - -**Risk/Impact:** -Users may experience a loss of their unspent excess unless the central operator sweeps via the `withdrawToken` administrative function sequentially to return them. - -**Recommendation:** -If `destinationContract` dynamics naturally lead to unpredictable leftover unspent balances, implement a local balance check on the relayer before and after execution to explicitly refund the unused token difference back to `params.owner`. - -### 3.2 Detached Permit Signature Arguments (Informational) -**Description:** -`params.permitV`, `params.permitR`, `params.permitS`, and `params.deadline` are executed outside the EIP712 payload digestive hashing. Let it be explicitly known that these parameters theoretically face on-chain mutation from MEV extraction bots intercepting the mempool. - -**Risk/Impact:** -Mutation of these values strictly disrupts the `try` block, subsequently failing the payload execution because no pre-existing allowance exists. The overarching payload logic cannot be altered, averting any financial vector escalation. - -**Recommendation:** -No immediate action is needed, but acknowledging their deliberate omission from the primary signature ensures accurate context for future upgrades. - -### 3.3 Single Hardcoded Destination Structure (Design Note) -**Description:** -Restricting calls strictly to a singular `destinationContract` offers spectacular lateral protection but inherently sacrifices composability if multiple operational destinations are anticipated in future versions. - -**Recommendation:** -Currently safe. If future designs require multiplexing multiple destinations, extreme caution regarding recursive call-bombing or arbitrary balance extraction must be enforced. - ---- - -## 4. Final Verdict -The `TokenRelayer.sol` contract introduces a highly secure and robust execution standard. The development exhibits sharp awareness of front-running patterns, safe external interactions, and proper standard protocol implementations (EIP-712 / EIP-2612). It is cleared for deployment and utilization.